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>
The capability/target-restricted token model for the agency portal (#73 option B),
proven before any endpoint sits on it:
- 'agency' scope value is OFF the read/write/full ladder, so the existing tokenScopeGate
rejects it on every public router by construction (auto-confinement, no new code).
- api_token_targets join table: which playlists an agency token may act on.
- agencyGate: THE single seam - agency-scope-only + (playlist in this token's allowlist
AND in the bound workspace), one query enforcing target + cross-workspace isolation.
- AGENCY_ROUTERS category in config/api-surface.js (mounted with agencyGate, not
tokenScopeGate) - declared; router/mount land with the endpoints.
Both bite-tested: spine (agency 403s on tokenScopeGate; read/write still pass) and the
gate (non-designated/cross-workspace/non-agency/JWT -> 403; neutralizing the target check
goes red). NARROW - not the general capability-scope system.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- routes/tokens.js: create (returns the full secret once), list (never the secret),
revoke. Mounted JWT-only via api-surface.js so an API token can never mint, list or
revoke tokens - no self-escalation.
- Settings "API Tokens" section: create form (name + read/write/full scope), one-time
secret reveal with copy, token list, revoke; i18n across en/es/fr/de/pt.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>