Commit graph

386 commits

Author SHA1 Message Date
ScreenTinker ce7b2948ae fix(#109): render Android PiP overlay above the YouTube WebView video plane
The PiP overlay (#109) returned sent:1 and showed its title in `uiautomator
dump`, but nothing painted on screen while YouTube was playing. By elimination
(YouTube-specific, landscape so no off-screen transform, real on-screen bounds
in the dump) the cause is surface occlusion: pipLayout sat as the last child of
rootLayout — the SAME compositing band as R.id.youtubeWebView — so the playing
video surface drew over it.

Fix (task option 1a): reparent pipLayout out of rootLayout to the window
content (android.R.id.content) as a top-level sibling drawn after rootLayout, so
it composites above the WebView. MainActivity.mirrorTransformToPip() copies
rootView's orientation/wall transform onto it so corner positions still track
the rotated content (web/Tizen parity). show() also bringToFront()+
requestLayout()+invalidate() on attach (covers the cause-3 measure/visibility
path). Remote-view screenshots now capture the content root so the PiP is still
included.

Instrumentation (Phase 1, default OFF): PipOverlay.pipDebug paints a solid
magenta box + border with media on top (box paints even if media never loads)
and logs box/pipLayout/rootView/youtubeWebView geometry over device:log tag
"pip"; loadImageInto also logs on success. Toggled via device:command
{type:"pip_debug"} (routed through MainActivity.onCommand).

Server: POST /api/pip and the clear handler log one concise [pip] dispatch line
(target + sent/offline) so journalctl shows PiP activity.

Validated end-to-end on an emulator (pixel10/API34) paired to an isolated local
server with YouTube playing: no crash, the PiP box composites above the live
video frame (center + top-right), clear removes it, and the portrait transform
mirror rotates the overlay with the stage (no off-screen). The Fire TV
hardware-overlay punch-through still needs real hardware (emulator composites
video inline); pipDebug + docs/109-android-pip-visibility.md cover that.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 14:19:32 -05:00
screentinker 89cbcac2cd
Add PIP-Weather-Radar example (TV-style live radar overlay) (#133)
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
* Add PIP-Weather-Radar example (TV-style live radar overlay)

A "cut to radar" PiP recipe: a Leaflet map (vendored locally for the
CSP) with a CARTO dark basemap, an animated RainViewer radar loop, and
live NWS warning polygons drawn and color-coded (tornado/severe-tstorm/
flash-flood/flood) with a pulsing "LIVE RADAR" HUD, count chips, and a
legend. Auto-frames the view to the active warning polygon(s).

Two modes: "always" (radar always up) and "on_warning" (default) which
shows the radar only while a qualifying warning covers the configured
point and clears it when the warnings expire — like a station breaking
in during severe weather.

100% keyless / open data: RainViewer radar, CARTO/OSM basemap, NWS
alerts. Zero Node deps; Leaflet is vendored client-side via
vendor-leaflet.sh (gitignored). Offline test covers the warning gate,
color map, RainViewer tile-URL builder, and overlay-URI round-trip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(radar): note Leaflet is vendored locally, not committed

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:05:19 -05:00
screentinker 0b138f10c6
Add PiP overlay example recipes (#132)
Self-contained examples for the PiP overlay API (POST /api/pip), each
with a CSP-safe query-param overlay (external JS), config.example.json,
zero runtime deps, an offline test, and a README:

- PIP-Announce-Broadcast    manual one-shot message to a screen/group
- PIP-Weather-Widget        Open-Meteo current conditions (keyless)
- PIP-Air-Quality           Open-Meteo US AQI widget (keyless)
- PIP-Crypto-Ticker         CoinGecko price strip (keyless)
- PIP-News-Ticker           scrolling RSS/Atom headlines
- PIP-Room-Status-Calendar  ICS-driven Available/Busy room sign
- PIP-Event-Countdown       client-side countdown, auto-clears at zero
- PIP-Welcome-Board         rotating welcome/birthday cards from CSV
- PIP-Fundraiser-Thermometer goal-progress bar from local/URL JSON
- PIP-QR-Rotator            rotating QR codes, encoded client-side
- PIP-Incident-Webhook      event-driven: red on firing, clear on resolved

Also includes the CAP-AU (NSW RFS) and US NWS/NOAA emergency-alert
monitors that push expiry-aware PiP overlays.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 20:20:37 -05:00
ScreenTinker 5f83fc20d3 docs(api): document /api/pip and the assignments muted field (#109/#129)
The PiP endpoints and the per-item mute field shipped without OpenAPI coverage.

- openapi.yaml: add POST /pip (show), DELETE /pip + POST /pip/clear (clear), all
  x-required-scope: full; add the `muted` boolean to PUT /assignments/{id}; add a `pip` tag.
- openapi-contract.test.js: the scope heuristic only treated `command` paths as full-scope,
  so a full-scope non-command route (/pip) would fail it — extend it to recognize /pip.

Docs-only as far as the running build goes (no route/behavior change). Lands on main; not
in the frozen v1.9.1-beta4 tag — ships in the next tag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 17:36:12 -05:00
ScreenTinker 71f8948bdb chore(android): bump versionName to 1.9.1-beta4 (versionCode 24)
The Android version fields live separately from the root VERSION / server package,
so the beta4 release commit didn't touch them and the APK reported beta3. Bump them so
the client reports beta4 and OTA sees it as newer. The v1.9.1-beta4 tag is intentionally
NOT moved to this commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 17:28:44 -05:00
ScreenTinker 118367837b chore(release): v1.9.1-beta4
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 17:00:59 -05:00
screentinker 618a7048c6
fix(server): proxy remote YouTube thumbnails + real version in boot banner (#131)
* fix(server): proxy remote YouTube thumbnails instead of ENOENT on a local path

YouTube content stores thumbnail_path as a REMOTE URL
(https://img.youtube.com/vi/<id>/hqdefault.jpg), but the thumbnail-serving route
path.resolve'd it into contentDir -> a local file that never existed -> ENOENT logged
a few times a minute (the tester-log spam). Recreating content didn't help (new rows
store the same remote URL).

- GET /api/content/:id/thumbnail now proxies a remote http(s) thumbnail_path
  server-side (same-origin, so dashboard CSP img-src is unaffected) via a non-throwing
  helper: upstream 404 -> 404, other failure/timeout -> 502, image/* only (modest SSRF
  hardening; the URL is server-set at ingest). Local thumbnails keep the sendFile path;
  the playlist/widget/workspace access gating is unchanged for both branches.
- routes/widgets.js inlineUserContent skips the disk read for a remote thumbnail and
  leaves the /api/content/:id/thumbnail reference in place (the proxy serves it).
- routes/content.js ingest unchanged; a comment notes the future download-at-ingest +
  backfill option for CDN independence.
- New test/thumbnail-proxy.test.js: local sendFile still works; a remote thumbnail is
  proxied (mock upstream, no local read, no ENOENT); upstream 404 -> clean 404. Full
  server suite 164/164.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(server): boot banner shows the real version, not a hardcoded v1.2.0

The startup ASCII banner printed "ScreenTinker Server v1.2.0". Use the already-imported
VERSION (require('./version'), the single source of truth that reads the root VERSION
file) in a fixed-width field (VERSION.padEnd(22).slice(0, 22) — the same padEnd
discipline the port line uses) so the fixed-width box border stays aligned for any
version length. No other behavior changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 17:00:24 -05:00
screentinker 6f0e4a07f6
Fix per-item mute (#129): persist, ship to device, and toggle in real time (#130)
* fix(server): persist + ship + real-time per-item mute (#129)

The dashboard mute toggle was a no-op end to end. The active model is playlist_items
(the device payload is its published_snapshot); the legacy `assignments` table the bug
report cited is unused for devices. Three breaks:

- PUT /api/assignments/:id silently dropped `muted` (only read sort_order/duration_sec/
  zone_id). It now accepts muted (coerced 0/1) and ITEM_SELECT returns it, so the toggle
  persists and its on/off state sticks.
- playlist_items had no `muted` column — added (schema + idempotent migration).
- buildSnapshotItems didn't select muted, so it never reached the published_snapshot /
  device payload — now included.

Real-time: on a mute change, emit device:mute-changed { content_id, widget_id, muted } to
every device on that playlist so the player toggles the matching item's volume live,
decoupled from publish (the value is also in the next snapshot, so it persists). Adds a
[mute] log line (the report noted zero mute log entries).

Test: test/mute.test.js — PUT persists + returns muted, it reaches the published
snapshot, and a non-mute update doesn't reset it. Server suite 164/164.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(player): apply per-item mute live on Android + web (#129)

Honor the new per-item mute from the server, both in real time and on reload.

Android:
- WebSocketService: onMuteChanged callback + main-thread device:mute-changed handler.
- MediaPlayerManager.setVideoMuted(): flips the live ExoPlayer volume on the current
  video (YouTube autoplays muted; images/widgets are silent).
- MainActivity: on device:mute-changed, apply immediately if the toggled item is the
  one playing now.
- PlaylistController.sig(): include muted so a published mute change re-renders/persists
  instead of being de-duped.

Web player (server/player/index.html):
- device:mute-changed handler toggles the current <video>; the video mount now also
  honors item.muted so a published mute sticks across reloads.

Tizen intentionally not included: its player mutes ALL video for autoplay, so per-item
unmute isn't achievable there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:54:23 -05:00
screentinker 965920cd17
PiP overlay MVP: push image/web overlays to a device or group (#109) (#127)
* 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>

* PiP overlay: add Android + web players (#109)

Extends the #109 PiP MVP to the other two players so the protocol (device:pip-show /
device:pip-clear) is honored fleet-wide, not just on Tizen. No server/protocol changes —
the route and socket messages are player-agnostic; these are the two missing surfaces.

Web player (server/player/index.html):
- New #pipContainer layer above #playerContainer, pointer-transparent, that the playlist
  render never touches. The same orientation transform is applied to it as to
  #playerContainer (extended to also reset width/height on landscape so a
  portrait->landscape switch realigns), so corner positions track the visible content.
- Inline PiP logic mirroring tizen/js/pip-overlay.js: image -> <img>, web -> <iframe>
  (muted by default via empty allow=), position/size/bg/opacity/radius/title, single slot
  last-show-wins, duration timer (0 = until cleared), id-aware clear, wrapped teardown.
- device:pip-show/clear handlers; reports show/clear over device:log (tag "pip").

Android player:
- activity_main.xml: a pipLayout FrameLayout as the LAST child of rootLayout — it draws
  above the content AND inherits rootView's orientation rotation/translation, so corner
  positioning is orientation-matched for free.
- PipOverlay.kt (new): builds the overlay box into pipLayout. image -> ImageView (decoded
  off-thread via ImageLoader, dropped if torn down mid-decode); web -> WebView with
  mediaPlaybackRequiresUserGesture=true (mute-by-default). Gravity-based corner/center
  placement with a 4% inset, GradientDrawable bg + corner radius, alpha=opacity, optional
  title bar. Single slot last-show-wins; duration timer; id-aware clear; teardown wrapped
  and also run on activity destroy (WebView cleanup).
- WebSocketService: onPipShow/onPipClear callbacks + safeOn handlers posted to the main
  thread (they build Views) + a sendLog(tag, level, message) emitter for device:log.
- MainActivity: instantiate PipOverlay (log -> wsService.sendLog("pip", ...)), wire the
  callbacks, tear down on destroy.

Verified: Android assembleDebug builds clean; web player inline JS parses; server suite
still 161/161 (no server changes this commit). Not yet validated on real hardware —
four-orientation corner positioning mirrors the player container/rootView transform but
should be eyeballed on a panel.

Refs #109

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 14:54:44 -05:00
ScreenTinker e2ff8f47b7 Tizen player: real Samsung B2B fleet control (#125), folding in #126
#123 already shipped a placeholder device:command handler (#121/#122): screen_off
was a black overlay, reboot/shutdown a toast, update a "re-install" toast. This
replaces that with the real control surface from #125, reconciled into the single
handler #123 introduced (rather than landing a second, competing handler).

- NEW tizen/js/device-control.js: window.STDeviceControl = { run, capabilities,
  backend }. Feature-detects webapis.systemcontrol.* (Tizen 6.5/7, sync/throws) then
  b2bapis.b2bcontrol.* (SSSP/Tizen 4, async), normalises both to Promises, re-probes
  each call. run() never rejects; resolves { ok, supported, action, note, reload }.
  Panel power: setPanelMute (mute ON = backlight OFF) -> setDisplayPanel/setPanelStatus
  fallback. reboot -> rebootDevice(); shutdown mutes the panel and notes SSSP has no
  true power-off; update/reload -> reload:true.
- tizen/js/app.js: device:command now calls STDeviceControl.run and reports the
  outcome via reportCmd (device:log tag=command -> dashboard:device-log, plus a
  structured device:command-result), reloading ~1.2s later on result.reload. screen_off
  falls back to the existing black overlay (showScreenOff) when no B2B surface exists;
  screen_on/launch still clear the overlay + keepAwake. Dropped the now-dead
  tryPowerControl. reportCapabilities() runs on device:registered so the dashboard sees
  the backend ("none" on web/URL-Launcher/consumer TV).
- tizen/config.xml: partner-level b2bcontrol + systemcontrol privileges (ignored, not
  fatal, on unsigned/URL-Launcher/web/consumer builds).
- tizen/index.html: load $WEBAPIS/webapis.js + $B2BAPIS/b2bapis.js before the app
  scripts (404 harmlessly off-hardware) and device-control.js before app.js.
- tizen/README.md: rewrote the remote-control table for real B2B control + a
  partner-signing caveat; added device-control.js to the file list.

Supersedes PR #126 (feat/tizen-device-command-125), which targeted main unaware that
this branch already had a device:command handler.

Verified: node --check on both JS files; config.xml well-formed (xmllint). Not yet
validated on a real SSSP panel — the control surface only takes effect on a
partner-signed .wgt (backend reports "none" on the dev/URL-Launcher build).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 13:28:08 -05:00
ScreenTinker 9c4b48800f Tizen player 1.9.1-beta3: bug fixes, multi-zone layouts, video walls
Brings the Tizen TV player to parity with the other players: closes the five
Tizen issues Bold Media Group filed (#118-#122) and adds the two larger renderer
features it was still missing.

Fixes (#118-#122)
- #118 Sticky "Not authenticated" banner. On TV sleep/wake the socket reconnects
  and a heartbeat could land on the fresh, not-yet-registered socket; the server
  rejected it and the old handler painted a permanent banner AND dropped the saved
  credentials, forcing a re-pair. Heartbeats are now gated on a per-connection
  authenticated flag (true only between device:registered and disconnect/auth-error),
  the heartbeat stops on connect/disconnect/auth-error, the banner clears on
  device:registered, and the auth-error toast is non-sticky.
- #119 app_version stuck at 1.0.0. Resolved at runtime from config.xml via the Tizen
  application API, with a fallback constant that build-wgt.sh stamps from config.xml.
- #121 Remote commands. Added a device:command handler (refresh/launch/screen_on/
  screen_off; honest no-op toasts for update/reboot/shutdown, which need B2B/MDM
  privileges a sideloaded app lacks). Removed the dead device:reload listener.
- #120 Dashboard preview. Added device:screenshot-request + remote-start/remote-stop.
  Images capture; video/YouTube fall back to a status card (TV hardware video plane
  and cross-origin iframes can't be read into a canvas).
- #122 Updates/boot. Documented the real paths (re-sideload or URL Launcher/MDM
  refresh; display-level kiosk/boot settings) since a sideloaded .wgt has no in-app
  OTA or config.xml autostart.

Multi-zone layouts (Android parity)
- New ZoneRenderer ports the Android ZoneManager: zones positioned by percent
  geometry with z_index/fit_mode/background, assignments grouped by zone_id
  (unassigned content goes to the first zone), each zone rotating independently with
  the same per-item schedule gating (#74/#75). app.js selects the renderer from
  payload.layout; single-zone playback is unchanged.

Video walls (web-player parity; Android has none)
- New WallController mirrors the web player: when payload.wall_config is present the
  stage is positioned (vw/vh) as this screen's slice of the wall. The leader plays
  normally and broadcasts wall:sync at 4Hz; followers hold the leader's item, align
  index, and lock their video to the leader's clock with a latency-compensated drift
  controller (hard-seek past 0.3s, gentle +/-3% playbackRate nudge past 0.05s), and
  request an immediate position on (re)connect via wall:sync-request. Per-tile
  rotation is not applied yet (matches the web player). Wall emits are gated on
  auth + connection so a pre-register tick can't trip device:auth-error.

Not ported: video-wall per-tile rotation, plus the minor Android-only reporting
events (device:playback-state, device:log) and the N/A offline-cache events
(device:content-ack/content-delete). None affect on-screen playback.

Verified: JS syntax + headless unit tests of zone grouping/geometry and wall
leader/follower + drift logic. NOT yet validated on Tizen hardware - multi-screen
video sync in particular needs a real wall to tune.
2026-06-18 13:28:08 -05:00
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