screentinker/server/config/api-surface.js
ScreenTinker 73ca3cf258 feat(api): scoped API token foundation + secure-by-exclusion mounts
Introduce the public API's token layer and make the router partition data-driven.

- api_tokens table: SHA-256 hashed secret, st_ prefix, workspace-bound, read/write/full scope.
- middleware/apiToken.js: bearerAuth front door (Bearer st_ -> token auth, else the
  unchanged requireAuth); apiTokenAuth acts as the owner with platform powers stripped
  to 'user' and the workspace binding made authoritative (X-Workspace-Id ignored);
  tokenScopeGate (read=GET, write=mutations) + requireScope('full') for commands.
- config/api-surface.js: single source of truth for the PUBLIC (token front door) vs
  JWT-ONLY (requireAuth) router partition. server.js mounts from these lists so the
  mount list and the partition firewall test cannot drift.
- device-groups: operational group commands (reboot/shutdown) require the full scope.

A Bearer st_ token fails jwt.verify on the JWT-only routers (401), so privileged
surfaces (admin, workspaces, ai, provision, white-label) are unreachable by exclusion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 18:45:09 -05:00

51 lines
2.6 KiB
JavaScript

'use strict';
// SINGLE SOURCE OF TRUTH for the API router partition.
//
// server.js mounts from these two lists; test/api.test.js (the partition firewall
// test) asserts against the SAME lists. Because both read this one file, the mount
// list and the test cannot drift: add a router to PUBLIC_ROUTERS and it gets the
// token front door AND the firewall test covers it; the day a JWT-only router stops
// returning 401 to a `Bearer st_` token (e.g. someone gives it the token door), CI
// fails. This is the firewall-rule-as-code.
//
// PUBLIC_ROUTERS - token-reachable. Mounted with the bearerAuth front door +
// resolveTenancy + tokenScopeGate. A scoped API token AND a JWT
// session both reach these.
// JWT_ONLY_ROUTERS - requireAuth only (no token front door). A `Bearer st_` token
// fails jwt.verify -> 401, so these are unreachable by any token
// (secure by exclusion). Privileged surfaces live here.
//
// Per-entry flags:
// renderBypass: also exposes a public GET /:id/render (device render) that skips auth.
// tenancy: JWT-only router also runs resolveTenancy (acts on the caller's active
// workspace). Routers without it target a workspace by URL/body param
// and are gated per-handler (e.g. canAdminWorkspace).
const PUBLIC_ROUTERS = [
{ path: '/api/devices', mod: './routes/devices' },
{ path: '/api/content', mod: './routes/content' },
{ path: '/api/folders', mod: './routes/folders' },
{ path: '/api/assignments', mod: './routes/assignments' },
{ path: '/api/layouts', mod: './routes/layouts' },
{ path: '/api/widgets', mod: './routes/widgets', renderBypass: true },
{ path: '/api/schedules', mod: './routes/schedules' },
{ path: '/api/walls', mod: './routes/video-walls' },
{ path: '/api/reports', mod: './routes/reports' },
{ path: '/api/groups', mod: './routes/device-groups' },
{ path: '/api/playlists', mod: './routes/playlists' },
{ path: '/api/activity', mod: './routes/activity' },
{ path: '/api/kiosk', mod: './routes/kiosk', renderBypass: true },
];
const JWT_ONLY_ROUTERS = [
{ path: '/api/ai', mod: './routes/ai', tenancy: true },
{ path: '/api/provision', mod: './routes/provisioning', tenancy: true },
{ path: '/api/teams', mod: './routes/teams', tenancy: true },
{ path: '/api/white-label', mod: './routes/white-label', tenancy: true },
{ path: '/api/workspaces', mod: './routes/workspaces' },
{ path: '/api/admin', mod: './routes/admin' },
];
module.exports = { PUBLIC_ROUTERS, JWT_ONLY_ROUTERS };