screentinker/server/config/api-surface.js
ScreenTinker 7eab9c6092 PiP overlay MVP: push image/web overlays to a device or group (#109)
Implements the #109 MVP from docs proposal: a floating overlay PUSHED to a device or
group in real time, rendered above the playlist without disturbing it. Scope is the
MVP only — video/RTSP, MQTT, offline-queue, and the priority/stacking system are
deferred to follow-up PRs as the proposal specifies.

Protocol (/device socket, player-agnostic):
- device:pip-show { pip_id, type:image|web, uri, position, width, height, duration,
  title?, title_color?, background_color?, opacity?, border_radius?, close_button? }
- device:pip-clear { pip_id? }
The player fetches uri itself (same trust model as remote_url content; server never
proxies). type:web is full-trust by design, hence the 'full' token scope.

Server (server/routes/pip.js, new; mounted in config/api-surface.js PUBLIC_ROUTERS):
- POST /api/pip and POST /api/pip/clear + DELETE /api/pip, all requireScope('full').
- Resolves device_id to a device OR a group, expands a group to members, and emits
  per-device — reusing the group command route's room-size online check and
  {device_id, name, status: sent|offline} result shape. Generates pip_id.
- Validates type/position allowlists, uri http(s), numeric bounds on
  width/height/duration/opacity/border_radius, colors via the existing VALID_COLOR
  (#RRGGBB; transparency is the separate opacity field).
- Workspace-isolated: every target query is scoped to req.workspaceId, so a token
  bound to workspace A can't address workspace B (404). Offline devices are reported,
  never queued (PiP is ephemeral).

Player overlay layer (Tizen; tizen/js/pip-overlay.js, new):
- A #pip sibling ABOVE #stage that PlaylistPlayer/ZoneRenderer never touch.
- applyOrientation now applies the SAME transform to #pip as #stage, so corner
  positions track the visible CONTENT in all four orientations.
- image -> <img>, web -> <iframe> (muted by default: empty allow= denies autoplay),
  sized/positioned/styled per payload, optional title bar.
- Single overlay slot, last-show-wins; duration timer (0 = until cleared); pip-clear
  (id-aware) or timer tears down; teardown wrapped so a malformed payload can't wedge
  the layer. Reports show/clear over device:log (tag 'pip').

Dashboard: a minimal "Send overlay" / "Clear overlay" tester on the device-detail
controls (device/group via the open device, type, uri, position, duration), calling
POST /api/pip through the api helper.

Tests (server suite green, 161/161):
- api.test.js: PiP tier — authz (read/write 403, full passes), workspace isolation
  (wsA token -> wsB device 404), payload validation, device + group targeting, clear;
  plus the PUBLIC_ROUTERS snapshot-firewall updated for /api/pip.
- pip-overlay.test.js: loads the real player.js + pip-overlay.js in a vm with a DOM
  shim; proves the overlay shows, auto-dismisses on the duration timer, and never
  changes the playlist signature / touches #stage; web->iframe, last-show-wins,
  id-aware clear, malformed-payload safety.

Not in this PR (intentional):
- Android player overlay — fast-follow. Protocol + server are player-agnostic; the
  Android layer (an overlay View above the player, orientation-matched to MainActivity's
  rootView rotation) is the same shape and lands next.
- OpenAPI docs for POST /api/pip — the contract test's scope heuristic only treats
  'command' paths as full-scope, so documenting a full-scope non-command route there
  needs that heuristic extended first; deferred with the docs item (proposal §8.6).
- video/rtsp types, MQTT, offline queue-on-reconnect, priority/stacking, arbitrary
  (x,y)/selector positioning (proposal §6).

Refs #109

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

62 lines
3.3 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 },
{ path: '/api/pip', mod: './routes/pip' },
];
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' },
{ path: '/api/tokens', mod: './routes/tokens', tenancy: true },
];
// #73: AGENCY_ROUTERS - capability-restricted ('agency' scope) surface. Mounted with
// bearerAuth + resolveTenancy + agencyGate (NOT tokenScopeGate). An 'agency' token is
// OFF the read/write/full ladder, so tokenScopeGate rejects it on every PUBLIC_ROUTER -
// it can reach ONLY this router, and only its allowlisted playlists in its bound
// workspace (agencyGate enforces both). read/write/full tokens and JWTs are rejected here.
const AGENCY_ROUTERS = [
{ path: '/api/agency', mod: './routes/agency' },
];
module.exports = { PUBLIC_ROUTERS, JWT_ONLY_ROUTERS, AGENCY_ROUTERS };