Commit graph

375 commits

Author SHA1 Message Date
ScreenTinker 0cd2a904e5 Android player: video-wall (wall:sync) support
Ports the wall:sync protocol the web and Tizen players already ship to native
Kotlin/ExoPlayer, so the Android player can join a video wall.

- WallController (new): 4Hz leader broadcast; follower latency-compensated drift
  controller (hard-seek past 0.3s, gentle +/-3% playbackRate nudge past 0.05s);
  role handling with immediate align on entry and on wall:sync-request. Per-tile
  rotation intentionally not applied (web/Tizen parity; left as a TODO).
- MediaPlayerManager: expose position/duration/seekExact/setSpeed for the drift
  controller; RESIZE_MODE_FILL / ImageView FIT_XY in wall mode (object-fit:fill
  parity), restored to fit/fitCenter on exit. Follower mute (setWallMute) persists
  across leader-driven item switches, and followers loop (REPEAT_MODE_ONE) so they
  never freeze on the last frame if the leader's next index is late.
- PlaylistController: wallFollower flag suppresses auto-advance (leader drives the
  index); getIndex/gotoIndex for follower tracking; itemStartedAtMs for non-video
  sync position.
- WebSocketService: onWallSync/onWallSyncRequest handlers (posted to the main
  thread since they drive ExoPlayer) + emitWallSync/emitWallSyncRequest senders
  guarded on socket.connected() like sendPlaybackState.
- MainActivity: parse wall_config in onPlaylistUpdate and branch before the
  orientation + multi-zone paths; size/translate rootView to this screen's slice;
  exit() restores full screen.

Compiles clean (./gradlew :app:assembleDebug). NOT yet validated on a device or a
real wall — the ExoPlayer seek/speed sync and the slice transform need on-device
tuning before this is trusted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 13:16:29 -05:00
ScreenTinker 10726fde42 Merge #117: HIDE_BILLING flag to hide the Subscription/billing UI (#116)
Some checks failed
CI / Unit tests (node --test) (push) Has been cancelled
CI / OpenAPI spec lint (push) Has been cancelled
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Has been cancelled
CI / Boot smoke + version check (push) Has been cancelled
2026-06-16 09:57:17 -05:00
ScreenTinker 674a34ba45 feat(config): HIDE_BILLING flag to hide the Subscription/billing UI (#116)
Opt-in, default-off UI gate (per strobe's spec; verified his file refs first).
When set, hides the Subscription sidebar item + billing view and bounces
#/billing to the dashboard. Billing shown by default -> existing deployments
unchanged. UI-only: /api/subscription/* untouched (internal usage reads stay).

- config.js: config.hideBilling from HIDE_BILLING (mirrors selfHosted).
- auth.js: surface hide_billing on GET /api/auth/me (client already fetches it
  at boot, stored on the user object).
- index.html: id="billingNavItem" on the Subscription <li> (mirrors adminNavItem).
- app.js: toggle billingNavItem in updateSidebarUser (next to the admin toggle);
  guard #/billing -> history.replaceState('#/') + render dashboard (replaceState
  so the back button doesn't loop into the guard).
- .env.example + README documented.

Spec assumptions verified against code: adminNavItem toggle pattern exists;
/me is fetched at boot and updateSidebarUser runs both at boot (cached user)
and post-/me, so no-flash holds on warm loads (one-time flash possible on the
first load after the flag flips — same as the admin nav, minor); route dispatch
is an if/else chain. Nav label is static (no data-i18n) so no i18n change.

Validated (headless Chrome, both states):
- flag unset -> Subscription tab present, #/billing renders (backward-compat).
- HIDE_BILLING=true -> tab hidden, #/billing redirects to #/.
- config maps HIDE_BILLING both ways; live /me default hide_billing=false.
- 149 server tests green. Default-off = zero change for existing deployments.

Known cosmetic (harmless): after the redirect the billing nav LINK keeps its
'active' class, but the nav item is display:none so it's never visible.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 09:19:24 -05:00
ScreenTinker 5b13254de3 chore(release): v1.9.1-beta3
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
2026-06-15 16:07:04 -05:00
ScreenTinker c5e8067b35 Merge #115: last-resort uncaughtException safety net (#114) 2026-06-15 16:02:18 -05:00
ScreenTinker 78a4ee4d37 fix(server): last-resort uncaughtException/unhandledRejection safety net (#114)
A FK constraint violation crashed the whole process on 1.9.1-beta2 with a
bare "FOREIGN KEY constraint failed" and NO stack — so it couldn't be root-
caused. better-sqlite3 is synchronous, so such a throw inside a socket.io
handler (no local try/catch) propagates to uncaughtException, and with no
handler Node exits traceless.

Add a small top-of-server.js net that logs the FULL err.stack (file:line of
the offending write) + timestamp, best-effort closes the DB (WAL flush), then
exits(1) so systemd restarts fresh. NOT catch-and-continue — after an uncaught
throw the process state is undefined, so we never keep serving. This is the
investigation tool the root-cause fix is blocked on, plus the fleet-wide-crash
net #114 asked for.

Verified (not assumed):
- A synthetic synchronous FK throw inside a real socket.io handler IS caught by
  uncaughtException, logs the full stack incl. the throwing file:line, exits 1.
- Non-over-reach: a FK throw in an Express route -> Express handles it (500), a
  throw in a local try/catch -> caught (200); the global net does NOT fire and
  the process stays alive. Last resort, not a catch-all.
- 149 server tests green; server boots clean (net doesn't trip on startup).

The root-cause FK fix is SEPARATE and waits on the stack trace this produces.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:57:17 -05:00
ScreenTinker 7539603b17 Merge #111: device-free preview, playlist + device surfaces (#104) 2026-06-15 15:20:57 -05:00
ScreenTinker 647a7de1e6 Merge #112: duplicate + replace playlist items (#105) 2026-06-15 15:20:51 -05:00
ScreenTinker d2feb2a3c5 Merge #113: drag-to-reorder display tiles (#106) 2026-06-15 15:20:17 -05:00
ScreenTinker 5d24c30ea1 feat(displays): drag-to-reorder display tiles within a section (#106)
Option A: tile-on-tile (same section) reorders; tile-on-section / cross-
section stays group-assign (existing behavior untouched). Ordering is
cosmetic (dashboard only — nothing the device/player reads).

Backend:
- Migration: devices.sort_order column (idempotent ALTER; default 0).
- GET /api/devices ordering: sort_order ASC, created_at ASC (was created_at).
- POST /api/devices/reorder — ordered id array -> transactional
  UPDATE sort_order=index, scoped WHERE workspace_id = caller's workspace
  (forged cross-workspace ids are no-ops). Write-gated (viewer read-only).
  Mirrors the playlist items reorder.

Frontend (the collision):
- Card-level dragover/drop: reorder ONLY when target is another card in the
  SAME section; otherwise no-op so the event bubbles to the section's
  group-assign handler. stopPropagation on the same-section drop prevents
  the section handler also firing. Drop indicator (inset box-shadow).
  Native HTML5 DnD; no library.

Validated (headless Chrome, synthetic DnD + a section-level drop spy):
- SAME-section reorder: section drop suppressed (sectionDrops=0), POST
  /devices/reorder fires, NO group call, sort_order persists in DB.
- CROSS-section: section drop fires (sectionDrops=1), POST /groups/:id/
  devices fires and membership actually changes — group-assign unbroken.
- The 0-vs-1 contrast proves stopPropagation disambiguates the shared gesture.
- 149 server tests green; migration applies clean on the prod-copy DB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:15:21 -05:00
ScreenTinker cbabbeb78c feat(preview): device-manager preview — second surface for #104 (combined)
Completes #104's two surfaces by reusing the now-generalized player preview
for devices, seam-safe (device-bound layout, NOT playlist-derived).

Server:
- GET /api/devices/:id/preview-payload returns buildPlaylistPayload(deviceId)
  — the device's OWN layout/orientation (device row) + its published items —
  with wall_config forced null (v1: wall members preview full-frame; a
  socket-free follower would otherwise freeze waiting for leader wall:sync).
  Device-READ gate (mirrors GET /:id, viewers allowed); NOT requirePlaylistRead.

Player (generalized, shared seam):
- Boot dispatch now accepts ?preview=1 with EITHER playlist=ID OR device=ID.
- bootPreview(qs) builds the right URL; shared body factored into
  renderPreviewFromUrl(url) used by both. Renderer still UNTOUCHED.
- derivePreviewLayout stays PLAYLIST-only; never touches the device path.

Dashboard:
- Device manager gets a Preview button -> /player?preview=1&device=ID
  (modal iframe, aspect from device orientation). Playlist-view button as-is.
- i18n x6 (device.preview_btn).

Validated (not just tests): 149 server tests green (generalization didn't
break the playlist path); device preview renders socket-free in headless
Chrome; layout proven device-bound on real data (device playlist has 0 zoned
items -> playlist-derivation would give NULL, but payload returns the device
row's "Vertical Full HD"); wall-member device previews full-frame (inWallMode
false) without freezing; auth gate outsider->403, no-token->401; playlist
path still renders the webpage note post-refactor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:57:19 -05:00
ScreenTinker e6ebf2a380 feat(playlists): duplicate + replace playlist items in place (#105)
Duplicate and Replace per-item actions, both leaning on the normalized
playlist_items schema (only content_id/widget_id/zone_id/sort_order/
duration_sec; type-specific fields are JOINed at snapshot time).

- Replace: extend PUT /:id/items/:itemId to accept a content_id/widget_id
  swap. Clean FK swap across ANY content type (image<->video<->youtube<->
  widget) — sets one, nulls the other, preserving zone_id/duration/
  sort_order/schedule rows. Only acts when content_id|widget_id is present,
  so partial PUTs are unaffected. Workspace-validated; markDraft.
- Duplicate: new POST /:id/items/:itemId/duplicate — copies the row +
  its schedule blocks (new ids) in one transaction, appended (sort_order
  MAX+1). markDraft.
- Frontend: Replace + Duplicate icon buttons per item; Replace reuses the
  add-item picker in a replaceItemId mode (PUT instead of POST). i18n x6.

Validated end-to-end against the live API: duplicate (incl. schedule copy
with distinct ids), replace same-type and cross-type both directions,
preservation of duration/schedule/zone, and validation (both->400,
missing->404). 149 server tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:36:19 -05:00
ScreenTinker 1c748b8d3b feat(preview): draft-aware device-free playlist preview via player reuse (#104)
Replaces the broken/fragmented preview with a single surface that renders a
DRAFT playlist exactly as a device does, by reusing the player's renderer in a
same-origin iframe. Fixes "not all items load" (one renderer, full type union)
and inherits the player's YouTube correctness (YT.Player handshake).

Server:
- deviceSocket: extract assemblePayload() (zone-reset + canonical shape) from
  buildPlaylistPayload so the device path and preview can't drift. Pure refactor
  (all 149 tests green).
- playlists: GET /:id/preview-payload (requirePlaylistRead, workspace-scoped).
  Draft-aware via buildSnapshotItems (live items, not published_snapshot);
  derivePreviewLayout() resolves layout from the playlist's own zone-bound items
  (0 zoned -> fullscreen; 1 -> use it; >1 -> dominant + ambiguous flag, never
  crashes). orientation validated/passthrough; wall_config/timezone null.

Player (renderer UNTOUCHED):
- ?preview=1&playlist=ID boot branch: fetch preview-payload (same-origin Bearer
  token) and call handlePlaylistUpdate(). Gated before the pairing/socket path
  so the unpaired auto-connect never fires. All socket emits already guarded.
- Webpage widgets: always-visible honest note (no auto-detection — an XFO
  refusal is provably indistinguishable client-side from a working embed).

Dashboard:
- playlists: Preview button + player-iframe modal with landscape/portrait toggle.
- widgets: same honest note on the existing widget preview modal (the surface the
  bug was reported on).
- i18n x6 (en/es/fr/de/it/pt) + player i18n x5.

Validated end-to-end (headless Chrome + CDP): preview boots, webpage note
renders, 3-zone layout derives+renders, shape parity with device snapshot proven
on real data, auth gate returns 401. The world-readable /uploads finding is
tracked separately as #107 (not a #104 concern — same path the device uses).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:11:05 -05:00
ScreenTinker d64244b5ac chore(release): v1.9.1-beta2
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
2026-06-14 20:34:21 -05:00
ScreenTinker a21843818c fix(release): bump-version handles a pre-release CURRENT version
The package.json + android versionName seds matched only a clean X.Y.Z, so bumping FROM a
pre-release (1.9.1-beta1 -> 1.9.1-beta2) left those two files stale while VERSION advanced -
an inconsistent release. Allow a [^"]* suffix so the current value's -beta1 is matched and
replaced. Still matches clean versions (regression-checked).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 20:33:37 -05:00
ScreenTinker fbd466b7f2 fix(docker): copy docs/openapi.yaml into the image so /docs's spec resolves
The /openapi.yaml route does res.sendFile(../docs/openapi.yaml) -> /app/docs/openapi.yaml in
the container, but the Dockerfile copied server/, frontend/, VERSION, scripts/ and never docs/,
so the spec 404'd in every deployed build (Redoc's /docs page loaded but couldn't fetch the
spec). Served fine from a dev checkout only because the repo has docs/ on disk. Verified in a
built image: /app/docs/openapi.yaml present, GET /openapi.yaml -> 200 text/yaml.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 20:32:30 -05:00
ScreenTinker 31be2ffe8c Merge feat/agency-tokens: agency upload portal (#73), full-screen guardrail, YouTube preview referrer fix
20 commits: agency-token security primitive + portal + designate UI + auto-publish + email
digest + size-guidance card + edit-designations + full-screen-only guardrail; zone-binding
reverted; API docs link; portal handoff at creation; YouTube content-preview 153 fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 20:27:10 -05:00
ScreenTinker 46e4bc8579 fix(content): YouTube preview 153 — give the iframe a referrer (page is no-referrer)
ROOT CAUSE (hard evidence this time, from the response headers): the app sends
Referrer-Policy: no-referrer globally (helmet default). A raw YouTube iframe then reaches
youtube.com with NO Referer, so YouTube can't identify the embedding site and shows "Video
player configuration error" (153). Confirmed by the three facts: the same /embed URL plays in
a top-level tab (no embed check), plays in the device player (YT.Player loads iframe_api and
validates via an ORIGIN postMessage handshake, which doesn't need Referer), and fails only as
a raw iframe on a no-referrer page. The player's page is ALSO no-referrer, proving it's the
embed method that saves it, not the headers.

Fix: add referrerpolicy="strict-origin-when-cross-origin" to the preview iframe — overrides
the page's no-referrer for just this element so YouTube receives our origin and validates the
embed. Scoped (only the YouTube embed sends a referrer; only the origin, not the path), no JS
API machinery needed for a passive preview, page-level no-referrer untouched.

Supersedes the earlier enablejsapi/origin strip, which was inert (those params do nothing in
a raw iframe with no IFrame API). Frontend-only; suite 149 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 20:12:57 -05:00
ScreenTinker 7f7dc80a8c fix(content): YouTube preview 153 — drop enablejsapi/origin from the passive embed
The content-tab preview embedded a RAW iframe with enablejsapi=1 (baked into the stored
/embed URL by /youtube) plus origin=window.location.origin — but the content tab loads the
YouTube IFrame API zero times. enablejsapi=1 + origin tells YouTube's player to expect a
postMessage handshake from a parent JS API that never exists here, which surfaces as "Video
player configuration error" (153). Same-video proof: it plays on the device player (which
loads iframe_api + uses YT.Player, so the handshake completes) and failed only on the content
tab — so it was never a video/embeddability problem, purely the embed construction.

Fix: the preview is passive (never drives playback via JS), so it must not declare the JS API
— strip enablejsapi + origin, leaving a plain /embed/ID (the form that plays in a bare tab).
Did NOT touch /youtube storage (the player extracts the videoId and ignores stored params, so
the baked-in enablejsapi is harmless there). Retracts the earlier wrong "validate
embeddability at add-time" diagnosis (never built — it would have rejected this embeddable
video). Frontend-only; suite 149 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:18:58 -05:00
ScreenTinker ed45a9a23d feat(ui): surface the agency portal handoff at token creation (#73)
When an agency token is created, the once-shown secret box now also shows the Portal URL
(window.location.origin + '/agency' — the real public host the admin is on, correct behind
Cloudflare, config-free) and a COPYABLE INVITE: "Go to <url> and paste this access key:
<key>". The key lives in the invite TEXT, never in a URL — no magic link, because Cloudflare
logs query strings and chat apps unfurl links (the key would leak on paste). Same exposure as
the key field itself, just with the destination surfaced. The existing "won't see it again"
warning now covers the invite too (it contains the key). i18n x5 (parity test).

Skipped the optional per-row portal URL in the token list: it's the same /agency for every
agency token, so per-row it's noise; the creation invite + the /docs link cover discovery.

Confirmed: invite copy button copies the full "go here + paste key" text; /agency resolves
(200); i18n parity + full suite green (149).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 17:54:23 -05:00
ScreenTinker 02859eb1aa feat(ui): surface the API docs link in Settings -> API Tokens (#73)
A meaningful link to /docs right under the section header (where someone's creating a token),
opening in a new tab (target=_blank rel=noopener) so it doesn't navigate them away from the
token they're mid-creating. "New to the API? See the full documentation ->" across all 5
locales. /docs (Redoc) already existed; this just makes it discoverable. Confirmed /docs ->
200 Redoc and /openapi.yaml -> 200.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 17:44:42 -05:00
ScreenTinker 57d78dd1fa feat: full-screen-only guardrail for agency designations (#73)
Agencies can only be designated FULL-SCREEN playlists (no item with zone_id) - a full-screen
agency upload can't safely target a zone, so the ambiguous case is excluded rather than
solved. Checked at THREE points:
- Designation (tokens.js create + PUT /:id/targets) -> 400: reject a zoned target.
- Upload (agency.js item-add) -> 409: block if the playlist BECAME zoned after designation.
  MANDATORY because auto-publish has no draft net - a full-screen playlist designated to an
  auto-publish token, then zone-assigned, would otherwise auto-publish a full-screen upload
  into a zoned playlist. The upload check is the only thing that catches it.
- Picker (settings.js): zoned playlists greyed/disabled with the reason (GET /playlists now
  returns a zoned flag); backend reject is the guard if the UI is bypassed. i18n x5.

isZonedPlaylist = EXISTS(playlist_items WHERE zone_id IS NOT NULL). Pure restriction - no
zone structure, no api_token_target_zones.

Bite-test (the exact sequence) GREEN and re-proven to bite: full-screen -> designate to an
auto-publish token -> zone-assign the playlist -> agency upload is BLOCKED (409), not
auto-published; neutralizing the upload check makes it go red. 149 suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 17:36:30 -05:00
ScreenTinker 4c38536cc6 feat(ui): edit-designations for agency tokens (#73)
Settings → API Tokens: each agency token gets an "Edit playlists" control that opens the
playlist picker pre-checked with the token's CURRENT designations (from the list GET's
tok.targets), lets the admin add/remove, and calls the existing PUT /:id/targets to
atomically re-designate. Reuses the creation picker pattern; common.save/cancel reused;
edit_targets + targets_updated i18n across all 5 locales. No security-model change - the
endpoint was already proven.

Test (integration): PUT /:id/targets re-designates (add + remove) and the confinement
follows the NEW set - a re-designated token reaches only its new playlists (router.param
403s the removed one). 148 suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 17:04:07 -05:00
ScreenTinker 6ea8100aeb feat(ui): the size-guidance card container in the agency portal (#73)
The #placementCard / #layoutView elements that agency-portal.js's reactive
loadLayoutForPlaylist() renders into. Was built with the card logic but never folded into a
commit; without it the size-guidance card has nowhere to render. Pure markup, no behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 16:10:25 -05:00
ScreenTinker 400a438fff revert: drop zone-binding, keep whole-playlist grants + size-guidance card (#73)
Investigation found zone placement is a DEVICE property (device.layout_id), not a playlist
property: a normal playlist has no derivable layout (zone_id is NULL unless set in the
device-assignment flow), so a playlist-scoped zone grant can't reach the normal flow. The
right model: placement belongs to the device (same playlist can be full-screen on one screen,
a zone on another); the agency just gets whole-playlist grants + size-guidance.

Removed the zone-grant machinery (security-adjacent dead surface is a liability, not dormant
convenience): api_token_target_zones (schema + a DROP migration for the dev DB where the
short-lived CREATE ran), resolveGrantedZone, grantableZoneIds, buildZoneGrantRows, the
create/PUT zone validation, GET /api/playlists/:id/zones, getPlaylistZones, the settings
zone-picker + its i18n, and the zone-grant bite-test.

KEPT (model-agnostic, good): the reactive per-playlist size-guidance card - GET
/api/agency/playlists/:playlistId/layout (router.param-confined) now reports the zones the
playlist actually feeds (where/what-size content lands), or full-screen when it has no layout.
Whole-playlist grants = today's working model. 147 suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 15:52:11 -05:00
ScreenTinker c5550f5bc9 feat: agency zone-grant issuance UI + reactive placement card (#73)
Issuance (on the proven seam):
- tokens.js create + PUT /:id/targets accept per-playlist zone grants (target_zones), inserted
  into api_token_target_zones inside the same transaction as the playlist grants (FK requires
  the parent, so order matters and is correct).
- Issuance validation (the mirror of runtime confinement): grantableZoneIds() - can grant ONLY
  a zone the playlist's layout actually feeds; can't grant one it doesn't have or one from
  another playlist's layout. Bite-tested. PUT re-designate stays atomic: delete parent rows ->
  zone grants cascade out (no manual child delete).
- settings.js: checking a designated playlist reveals its grantable zones (GET
  /api/playlists/:id/zones, JWT); leave unchecked = whole-playlist. i18n across all 5 locales.

Card:
- GET /api/agency/playlists/:playlistId/layout (rides router.param - confined; a non-
  designated playlist -> 403, asserted). "Your zone" = the GRANTED zones. Retired the
  token-wide /layouts (the per-playlist card replaces the disconnected lump).
- Portal card reacts to the playlist selector: pick a playlist -> its layout renders, the
  granted zone highlighted with px size, siblings as context.

Full suite + agency bite-suite green (154).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 15:12:55 -05:00
ScreenTinker 289d54f4fa feat(api): zone-grant confinement for agency tokens - FK-anchored (#73)
Placement-as-grant, replacing the inferred auto-place idea. api_token_target_zones is an
ADDITIVE second table (does NOT touch the proven api_token_targets), structurally anchored:
a composite FK to api_token_targets(token_id, playlist_id) makes a zone grant orphan-
impossible and cascade away when the playlist grant is revoked - "narrow" is structural, not
conventional. zone_id FK -> layout_zones cascades on zone/layout delete.

Confinement (lib/agency-targets.resolveGrantedZone, called in the item-add): grants exist ->
the item MUST land in a granted zone (a body zone_id picks among grants, never escapes them);
none -> whole-playlist/full-screen as before. The item-add stamps the granted zone_id.

Bite-tested (6, all proven incl. neutralize->red on the confinement): granted YES; non-
granted/cross-playlist/ambiguous blocked; orphan-grant rejected by the FK; cascade on
playlist-grant revoke, on playlist delete, on zone/layout delete; and foreign_keys=ON
asserted (a cascade that no-ops because FKs are off is the trap). 153 suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:57:27 -05:00
ScreenTinker c55ca60b56 feat(api): batched email digest for agency uploads (#73)
Reuses the existing scheduler + sendEmail infra (no new scheduler). The agency endpoint
enqueues one agency_notifications row per item added; a 15-min flush groups unsent rows per
token+playlist+action and sends ONE digest per group to the workspace owner/admins + the
playlist owner (deduped via UNION). Draft -> "added N items, awaiting approval"; published ->
"updated <playlist>".

Two robustness rules, both tested:
- Queue never balloons when SMTP is off: the endpoint skips enqueue when !isConfigured(),
  and the flush drains-and-discards unsent rows as a backstop.
- sent_at is stamped ONLY after a successful send, so a failed send retries next cycle
  instead of silently dropping.

Wired into boot via startAgencyDigest(). 147 suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 13:59:37 -05:00
ScreenTinker 986d94a778 feat(api): GET /api/agency/layouts - device-free layout geometry (#73)
So the agency can size/place content: returns the canvas size + zone positions/sizes for the
layouts its designated playlists feed, marking which zones are theirs. DEVICE-FREE BY
CONSTRUCTION - the query path is playlist_items.zone_id -> layout_zones -> layouts and never
touches devices/groups/schedules, so device names/locations/IPs/topology are structurally
absent, not filtered. Geometry only - no sibling-zone content. layout.name included (admin's
canvas name); thumbnail_data omitted (could render other zones' content).

Confinement query in lib/agency-layouts.js, bite-tested: own layout YES, a non-designated
playlist's layout NO, response has NO device fields (asserted on a db where a location-named
device exists), and neutralizing the t.token_id filter goes red. 142 suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 13:53:30 -05:00
ScreenTinker 1f207c4278 feat(api): per-agency-token auto-publish (#73)
api_tokens.auto_publish (DEFAULT 0 = draft, the fail-safe). Admin sets it at token creation
in the designate UI (checkbox, agency scope only). The agency endpoint reads it from the
TOKEN ROW via req.apiToken (apiTokenAuth attaches it) - NEVER from req.body, so an agency
can't opt itself out of approval. 0 -> markDraft; 1 -> the shared publishPlaylist path.

Tests (integration): draft is the default; a draft token with auto_publish:true IN THE BODY
still lands draft (body ignored); an auto-publish token goes live; manual publish still works
(extraction regression). i18n across all 5 locales. 141 suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 13:48:17 -05:00
ScreenTinker 79c453cd43 refactor(playlists): extract publishPlaylist() shared fn (#73)
POST /:id/publish snapshots items into published_snapshot (what devices consume) + pushes
to devices. Extracted that into publishPlaylist(id, req) so the agency auto-publish path can
call the IDENTICAL logic - a "published" playlist that wasn't snapshotted would be live on no
screen. The manual endpoint now calls it; behavior-preserving (suite green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 13:48:17 -05:00
ScreenTinker efd4d7826c feat(ui): standalone agency upload portal (#73)
Agency-facing. A self-contained page at /agency (NOT the dashboard SPA - the agency has no
JWT, only the token). Entry: paste access key -> sessionStorage (cleared on tab close, not
localStorage) -> sent as Bearer. Flow: list designated playlists -> upload (shared ingest =
first-class content) -> date-bounded item on a chosen playlist (lands as draft for admin
re-publish). Graceful failure: any 401/403 resets to the entry screen with "key invalid,
paste it again" - never a wall of 403s. Blast radius of a leaked key stays bounded by the
narrow scope.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 13:08:07 -05:00
ScreenTinker d59adfd10c feat(ui): agency token designation in Settings (#73)
Admin-facing. Extends the existing API-token UI: an 'agency' scope option reveals a
playlist picker (the workspace's playlists); creating the token binds the checked ones as
its allowlist (target_playlist_ids). The token list shows each agency token's designated
playlists (tokens GET now returns targets for agency-scoped tokens). i18n keys added across
all five locales (parity test).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 13:08:07 -05:00
ScreenTinker 6d152a5ccf feat(api): GET /api/agency/playlists - a token's designated targets (#73)
The portal needs to show an agency which playlists it may post to. New read surface on the
security primitive, built with write-path rigor: the confinement query lives in
lib/agency-targets.js (own token + bound workspace only) and is bite-tested four ways -
own targets yes; another token's, outside the allowlist, and cross-workspace all NO;
neutralizing the t.token_id filter makes it go red. Real-path wiring + the portal's
graceful 401 trigger asserted in the integration suite. No :playlistId, so router.param
doesn't apply - the query is the seam.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 13:08:07 -05:00
ScreenTinker 40102b2b41 feat(api): agency portal endpoints + router.param target seam (#73)
The agency capability behind the proven off-ladder/agencyGate primitive:
- agencyGate is now SCOPE-only at the mount; the per-target check is router.param
  ('playlistId') in routes/agency.js - it fires WITH the param before the handler, so no
  :playlistId route can skip it (drift-proof). A mount-level target check was silently
  bypassed (Express populates req.params only at route match); the integration bite-suite
  caught it - this is the fix.
- routes/agency.js: POST /content (shared ingest) + POST /playlists/:id/items (date-bounded
  #74/#75 item; lands as draft so the admin's re-publish is the approval gate).
- tokens.js: issue scope='agency' tokens bound to a non-empty in-workspace playlist
  allowlist (atomic); PUT /:id/targets re-designates (JWT-only -> can't self-widen).
- server.js: AGENCY_ROUTERS mounted bearerAuth + resolveTenancy + agencyGate.

Full bite-suite (test/agency.test.js) GREEN and re-proven to bite on the SHIPPING path:
neutralizing the router.param check makes non-designated->403 go red. Four assertions at
three seams: target (router.param), off-ladder (tokenScopeGate), can't-widen (tokens
JWT-only), issuance cross-workspace (create validation). 139 suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 22:48:42 -05:00
ScreenTinker a59b53cc25 refactor(content): extract the upload ingest into a shared lib (#73)
routes/content.js POST / processing (thumbnail/dimensions/duration) + insert moved to
lib/content-ingest.js so the agency router produces byte-identical first-class content.
content.js POST / is now a thin caller; behavior-preserving - the 52 content regression
tests (api/operator-permissions/config-paths) pass unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 22:48:42 -05:00
ScreenTinker c8a24d2243 feat(api): agency-token security primitive - off-ladder scope + agencyGate (#73)
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>
2026-06-13 21:30:38 -05:00
ScreenTinker f4c5865013 fix(server): strip totp_secret_enc/totp_last_step from login responses (#100)
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
Review caught the encrypted TOTP secret riding in the login/verify response body:
issueSession receives a SELECT * user row and only destructured out password_hash, so
totp_secret_enc (and the internal replay counter totp_last_step) leaked. Encrypted, so
not catastrophic, but it regresses the API work's "secrets never in responses" rule.
Strip both in issueSession (covers /login and /totp/verify); add an assertion that a
verify response carries no totp_secret_enc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:48:55 -05:00
ScreenTinker 728f03beba test(server): TOTP - bite, lockout, replay, recovery, st_ bypass, key-rotation (#100)
Unit: the mfa_pending BITE (db-injected so removing the rejection goes red), lockout, replay,
recovery-hash, decrypt-null graceful. Integration: enrollment, login->mfa_required, route-level
bite, recovery single-use, API-token bypass, verify lockout. Key-rotation: enroll under key A,
reboot under key B -> recovery still works, TOTP fails cleanly (no 500).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:48:55 -05:00
ScreenTinker 1d3e9acea4 feat(server): TOTP MFA login flow + enrollment/verify endpoints (#100)
Two-token login: /login returns an mfa_pending token when TOTP is on; requireAuth/optionalAuth
REJECT mfa_pending (tightening #1 - else password-alone is a session). /totp/verify exchanges
it + a TOTP or recovery code for a full session (per-user lockout; recovery checked
independently of the decryptable secret). Enrollment: setup -> enable (confirm-then-enable) ->
recovery codes shown once; disable/regenerate require re-auth; regenerate replaces atomically;
status surfaces codes-remaining (tightening #3). API tokens + SSO bypass TOTP by construction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:48:55 -05:00
ScreenTinker c38d8dc0e6 fix(server): rate-limit per endpoint, not the stripped req.path (#100)
app.use('/api/auth/login', rateLimit(...)) etc. keyed on req.path, which Express strips to
'/' for mounted middleware - so /login, /register, /totp/verify shared ONE per-IP counter
(coupled limits; the new /totp/verify brute-force limit was not actually independent). Key
on originalUrl instead. Also adds the /api/auth/totp/verify 10/min limit (tightening #2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:48:55 -05:00
ScreenTinker c02086e305 feat(server): TOTP primitives - encrypted secret, hashed recovery codes, verify lockout (#100)
lib/totp.js: otplib wrapper; secret stored via secretbox (must be reversible to recompute
codes); recovery codes SHA-256-hashed (api_tokens discipline); verifyCode returns the
matched step and blocks intra-window replay via totp_last_step; decrypt failures return
null (no throw). lib/totp-lockout.js: per-user lockout for /totp/verify (#87 model).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:48:55 -05:00
ScreenTinker e1cd8591bb chore(server): TOTP schema + otplib dep (#100)
users.totp_secret_enc (secretbox-encrypted, reversible) + totp_enabled + totp_last_step
(replay guard), and the totp_recovery_codes table (SHA-256 hashed, single-use). Migrations
default everything off so existing accounts are untouched. otplib pinned ^12 (v13 is a
breaking plugin-rewrite with no authenticator/checkDelta).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:48:55 -05:00
ScreenTinker 1f794ff7b4 chore(release): v1.9.1-beta1
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
2026-06-12 22:37:50 -05:00
ScreenTinker 6add29bf6a fix(player): auto-relaunch after OTA self-update (#96)
After the OTA installs, PACKAGE_REPLACED kills the old process and nothing brought
MainActivity back, so updating screens dropped to the launcher (the 1.9.0 fleet bug). Add a
MY_PACKAGE_REPLACED receiver that relaunches via a shared Relauncher cascade (extracted from
BootReceiver so boot + post-update share one path):
  1. overlay-direct startActivity (SYSTEM_ALERT_WINDOW) - legal on all versions when granted
  2. full-screen-intent notification - auto-launches <14; on 14+ (USE_FULL_SCREEN_INTENT
     revoked) degrades to a VISIBLE, tappable "tap to resume" prompt - fail loud, never a
     silent dark screen

Emulator-proven on Android 16: MY_PACKAGE_REPLACED -> Relauncher[update] -> overlay-direct
(BAL_ALLOW_SAW_PERMISSION) -> MainActivity on the new version. Accessibility re-binds across
the package-replace (Service connected fires post-relaunch), so sequential OTAs keep their
auto-confirm.

Unattended OTA requires accessibility (auto-confirm the install) + overlay (relaunch); the
setup wizard grants both. A device where they're skipped degrades to the visible prompt.

Closes #96.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 22:34:32 -05:00
ScreenTinker 5bcaca7c51 fix(player): OTA install silently fails on Android 14+ (explicit PendingIntent)
UpdateChecker.tryPackageInstaller built the INSTALL_COMPLETE status PendingIntent with
FLAG_MUTABLE and an implicit intent. On Android 14+ (target SDK 34) that combination is
disallowed - getBroadcast() throws, the inner catch swallows it, and the PackageInstaller
session is never committed. Result: every OTA silently fails to install on a 14+ device
(download succeeds, version never changes). Make the intent explicit via setPackage(),
keeping FLAG_MUTABLE so PackageInstaller can still write EXTRA_STATUS back.

Emulator-proven on Android 16 (API 36): "Package installer session committed" and the
update applies. Distinct from the relaunch bug - this is install-on-14+.

Part of #96.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 22:34:32 -05:00
ScreenTinker 8d03741713 feat(server): make OTA observable - log update-check + apk-download hits (#96)
The OTA was invisible server-side: /api/update/check and /download/apk returned without
logging, which is part of why the 1.9.0 auto-relaunch failure went unseen. Log every
version check (client version vs latest, update_available, whether an APK is staged) and
every APK download (a device actually applying an OTA), keyed on the CF-aware getClientIp
so production logs show the real per-device IP behind Cloudflare, not the edge.

Observability for the #96 auto-relaunch work (this is how we'll watch the OTA fire during
the relaunch testing). Part of #96.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 22:34:29 -05:00
ScreenTinker f06a87f4be fix(api): harden device pairing against brute-force (#87)
The 6-digit pairing code is generated client-side, so the server can't raise its entropy
without a player change. Instead, harden server-side (no client change):
- lib/pair-lockout.js: lock an IP out of POST /api/provision/pair after 5 failed claims
  (15-min lockout), and expire stale provisioning codes after 15 min so a code is not
  claimable indefinitely. A successful claim resets the IP.
- /pair enforces both. Only an UNKNOWN code (404) counts toward the lockout (a real guess);
  an EXPIRED code (410) is a legitimate-but-stale code and does NOT count, so a slow bulk
  rollout from one shared-NAT IP can't lock itself out. getClientIp is Cloudflare-aware
  (CF-Connecting-IP validated against a trusted edge peer), so the lockout keys on the real
  per-client IP, never a shared edge.

Unit-tested deterministically with injected time, incl. the bulk-rollout-never-locks case.

Closes #87

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 20:16:12 -05:00
ScreenTinker 3305e79e61 fix(api): consolidate device pairing to /pair, remove vestigial bare endpoint (#90)
POST /api/provision was a second pairing endpoint that paired a device by code but,
unlike POST /api/provision/pair, did NOT assign a workspace, enforce checkDeviceLimit, or
emit device:paired / dashboard:device-added - a silently-diverging duplicate that no
client ever called. It now returns 410 Gone and points callers at /pair, so
/api/provision/pair is the single, fully-protected pairing endpoint. The mount stays in
the JWT-only partition, so a Bearer st_ token still gets 401 (requireAuth) before the 410.

Closes #90

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 20:13:16 -05:00
ScreenTinker 538f4a7b03 test(api): close #92 follow-up coverage gaps
The non-security gaps named in the public-API self-review:
- gap-fix: zone_id (playlist items) + layout_id (device PUT) accepted and returned on read,
  INCLUDING the cross-tenant rejection (the is_template OR workspace_id guard - the
  security-relevant one).
- docs serving: /openapi.yaml serves the spec, /docs returns the Redoc page.
- i18n drift-guard: apitoken.* keys have full parity across en/es/fr/de/pt (a key missing
  in one locale fails CI).
- token lifecycle branches: token-create workspace-membership validation and last_used_at
  stamping (integration), plus the must_change_password gate (unit test via the in-memory
  DB injection - cross-process WAL visibility is unreliable for that branch in-process).

119 tests total (was 108), all in the existing node --test job.

Closes #92

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