mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
157 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
c4b5a8679e |
refactor(auth): centralise session token resolution across manual verify sites
Six places verified a session JWT inline instead of going through requireAuth, each repeating a slightly different subset of its checks. Introduce resolveSessionUser() in middleware/auth.js as the single definition of "this token is a usable session, and here is whose it is", and route all of them through it: the three /api/status token routes, the screenshot route, the content-reference gate, and the /dashboard socket handshake. requireAuth is now a thin wrapper over the same helper, so the two cannot drift. Also: - Give the pre-TOTP token a distinct audience so it is redeemable only through verifyMfaPendingToken (POST /api/auth/totp/verify). verifyToken refuses any token carrying an audience, so a token minted for one purpose cannot be redeemed on another path. - The dashboard socket handshake now takes userId/userRole from the live users row rather than from the token claim, so role changes take effect on the next connection instead of riding the token's remaining lifetime. - Add test/session-token-resolution.test.js covering all six surfaces, including the socket handshake. Every call site keeps the status code and error body it returned before. Net query cost: the content-reference gate and the socket handshake each gain one users-by-id lookup (the same one requireAuth already does per request); the other four are unchanged or replace an equivalent lookup. In-flight pre-TOTP tokens are invalidated by the audience change; they live 5 minutes, so the window is a re-login at worst. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e91d87fbfd
|
feat(stripe): enable promotion codes on checkout sessions (#227)
Add allow_promotion_codes: true to the checkout.sessions.create call in POST /checkout. This is what renders the "Add promotion code" field on Stripe's hosted checkout page; for API-created sessions there is no Dashboard equivalent (that toggle only exists for Payment Links, which we don't use), so a comment warns against removing it as "redundant". The billingPortal branch is untouched — portal sessions handle discounts separately. Testing: no Stripe-SDK test/mock existed (the billing-*.test.js files cover the #146 usage-metering path, not Stripe). Added stripe-checkout.test.js using the repo's in-process router-mount convention with a minimal `stripe` stub injected via require.cache, asserting the checkout payload carries allow_promotion_codes:true (and still builds a subscription session for the requested price). Full suite 557/557. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
8529be5a30
|
feat(content): subtitle/caption support as a content property (#223)
Subtitles/captions are set once in the content library and applied
automatically by the player — no in-player controls (the player stays bare).
- DB: 4 new content columns (captions_enabled, captions_lang for YouTube;
subtitle_url, subtitle_lang for uploaded videos), all default off/NULL.
- buildSnapshotItems: denormalize the 4 fields into published_snapshot so the
player receives them (enumerated query).
- content.js PUT: accept the 4 fields (subtitle_url only clearable here).
- POST /:id/subtitle: dedicated .vtt uploader (separate multer, since the main
filter is video/image-only); stores the file in the content dir, records
subtitle_url + subtitle_lang. Old subtitle file replaced; DELETE cleans up
the sidecar.
- Player: YouTube -> loadModule('captions') + setOption(...languageCode) in
onReady (best-effort, wrapped). Uploaded video -> a <track kind="subtitles">
appended to the <video>, forced mode='showing' on load (same-origin, so
CORS-clean like the video).
- Edit modal: YouTube gets an enable-captions checkbox + language; uploaded
video gets a .vtt file picker + language + a remove-subtitle option. en/es.
Honest limitation (in the PR): YouTube caption control via the IFrame API is
undocumented/version-dependent and only works if the video actually has
captions — hence best-effort and wrapped so it can never break playback.
Test: content-subtitles.test.js — the 4 fields survive publish -> snapshot,
the .vtt upload endpoint stores + records the file, and a non-.vtt is rejected.
Suite 550/550.
Closes #216
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
8b661a7347
|
feat(content): batch operations — multi-select, batch delete, batch move (#224)
The content library had no batch operations — every item was managed one at a time. Add multi-select with batch delete and batch move. Backend (content.js): - POST /content/batch/delete — array of ids, atomic: validates + authorizes EVERY id first (malformed/missing/forbidden rejects the whole batch), then deletes in one transaction. Reuses the single-delete teardown. - POST /content/batch/move — array of ids + target folder_id, same atomic validate-all-first; target folder must share each item's workspace. Folder is organizational (not in the snapshot), so no device push. - Refactor: extract purgeContentRow() (file removal + snapshot scrub + row delete + affected-device collection) and pushContentUpdates(); DELETE /:id now uses them, so single + batch share one scrub path (no duplication). Add a boolean contentWritable() mirroring checkContentWrite's authorization. - 500-item cap per batch; UUID validation guards the snapshot-scrub LIKE. Frontend (content-library): - Per-card selection checkbox, select-all/none (visible), shift-click range. - Selection persists across folders/pages (issue-aligned cross-page selection); cleared after a successful batch op. - Batch toolbar (shown when >0 selected): count, move-to-folder picker, delete with click-again confirm. Selected cards get an outline. - api.batchDeleteContent / batchMoveContent; en/es i18n. Not included: batch "set expiry" (listed in the issue's toolbar sketch but only delete/move had endpoint specs) — deferred; PUT already does per-item expiry. Test: content-batch-ops.test.js — batch delete removes rows+files+scrubs snapshots; atomic rejection leaves valid rows intact; malformed id -> 400; batch move reassigns folder; cross-workspace folder refused; empty batch -> 400. Suite 553/553. Closes #213 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
5c6d508032
|
feat(content): multi-file upload (#222)
Uploading N files fired N sequential XHRs (one POST per file). Select-many now goes up in a single request. - Server POST /api/content: upload.array-style `files` field (up to 20) via upload.fields, looping ingestUploadedFile per file. Keeps the legacy single `file` field so older clients / API callers are unaffected. Response shape is backward-compatible: a single file returns the content object (what every existing caller reads), a batch returns the array. - api.uploadContent: accepts a File, FileList, or array; appends all under `files`; aggregate upload progress; resolves to object (single) or array (batch). - content-library handleFiles: one batched request with aggregate progress and a "N files uploaded" toast instead of a per-file loop. - en/es i18n for the count-based progress/toast strings. checkStorageLimit is left as-is — it's a coarse pre-gate (blocks only when already at/over the limit), same as before; per-file aggregate sizing was a listed "consideration", not required, and is out of scope here. Test: content-multi-upload.test.js drives the real router+multer over HTTP — 3-file batch creates 3 rows and returns an array, legacy single `file` returns an object, single `files` returns an object, empty -> 400. Suite 545/545. Closes #212 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
792b105035
|
feat(content): server-side search, type filter, and sort (#221)
Content discovery was client-side only, scoped to the items already rendered
on the current page — searching "logo" on page 1 couldn't find logos on page
2 or in another folder.
Server (GET /api/content):
- ?q= text search on filename (LIKE, workspace-wide — a search ignores the
open folder so nothing is missed). LIKE metacharacters are escaped so a
filename with % or _ matches literally.
- ?type=video|image|youtube|web — youtube (video/youtube) and web (other
remote_url) are split from plain uploaded video/image so the four UI buckets
map cleanly.
- ?sort=date_desc|date_asc|name|size — whitelisted (never interpolates user
input into ORDER BY); default keeps the legacy newest-first ordering.
Frontend (content-library):
- Type filter + sort dropdowns; search debounced (300ms) and now hits the
server instead of filtering the DOM.
- Result count shown while a search/type filter is active.
- en/es i18n.
api.getContent gains an opts arg ({q,type,sort}); folder_id is omitted while
searching to match the server's workspace-wide behaviour.
Test: content-search-filter-sort.test.js mounts the real router and covers
substring match, LIKE-escape (literal %), the type buckets, name/size sort,
the ORDER BY injection guard, and combined filters. Suite 541/541.
Closes #214
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
ad03a5ec0a
|
feat(content): unstable-connection mode — cap YouTube at 720p for weak WiFi (#220)
On Android TV with weak/unstable WiFi, YouTube embeds auto-select 1080p+
and buffer/stall. Add a per-item "Unstable connection" flag that biases the
YouTube embed toward a 720p ceiling.
- DB: content.unstable_connection INTEGER NOT NULL DEFAULT 0 (non-destructive,
existing rows unchanged).
- buildSnapshotItems: denormalize the flag into published_snapshot so it
reaches the player (that query enumerates columns, so it had to be added
explicitly — covered by a new test). preview-payload reuses the same query.
- content.js PUT: accept + coerce unstable_connection to 0/1.
- Player: playerVars.vq='hd720' + best-effort setPlaybackQuality('hd720') in
onReady when the flag is set. Both are hints YouTube may still override, but
together they bias the initial selection down to 720p.
- Edit modal: YouTube-only checkbox + hint; en/es i18n.
Scoped to the core ask; the issue's optional "Shorts get inverse hd1080"
refinement is intentionally left out (dubious for signage, and forcing higher
quality on a weak link is the opposite of the goal).
Closes #217
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
9e0048eec2
|
fix(content): respect current folder when uploading files (#211)
Previously the dashboard upload always sent files to root (folder_id=NULL) because the upload flow never read or forwarded the current folder context. The agency upload already handled this correctly — this applies the same pattern. Changes: - api.js: uploadContent() accepts optional folderId, appends to FormData - content-library.js: handleFiles() passes state.currentFolderId - content.js: POST / reads folder_id from multipart body |
||
|
|
b938fce368 |
feat(auth,tizen): TOTP 2FA UI, email verification on signup, Tizen SSSP install
Three features from this session, full server suite green (535/535). TOTP 2FA (#100) — backend shipped without a UI; add it: - Login: mfa_required -> 6-digit challenge (recovery codes accepted) -> /totp/verify. - Settings > Account: enable (QR + confirm -> recovery codes once), regenerate, disable; SSO accounts see "managed by your identity provider". - /totp/setup returns a server-rendered qr_data_url (bundled qrcode dep). keyuri folds the request Host into the issuer so multi-instance accounts are distinguishable in the authenticator app. Email verification on signup — hosted HARD-block / self-host SOFT-nudge: - email_verified column; existing users asked on first login (SSO + platform admins grandfathered); single-use 24h tokens (SHA-256 hashed). - Gate engages only when email is configured (never locks out a no-mail instance). GET /verify-email + POST /resend-verification (generic, no account enumeration). - Client: "confirm your email" flow + resend, verified/error toasts, self-host banner; onAuthSuccess refuses a tokenless response (defensive). Tizen SSSP URL-Launcher install — Fusion-style one-URL native install: - Server hosts /tizen/sssp_config.xml (dynamic <size>, always matches the served .wgt) + /tizen/ScreenTinker.wgt + a human landing. lib/wgt-cache.js resolves the signed .wgt (/data mount wins, mirroring the APK). - build-wgt.sh also emits a static sssp_config.xml for CDN hosting. - Retail panels require a Samsung Partner cert; dev-mode is SDB self-signed only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
96b71a0d56
|
feat: transition engine — GL wipes across web, Tizen & Android (+ image↔video) (#204)
Client-side GL Transitions v1 across all three players with never-blank degradation: shader lib + generated manifest + real-WebGL CI, transition-as-widget normalization, persistent WebGL/GLES2 compositors (web/Tizen/native Android), image↔video wipes, SSRF-hardened media proxy, decode-gated image preload, dashboard picker. Fixes the playlist change-fingerprint that dropped transition edits. Alpha + on-device soak validated. |
||
|
|
335681b907
|
feat(directory-board): panel-ring scroll + in-place refresh + per-device frame diagnostic (#203)
Compositor panel-ring board scroll (smooth on Blink+Gecko, no blank-on-refresh), a per-device frame-rate diagnostic widget + dashboard card, and web/Android/Tizen device-id passthrough to widget render URLs. |
||
|
|
5c1cb4b992
|
fix(server): floor duration_sec to prevent widget zero-duration player loop (#199)
A duration_sec=0 assignment (especially a widget) made the player schedule a 0ms auto-advance, self-looping and black-screening the TV. #198 fixed the Android client; this hardens the source so a 0 can't be stored or served in the first place. assignments.js accepted an explicit 0 on the POST/PUT/copy write paths — the `= 10` destructure default only covers an ABSENT field, not an explicit 0. - Add normalizeDuration() and apply it on all assignment write paths so any missing/invalid/<1 duration is floored to the 10s default. - Add an idempotent migration repairing existing playlist_items rows with duration_sec IS NULL OR < 1 (fixes the live widget on existing DBs). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
059ee1744e
|
fix(widgets): directory board scroll stutter — seamless loop gap mismatch (#197)
The vertical auto-scroll is a CSS keyframe that translates the track by cycleH = baseH + GAP_PX and loops linear infinite. GAP_PX was 100 but the actual .gap element between the content and its seamless clone is 120px, so every cycle the reset landed 20px off — a visible jump/stutter once per loop. Set GAP_PX = 120 to match the .gap CSS, and drive each gap element's height from GAP_PX inline so the scroll math and the rendered gap can never drift again. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
5f5ec88eb0
|
fix(widgets): put the CORP: cross-origin header on the route that actually serves content (#196)
Follow-up to #195. The CORP fix there landed on routes/content.js `/:id/file`, but that handler is SHADOWED: server.js registers a public `app.get('/api/content/:id/file')` (and `/thumbnail`) BEFORE the auth-gated content router, and that public route (gated by playlist/widget reference) is what actually serves widget logo/background images. So the header never changed on the wire — origin still returned CORP: same-origin and the player's sandboxed (opaque-origin) widget iframe kept getting NS_ERROR_DOM_CORP_FAILED / 0 bytes. Set Access-Control-Allow-Origin: * + Cross-Origin-Resource-Policy: cross-origin on the real public routes in server.js: /file, /thumbnail (local), and the remote-thumbnail proxy. Revert the now-dead content.js edit so the fix lives only where the bytes are served. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
178af029a4
|
Directory board: JSON/CSV import + logo-replaces-title + fix images on player (#195)
* feat(widgets): bulk import for the directory board (JSON / CSV / TSV / text)
Adds an "Import from JSON / CSV" button to the directory-board editor. Paste JSON
(the { company, tenantsByFloor, advertisements, backgroundImages } shape plus
categories[]/floors[]/flat-array/bare-floor-map variants), a CSV/TSV/pipe/semicolon
table (with or without a header — vacant/yes/1 => available, quoted fields), or a
sectioned "room name" text list, and it auto-fills title, footer, floors->categories,
rooms/names/details/availability, and background-image URLs. "Replace / append" toggle.
Tolerant key matching (room/suite/unit/id, name/tenant/company, details/subtitle, …);
warns on things it can't use (bare-filename background images, headerless columns).
parseDirectoryImport is pure and was unit-tested in node across every format.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(widgets): directory board — logo replaces title, and images load on the player
Two on-screen bugs on the directory board:
1. A logo did not remove the title text — both rendered, stacking the wordmark over
the name. renderDirectoryBoard (and the directory-search header) now gate the title
h1 behind !logoSrc, so a logo replaces the title. New render test guards it.
2. Logo + background images did not show on the player (NS_ERROR_DOM_CORP_FAILED,
0 bytes). The player embeds widgets in a sandbox="allow-scripts" (opaque-origin)
iframe, so /api/content image requests are cross-origin, and the helmet default
Cross-Origin-Resource-Policy: same-origin blocks them. Set CORP: cross-origin (+
ACAO:*) on the content file + thumbnail routes, matching the existing /uploads/content
static route. Content already serves publicly, so no new exposure. Verified in a real
sandboxed iframe: same-origin blocks, cross-origin loads.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
bc9e72ec0b
|
fix(content): render YouTube Shorts in 9:16 instead of a landscape frame (#184) (#189)
Vertical Shorts were played in a player forced to 100%x100% on a landscape frame, so they looked wrong (pillarboxed/small). Option A: detect vertical at ingest, persist it, and have every player honor it. - Ingest (routes/content.js): detect a Short from the /shorts/ URL form OR portrait oEmbed dims (oEmbed now queried with the ORIGINAL url so /shorts/ reports its true dimensions), and persist it as st_aspect=vertical on the stored embed URL. That's the only signal players get (remote_url), so it must be captured at ingest, not re-derived per loop. YouTube ignores the unknown param; players read the video id, not the full URL, to build the embed. - Players read st_aspect=vertical and center a 9:16 box (fills a portrait screen, pillarboxes cleanly on landscape) instead of 100%x100%: web (player/index.html), Android (WebViewSupport.youtubeEmbedHtml), Tizen (player.js single-zone + zone paths). Dashboard library uses a static thumbnail, so it's unaffected. Not doing Option B (yt-dlp): runtime dep + storage/bandwidth + maintenance + YouTube ToS; embed-disabled Shorts already skip gracefully. Tests: youtube-shorts.test.js (4) — /shorts/ and portrait-dims tag vertical, landscape stays untagged, /shorts/ tags even if oEmbed fails. Android compiles; web player inline JS + Tizen player.js parse. Note: pre-existing Shorts added before this aren't retagged (would need an oEmbed backfill) — re-add to fix, or a follow-up migration. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
a15086540f
|
feat(widgets): directory-search widget (interactive search of a directory board, live-sync) (#188)
* feat(widgets): add directory-search widget
An interactive, walk-up search view of an existing directory-board. It
references a source board by id (no data copy), so a venue can run the
scrolling board on a main screen and a search view on a tablet, letting
people find an entry instantly.
Server (routes/widgets.js):
- register 'directory-search' + renderDirectorySearch(): resolves the source
board, inlines its categories as one \u003c-guarded JSON blob, renders all
text via textContent (XSS-safe), live case-insensitive filter over
identifier/name/subtitle (debounced), grouped results, available styling,
optional touch on-screen QWERTY keyboard that drives the same filter path.
- missing / non-directory-board source -> friendly full-page fallback, not a 500.
- live-sync while open is out of scope; left a // TODO for a poll hook.
Frontend editor (views/widgets.js): type + magnifier icon, source-board
dropdown (from loaded widgets, filtered to directory-board), title, logo
(reuses the board's picker), placeholder text, theme, on-screen-keyboard toggle;
getConfigFromForm case. i18n: widget.dirsearch.* + type keys in en/es/it/de/pt/fr.
docs: openapi widget_type enum. Tests: server/test/directory-search.test.js.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(widgets): live-sync for directory-search (poll source board, no reload)
Reflect directory-board edits on an open directory-search page without a reload.
- New public GET /api/widgets/:id/data.json returns { categories } for a
directory-board (404 for missing/wrong-type so the page keeps last-good data
on a transient miss). CORS-open (ACAO:*) + no-store so a null-origin sandboxed
widget iframe can read it; exposes only data already public via /render.
Exempted from CSP + auth in server.js alongside /render.
- directory-search page inlines its source_widget_id and polls the board's
data.json every 30s via a relative URL (works behind a proxy/base path and
from a null-origin iframe). Only rebuilds + rerenders when the data actually
changed, so a mid-search view isn't disturbed; skips while document.hidden;
keeps last-good data on any fetch error. Flatten logic factored into
buildFlat() and reused by the poll.
Tests: data.json feed (categories, CORS header, 404s) + poll wiring in
directory-search.test.js (13/13). Verified live in a browser (page.clock
fast-forward): editing the board updates the search page with no reload.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(android): let player WebViews take touch focus for interactive widgets
directory-search is served through the existing generic widget path
(loadUrl <server>/api/widgets/:id/render), so it already renders on Android
with JS + DOM storage + mixed-content enabled, same-origin (so its live-sync
fetch of the source board's data.json works), and no touch blocking.
Add isFocusable/isFocusableInTouchMode to the shared WebView config so the
search field reliably takes a tap/cursor inside the kiosk lock-task WebView.
Harmless for passive widgets (board/YouTube have no focusable inputs); the
widget's own on-screen keyboard still drives the filter when the system IME
is suppressed. Verified with :app:compileDebugKotlin.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
9c70fcc790
|
feat(diagnostics): device incident log — offline cause, network-vs-reboot, display-sleep (#175)
* feat(diagnostics): device incident log — why a screen went offline/black, with device-attested cause
Field request (Bold/s_t_r_o_b_e): "screens go offline randomly — let us see the cause." Answers it
across the fleet with a unified incident log, and — the key insight — lets the DEVICE disambiguate
the cause the server can't: if the app process survived the gap it was a NETWORK problem (not a
reboot), and it can even tell a dropped Wi-Fi/Ethernet link from a link-up-but-server-unreachable
(router/upstream) failure.
Schema:
- device_status_log gains reason + detail (WHY each offline transition happened).
- NEW device_events table (unified incident feed): type (offline/online/display_off/display_on/
crash/reboot/network/app_error) + reason + detail, indexed, age-pruned + per-device capped.
Server:
- Capture the socket.io disconnect REASON (transport_close/ping_timeout/transport_error) instead of
discarding it — recorded in the offline-cause log. devices.offline_reason stays on the EXIT-SIGNAL
contract (crashed/clean_exit/silent) — a separate axis, preserved (violent death = 'silent').
- device:event handler (typed incidents) + device:connectivity-report handler (device-attested).
lib/incident-classify.js (pure, unit-tested) composes reason+detail: cold_start->reboot;
link_lost->network "Wi-Fi/Ethernet link lost"; else network "LAN up, server unreachable
(router/upstream)"; appends SSID / weak-signal (rssi<-75) / IP-changed. On a report it upgrades the
most-recent offline row from the server's guess to the device's ground truth.
- heartbeat timeout -> 'heartbeat_timeout'; retention/cap for device_events.
- Device-detail API returns statusLog.reason/detail + the last 50 device_events.
Device (Android WebSocketService): ConnectivityManager default-network callback (link-lost during a
gap) + Wi-Fi SSID/RSSI + IP snapshot -> device:connectivity-report on reconnect (app survived =>
network); ACTION_SCREEN_ON/OFF receiver -> device:event display_on/off ("screen went black"). All
guarded/feature-detected; no manifest change; compiles clean.
Web + Tizen players: reconnect connectivity-report (link_lost from navigator.onLine during the gap)
+ visibilitychange -> display_off/on. Best-effort (no wifi detail in a browser). Tizen exit-signal
marker slice untouched.
CMS (device-detail): the offline cause on the uptime-timeline hover + a new "Recent incidents" panel
(merged offline periods + typed events, friendly labels, detail, relative time + down-duration).
Built as a 4-way parallel agent fan-out over disjoint domains against a locked contract, then
integrated. Verified: full server suite 443/443 (incl. the seam fix keeping the exit-signal contract
intact), Android compileDebugKotlin clean, all players + CMS node -c clean.
Refs #170.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(diagnostics): internet-reachability probe — split "our server down" from "no internet" (#170)
Follow-up to the incident log: when a device's link is UP but it's offline, "router/upstream" was a
catch-all. The device now probes a public host (1.1.1.1 / 8.8.8.8 :443) DURING the gap, so the cause
pinpoints blame:
- link_lost=true -> Wi‑Fi/Ethernet link lost (device's own link)
- link up, internet_ok=true -> server_down: internet reachable, OUR server was unreachable
- link up, internet_ok=false -> no_internet: router/ISP down
- link up, no probe result -> generic router/upstream (unchanged fallback)
- Android WebSocketService: fire a short daemon-thread TCP probe (443, either host) at disconnect;
the result rides the connectivity-report as internet_ok (omitted if the gap ends before it finishes).
- lib/incident-classify.js: 3-way split on internet_ok; new reasons server_down / no_internet.
- Frontend i18n: device.event.server_down / .no_internet labels.
- Tests: +3 classify cases (server_down, no_internet, link_lost wins over internet_ok). 12/12.
Verified: classify 12/12, Android compileDebugKotlin clean, node -c clean. Refs #170.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(diagnostics): log an 'upgrade' incident (old → new app_version) — server-side (#170)
When a device reports an app_version different from the stored one, applyDeviceInfo logs an
'upgrade' device_events row (detail 'old → new'). Server-side, so it covers Android/Tizen/web with
no client change; a fresh pair (no prior version) isn't counted. Adds device.event.upgrade label.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
2f3dd80881
|
feat(agency): per-token upload folder — auto-created, subtree-confined (#158) (#171)
Agency-portal uploads previously all landed at the workspace library root, unsorted. Instead of the issue's whole-workspace folder dropdown (which would leak every folder name to an external party), bind ONE folder per agency token — admin-controlled and agency-invisible — and scope the portal picker strictly to that folder's own subtree (Hybrid-C). Fully backwards-compatible: no bound folder -> root, exactly as before. Model / multi-workspace: an agency token is bound to ONE workspace at issuance, so the token key IS that workspace's private link and the bound folder lives in that workspace. An admin with N workspaces mints one token per workspace (each with its own auto-folder). No workspace-switcher in the portal — the token is the tenant boundary. Backend: - api_tokens.upload_folder_id (additive; ON DELETE SET NULL -> deleting the folder falls back to root). - lib/agency-targets.folderSubtree(): recursive-CTE helper = the SINGLE confinement source shared by GET /api/agency/folders AND the POST /api/agency/content target check, so the set the agency can SEE and the set it may WRITE to can never drift. Workspace-guarded at the anchor row; descendants inherit the workspace (folders.js forbids cross-ws parents). - routes/agency.js: GET /folders (bound subtree only); POST /content defaults to the bound folder and 403s any folder_id outside the subtree. - routes/tokens.js: create auto-creates "Agency — <name>" (or binds a picked folder, validated same-workspace, respecting the 100-folder cap) inside the token tx; new PUT /:id/upload-folder to rebind; listing surfaces the bound folder name. - middleware/apiToken.js + lib/content-ingest.js: upload_folder_id onto req.apiToken; ingest writes folder_id. Frontend: - Agency portal: folder <select> shown only when a real subfolder choice exists (identifies the "Main folder" root client-side without learning the token's folder id). - Settings: folder pick at token creation, bound-folder display, rebind modal. - i18n: 7 new apitoken.* keys across all 5 locales. Tests (429/429): - test/agency-folder.test.js: 5 folderSubtree confinement bites (subtree in, siblings out, workspace guard, null -> root). - test/agency.test.js (+1 e2e): auto-create, default-to-bound, in-subtree pick lands there, sibling -> 403, admin-pick, unknown-pick -> 400, rebind-to-root. Closes #158. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
ef91f644a7
|
feat(system-control): Tier 0/1 controls with no device-owner dependency (#160) (#169)
* feat(system-control): Tier 0/1 controls with no device-owner dependency [#160] Track A of the system-control split (Track B = device owner, shipped in #168). Ships the capabilities that need NO device owner, with graceful per-tier degradation. Capability reporting (keystone): - DeviceInfo now reports can_write_settings / accessibility_enabled / overlay_granted alongside the existing tier/device_owner flags; server persists them (3 additive device columns, older APKs default to 0); dashboard gates controls + shows what's grantable. Android SystemControl (new, all best-effort / no-op when unsupported): - Tier 0 (no permission): media volume (AudioManager STREAM_MUSIC), per-window brightness (WindowManager.LayoutParams.screenBrightness — dims our window only). - Tier 1 (WRITE_SETTINGS): system-wide brightness + screen-off timeout (Settings.System). - Commands set_volume / set_brightness / set_system_brightness / set_screen_timeout wired in MainActivity.onCommand; ALLOWED_COMMANDS extended for the group path. - SetupActivity gains a one-time WRITE_SETTINGS grant row (mirrors the overlay/accessibility grants); manifest declares WRITE_SETTINGS. Dashboard: - device-detail "System control" section (any Android panel): volume + this-app brightness sliders always; system brightness + sleep-timeout only when the panel reports can_write_settings, else a "grant on the panel" hint. Sends on release (not drag). Validated live on a non-owner tier-0 panel: dashboard → set_volume 0.75/0.15 → the panel's STREAM_MUSIC volume moved to 11/2 (of 15). 423 server tests green. Closes #160. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(system-control): volume slider reflects real volume + device-owner brightness/timeout [#160] Two fixes from live testing: 1. Volume "doesn't remember" — the slider hardcoded 50 because the panel never reported its current volume. Now DeviceInfo reports media_volume (0..1); a new lightweight device:info socket event lets the panel re-report right after a set_volume (no full re-register / playlist re-push); server stores devices.media_volume; the dashboard inits the slider from it. Validated: dashboard set_volume 0.60 -> panel STREAM_MUSIC 2->9 (of 15) -> stored 0.60. 2. System brightness/timeout on a DEVICE OWNER — was gated only on WRITE_SETTINGS, which an owner doesn't have. A device owner can set those via DevicePolicyManager.setSystemSetting with no grant, so SystemControl now takes that path when isDeviceOwner(), and the dashboard enables the Tier-1 controls when can_write_settings OR tier===2. STPolicy.setSystemSetting added. deviceSocket device_info UPDATE extracted into applyDeviceInfo(), shared by device:register and device:info. Migration: devices.media_volume REAL (additive). 423 server tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(system-control): brightness/timeout remember + move controls into a tab [#160] Same "remember what it's set to" treatment as volume, now for brightness + sleep timeout, and the System control section moves off the top into its own "Controls" tab. Reporting (DeviceInfo -> device:info re-report -> devices columns -> dashboard slider init): - system_brightness (read from Settings.System, no permission) + screen_off_timeout_ms. - window_brightness: persisted in ServerConfig (survives relaunch, re-applied on MainActivity launch) so the per-window slider reflects it too. - reportInfoNow() now also fires after set_brightness / set_screen_timeout. Dashboard: new "Controls" tab (any Android panel) holding the volume/brightness/timeout controls; every control inits from the reported value; sleep dropdown preselects the current timeout. Server: +3 additive columns (system_brightness, window_brightness, screen_off_timeout_ms); the device_info UPDATE stores them. Migrations all additive/re-runnable. 423 server tests green. Validated live: set_brightness 0.40 -> stored window_brightness 0.40; volume 0.30 -> 0.33. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
501ffb11c1
|
feat(device-owner): tier foundation + QR provisioning + content-expiry & device enhancements (#168)
Device-owner tier substrate + silent install, end-to-end QR provisioning (Android 12+ compliance, APK-derived checksum, URL pre-seed, zero-touch onboarding, guided a11y screen), content-expiry (#157) with player no-restart deferral, and the device-enhancement batch (#10/#12/#13/#14). Backward-compatible with 1.9.3 clients; all autonomous behaviors opt-in. QA + security review green. Closes #161, #157, #159. |
||
|
|
938a43a466
|
Group sync: clock/schedule synchronized playback (offline-native) + double-buffer (#167)
* feat(group-sync): synchronized playback per group (server + Android) [stage 1]
Play a group's shared playlist in lockstep across its displays — start/end items
together — by reusing the video-wall sync primitive with the spatial transform
removed and keyed on group_id instead of wall_id.
Server:
- device_groups += sync_enabled + leader_device_id (optional pin).
- buildPlaylistPayload emits a group_sync:{group_id, is_leader} block ONLY for a
member whose playlist matches the group's shared playlist (playlist-match guard —
a mismatched member is ignored, never synced). Membership via device_group_members.
- Leader auto-election: pinned-if-online -> first online matching member -> stable
fallback; computed (never persisted, so the operator's pin is preserved).
- group:sync / group:sync-request relay among eligible members (mirrors wall:sync,
guarded on membership + playlist match).
- Self-heal: re-push payloads to group members on (re)connect so is_leader refreshes.
- PUT /api/device-groups/:id accepts sync_enabled + leader_device_id and re-pushes.
Android:
- WallController is now mode-aware. WALL = transform + object-fit:fill + forced
follower-mute (UNCHANGED — every wall branch is byte-equivalent when isGroup=false).
GROUP = same leader/follower timing incl. the full video drift controller, but
full-screen (no transform), normal fit, and per-item mute honored (no forced mute).
- WebSocketService: emitGroupSync/emitGroupSyncRequest + onGroupSync/onGroupSyncRequest.
- MainActivity: dispatch emit by mode, parseGroupConfig, onPlaylistUpdate group hook.
Wall path is provably unchanged (additive mode, defaults to WALL). Kotlin compiles;
server suite 407/407.
Stage 2 (follow-up): web + Tizen parity, dashboard sync toggle + leader picker,
offline-sweep leader promotion.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(group-sync): web + Tizen player parity [stage 2]
Mirror the Android group-sync generalization in the two web-based players so a group
with a shared playlist syncs across mixed player types, not just Android.
Web player (server/player/index.html):
- group:sync / group:sync-request handlers (index jump + latency-compensated video
drift, same maths as wall:sync) — no audio policy here, per-item mute honored.
- emitGroupSync() + applyGroupSync() (4Hz leader broadcast; follower sync-request).
NO CSS transform, NO forced mute (unlike applyWallMode).
- isFollower now also true for a group follower (suppresses self-advance).
- handlePlaylistUpdate handles data.group_sync (mutually exclusive with wall).
Tizen (tizen/js/player.js WallController + app.js):
- WallController is mode-aware: WALL styles the slice + forced follower semantics
(UNCHANGED when mode !== 'group'); GROUP clears any wall styling (full-screen) and
drives leader/follower timing only. syncId()/clearStageStyle() helpers; emitSync/
onSync/onSyncRequest key the event + id off the mode.
- app.js: group:sync/request socket handlers; onPlaylist enters group sync on a
group_sync block, else exits — content renders through the normal single-zone path.
Realigned the v4-exit-signal-phase3 TIZEN slice (682->696) shifted by the app.js edits.
Wall path is provably unchanged in both (additive group mode). Server suite 407/407;
both players' JS parse.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(group-sync): dashboard UI — sync toggle + leader picker [stage 3]
On each group's header row:
- "Sync" checkbox -> PUT /api/groups/:id { sync_enabled } enables synchronized
playback; server re-pushes to members so they enter/exit sync mode. A hint notes
it needs a group playlist and that a display on a different playlist is ignored.
- When on, a "Leader: Auto / <display>" picker -> { leader_device_id } (null = auto-
elect, which self-heals; or pin a specific member to always lead when online).
Adds api.updateGroup(id, data) and the en i18n strings (en-only, mirroring the
existing per-group UI keys; the i18n parity test is apitoken-scoped).
Frontend parses (ESM); server suite 407/407.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(group-sync): rework to clock/schedule sync + double-buffer + polish
Replace the leader/follower relay model with clock/schedule sync. Every
same-playlist member derives the identical (index, position) locally from a
server-disciplined clock + the deterministic playlist schedule, so sync:
- needs no server at play-time (offline-native), and
- has no leader role to double-elect (kills the split-brain class the leaked
WallController tick produced).
Server
- heartbeat-ack now carries server_ms + echoes client_ms for NTP-style clock
discipline; the client caches the offset (survives an outage).
- POST /groups/:id/resync -> group:resync (manual "Resync now").
- (kept: group_sync payload; leader machinery is now vestigial/ignored.)
Clients (web / Tizen / Android)
- Clock offset disciplined over the heartbeat, cached (localStorage / prefs).
- Schedule engine: pos = (syncedNow mod Sum(duration_sec)) with a CANONICAL
slot formula identical across platforms so mixed-platform groups can't drift.
- Snap-on-load: a fresh clip hard-seeks ONCE to the exact position (was ~5s of
gentle nudge to eat a ~0.3s load offset); steady-state keeps the gentle nudge.
- Double buffer: warm the next clip a few s before the boundary -> instant
switch, no black hold. Android pre-decodes on a throwaway surface so the swap
doesn't flash one wrong-aspect (landscape-stretched) frame.
- In-place duration edits: duration_sec dropped from the change signature and
applied in place, so a duration edit re-anchors the schedule WITHOUT a restart.
- Live-log shows discrete corrections (jump/align/seek) immediately; only the
steady-state line is throttled.
Android
- Fix leaked WallController leader tick: onDestroy() now stops it (Handler on the
main looper outlived the Activity -> zombie broadcaster / split-brain).
Dashboard
- Group leader picker -> "Resync now" button.
Tests: heartbeat-ack clock fields + resync route; exit-signal .wgt slice realigned.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
34f1cb9e7c
|
feat(dashboard): version indicator + GHCR update check (#165)
* feat(dashboard): version indicator + GHCR update check with admin panel - Add server/lib/ghcr-check.js: GHCR tag poller (36h cache, semver filter) - Extend /api/version with latest_version and update_available - Add POST /api/admin/check-update (force GHCR poll) - Add POST /api/admin/trigger-update (Docker compose or manual instructions) - Sidebar footer: version label + amber badge when update available - Admin > System: version comparison card with Check/Update buttons - 14 new tests (10 unit + 4 integration), 68/68 passing Closes #163 * fix(dashboard): gate trigger-update to platform-admin + add GHCR fetch timeout Review follow-up on #165 (the two blockers): - trigger-update runs `docker compose up -d` on the HOST via docker.sock (root-equivalent) but was behind requireAdmin, i.e. reachable by any workspace-level admin. On a multi-tenant host that's a customer, not the infra operator. Gate it with requirePlatformAdmin (DOCKER_UPDATE_ENABLED still gates it further). check-update stays requireAdmin — it's a read-only GHCR poll. - ghcr-check.checkNow had no fetch timeout. Node's global fetch has no default timeout, so a hung GHCR connection never settled — leaving `inFlight` set forever (the finally never ran), which wedged the background poller AND hung any awaited checkNow (/api/admin/check-update). Add a 10s AbortController timeout on both requests so the try/catch/finally always fire. All 405 server tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: ScreenTinker <hello@screentinker.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
1ebdb1f7a9
|
feat(ota): self-update kill switch — global, per-device, and MDM auto-detect (#166)
Lets an operator (or an MDM) own updates instead of the app self-installing, which on managed panels shows a self-install confirm dialog over customer content (#155). Three layered controls: - GLOBAL (server): config.otaEnabled from OTA_ENABLED (default on). When off, /api/update/check returns update_available:false, reason:ota_disabled_global — the whole instance stops offering updates. - PER-DEVICE (server + dashboard): new devices.ota_enabled column (default 1). When 0, that device is never offered an update (reason:ota_disabled_device). A "Self-update (OTA)" toggle in the device settings flips it via PUT /api/devices/:id. - AUTO-DETECT (Android): UpdateChecker stands down entirely when a foreign device owner (an MDM/DPC) manages the panel — detected via getActiveAdmins() + not being device owner ourselves. Pure client-side, errs safe, needs no server change. The two server gates are enforced server-side so they cover EVERY client version, not just ones with the client-side stand-down. When OTA is off the device still reports its version (dashboard sees state); the MDM/operator owns the actual update. For an MDM-managed fleet (e.g. Pivot/MAXHUB), turn OTA off and let the MDM push the APK — the install-dialog race disappears from every angle. Tests: +2 (per-device gate + a real OTA_ENABLED=false server for the global gate); full server suite 393 pass; Android compiles. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
e0b45bf907 |
fix(playlists): return item schedules from GET /:id so the editor shows them (#156)
GET /:id built the items array but never called schedulesForItem, so the playlist editor rendered "always plays" for items that have a live schedule. Because the editor re-PUTs whatever it loaded and PUT .../schedules is a wholesale DELETE+INSERT, an unchanged save on a mis-loaded item silently wiped the real schedule. Mirror GET /:id/items:351 so the read path returns the blocks the editor and player already agree on. Adds render / round-trip / wipe-trap regression tests (subprocess HTTP harness). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
90b8cbb1e6
|
fix(preview): server-side preview sessions to bypass CSP (#151)
* fix(preview): replace srcdoc with server-side preview sessions to bypass CSP Widget previews (clock, weather, etc.) were rendered via iframe.srcdoc, which inherits the dashboard CSP script-src 'self'. This blocked the inline scripts widgets need (setInterval for clock, fetch for weather), causing previews to show blank/static content. Replace srcdoc with ephemeral server-side preview sessions: - POST /api/widgets/preview-session — stores rendered HTML (Map, 5min TTL) - GET /api/widgets/preview-session/:id — serves the HTML via iframe src, bypassing CSP like the device render endpoint already does The old /api/widgets/preview endpoint is unchanged for backward compat. * fix(preview): add rate limiter for /preview-session route --------- Co-authored-by: BlazzzPlay <fabianma7@gmail.com> |
||
|
|
2ba06e98ec |
feat(#150): preserve per-device settings across delete+re-pair (fingerprint-keyed)
Delete+re-pair mints a new device row whose INSERT omits every setting, silently resetting orientation/name/playlist/etc to defaults (Bold MDM churn). Add a fingerprint-keyed device_settings table (no FK to devices -> survives the cascade): snapshot on DELETE, auto- restore on fingerprint-match re-pair (relinking the fp to the new id), operator re-adopt API (GET /devices/removed + POST /devices/:id/re-adopt) for the changed-fingerprint case. Purge on workspace/user/org deletion (no cross-tenant bleed). Orientation enum-validated on PUT + restore. blocked preserved (re-enforced by the register kill-switch). Wall membership deferred (TODO). Backend only — frontend re-adopt UI NOT built (awaiting API review). Local only, no bump/tag. |
||
|
|
677b17028e |
feat(#146): billing:read scoped token — dual-path auth for the Usage Report (Option C)
Least-privilege way to read GET /api/billing/usage without requiring platform admin. Additive + isolated: reuses the existing api_tokens scope system (the off-ladder 'agency' scope is the precedent) and does NOT touch the shared role/permission checks other endpoints rely on. - New off-ladder scope 'billing:read' (routes/tokens.js SCOPES). Like 'agency' it is NOT on the read<write<full ladder, so tokenScopeGate rejects a billing token on every PUBLIC_ROUTER and JWT-only routers reject any st_ token -> the scope grants billing-read and NOTHING else. - DUAL-PATH gate requireBillingRead (middleware/apiToken.js), written as an EXPLICIT OR: authorize if (billing:read token) OR (platform-admin session). Admins keep read access but are NOT required to; the token path doesn't lock out admins or vice versa. Billing route now mounted with bearerAuth (token OR JWT front door) + requireBillingRead (was requireAuth + requirePlatformAdmin). - MINTING is platform-admin only (stricter than read/write/full/agency, which any workspace member may mint) since a billing:read token grants GLOBAL billing-read. Note: no finer "owner" tier exists here (#14 collapsed superadmin->platform_admin), so PLATFORM_ROLES is the top level required. Tests (5, test/billing-authz.test.js): dual-path positive (token AND admin session both 200) + negative (user 403 / anon 401); scope isolation (billing token 403 on /api/devices, 401 on /api/admin; read token 200 on devices but 403 on billing); minting owner-only (user + ordinary-admin 403, platform-admin 201); revocation -> 401. Existing token firewall/partition suite (api.test.js) + billing-endpoint tests unchanged & green. Reused the exact SHA-256 token-verification path (no bcrypt/new mechanism). Suite 306/306. NOTE: spec described bcrypt + JSON `scopes` + an analytics:read precedent; this codebase actually uses SHA-256 + a single `scope` TEXT column + 'agency' as the off-ladder precedent. Implemented faithfully to the real system. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
977407ce99 |
feat(#146): usage metering + admin-gated Billable Screens report (contract system-of-record)
Implements the ByteTinker-Bold distribution-agreement billing math and surfaces it on a standalone admin-only route. No UI (the API figure is the deliverable). Server-side only. Contract math (lib/billing.js, config-driven; defaults ARE the agreement): - ASD (per device/day) = min(1.0, online_seconds / (hours*3600)) # 28800 default - BillableScreens (per month) = round-half-up( Sum ASD / days_in_month ) - Flat tier (not marginal): 1-499 $1.50 / 500-999 $1.25 / 1000+ $1.00; cost = screens*rate. Single global rate card for now (per-tenant is a future concern; noted in code). Data foundation: - New durable rollup device_usage_daily(device_id, day 'YYYY-MM-DD', online_seconds), index on day. status_log (3d) / telemetry (24h) can't back a billing month. - Accumulated INCREMENTALLY off the heartbeat tick from the live connection map (same source as devices_connected) - never reconstructed from logs. Each tick credits every connected device's today-row (min(86400, +elapsed)), chunked + transactional (non-blocking); per-tick credit capped (accrualCapSeconds) as a stall/restart guard. - Retention ~400d, pruned via chunked-prune (pruneUsageDaily in runMaintenance). API: GET /api/billing/usage?month=YYYY-MM (default current), requirePlatformAdmin, mounted SEPARATELY from /api/status (billing is revenue data + a heavier aggregate; must not touch the hot status path). Reads the rollup only. MTD figure averages over COMPLETED days only (today shown in `daily` but excluded until it completes); is_final + billable_screens_final appear once the month completes. Tests (12): ASD math; billable round-half-up; flat tier/cost boundaries; accumulator (accrues by interval, caps at 86400/day, disconnected doesn't accrue); report MTD-excludes- today + final-month is_final; retention prune; endpoint authz (admin 200 / non-admin 403 / anon 401) + billing absent from /api/status. Suite 301/301. First-full-month caveat + formula in docs/billing.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
9418582de5 |
feat(#146): always-on devices_connected + admin-toggleable /api/status debug block
1. devices_connected (always on, never gated): a top-level /api/status field next to loop_lag = LIVE WS socket count from the heartbeat connection map (getConnectedCount), NOT devices.status='online' (which lags by the offline-timeout). The single most-glanced operational number, so it can't disappear when debug is off. Also dropped 4 dead per-poll COUNT(*) queries the route computed but never returned. 2. debug block behind an admin flag: new minimal app_settings KV table (none existed; ai_settings is per-workspace, white_labels is branding) + lib/app-settings.js (cached, refresh-on-write so status polls read a cached boolean, not a DB row). routes/status.js includes `debug` ONLY when status_debug_enabled is on (persisted value overrides the STATUS_DEBUG_ENABLED env default); when off the key is omitted entirely. 3. Admin toggle: GET/PUT /api/admin/status-debug (requirePlatformAdmin, mirrors the branding endpoints) + a checkbox in the Admin tab "Status endpoint" section (mirrors the branding checkbox). Takes effect on the next poll, no restart. Tests: devices_connected always present+numeric and rises with a live socket (booted + socket.io-client); debug present by default, admin flips OFF -> key omitted (loop_lag + devices_connected remain) -> ON again, no restart; non-admin 403, anon 401; unit coverage for getConnectedCount + app-settings default/override. Suite 289/289. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
fa3ab44c20 |
feat(#146): /api/status.debug throughput counters (gauges -> gauges + work done)
The debug block exposed only gauges (buckets, quarantined, inFlight) — state, not work.
A real flapping Firestick reads as flap.buckets:36, quarantined:0, indistinguishable
from healthy. Add lightweight in-memory throughput counters (total + last-completed
rolling window) so the server tells the flapper/flood story itself.
- lib/rolling-counter.js: shared bounded scalar counter (total, curWindow, lastWindow,
windowStart); rolls lazily on bump AND read (no timer), idle decays to 0.
DEBUG_STATS_WINDOW_MS default 60000.
- flap-limiter: refused{Total,LastWindow} (every allow:false), quarantineStarts{Total,
LastWindow} (a quarantine event stays visible after the gauge decays).
- ota-breaker: stats() rateBackoff{Total,LastWindow}.
- ota-download-guard: servedTotal/shedTotal alongside the per-window values.
- database: maintenance sweepsTotal (confirm the prune is firing, not stalled).
- routes/status: debug block gains ota_breaker + the new fields (aggregate-only, cheap).
Tests: rolling-counter window-roll + idle decay; each counter increments on the right
event; booted /api/status asserts the new fields present + numeric. Suite 285/285.
Fallout doc: observability section lists the fields + what each tells a soak-watcher.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
bfa99771ca |
feat(#146) P3.8: soak observability block on /api/status
Expose the new internal states so we can SEE the limiters biting during the alpha soak
instead of grepping logs. /api/status now carries debug: {
flap: {buckets, quarantined},
ota_download: {inFlight, servedThisWindow, shedThisWindow, windowCount},
maintenance: {deleted, ms, at, running}, // last status-log prune
log_coalescer_buffer,
}. Aggregate counts only (no device ids/secrets), cheap in-memory reads. stats()
added to flap-limiter + ota-download-guard (singleton prod state), getMaintenanceStats
from database. Asserted in the booted /api/status test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
97d489223f |
fix(#146) D: operator block — close the device_id-less gap + dashboard toggle
- Enforcement (deviceSocket): resolve identity ONCE via the SNAT-safe chain and check
blocked against the RESOLVED device_id (device_id directly OR fingerprint->device_id),
so a blocked device that reconnects WITHOUT a device_id is still caught — the old
"if (device_id)" gate let a device_id-less reconnect slip past. Still the first gate,
before flap/throttle/DB/playlist. Nulling the token still does NOT block (it
re-provisions) — the blocked column is the lever.
- Dashboard toggle: POST /api/devices/:id/{block,unblock} (write-gated + workspace-scoped
via checkDeviceOwnership) writes devices.blocked; takes effect on the device's NEXT
register with no restart. api.js + a Block/Unblock button in device-detail.js.
- Outage procedure documented in-code: direct SQLite
"UPDATE devices SET blocked = 1 WHERE id = <id>" works with the dashboard down.
Tests: blocked refused at handshake with no playlist build; device_id-less reconnect
with a mapped fingerprint still refused; unblock effective on next register, no restart.
Suite 262/262.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
ed3cf72b82 |
feat(#142): event-loop lag telemetry (perf_hooks) + bounded storage
Continuously samples event-loop delay via perf_hooks.monitorEventLoopDelay() (C++-backed histogram; cheap). Each window persists mean/p50/p99/max to a new event_loop_lag table and recomputes a coarse load band (normal/elevated/critical) from the window p99. Standalone value: current lag is exposed on /api/status and band changes are logged, so site lag is diagnosable independent of throttling. The band feeds the #142 reconnect throttle (next commit) but ships first as its own subsystem. - event_loop_lag is bounded from day one: indexed on sampled_at + scheduled prune (LAG_TELEMETRY_RETENTION_DAYS, small default) modeled on the play_logs prune. Deliberately NOT another unbounded-growth table. - Band transitions are asymmetric: jump up immediately (tighten fast), release one level at a time after N calm samples below a deadband (release slow, no flap). Pure nextBand() function, unit-tested deterministically. - config: LAG_SAMPLE_INTERVAL_MS, LAG_RESOLUTION_MS, LAG_TELEMETRY_RETENTION_DAYS, LAG_PRUNE_INTERVAL_MS, LAG_ELEVATED_MS, LAG_CRITICAL_MS, LAG_RELEASE_SAMPLES. - tests: band-transition unit tests; integration proves sampling persists, stays bounded under the prune, and surfaces on /api/status. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
071d7cc9c3 |
fix(server): persist per-item mute into the published snapshot (#129)
A mute toggle wrote the draft playlist_items + emitted a live device:mute-changed but only markDraft()'d — it never updated playlists.published_snapshot, the copy the device actually plays. So the device's item.muted stayed 0 and every loop/reload re-applied full volume: dashboard icon red but audio kept playing (Android; web's native <video> loop masked it). emitMuteChanged now surgically patches the matching item's muted (0/1) inside the published_snapshot and re-pushes the playlist, so loops re-apply the correct flag. Surgical patch (not publishPlaylist) so a mute toggle can't prematurely publish other draft edits or flip publish state. Adds a regression test that fails without the patch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
a36880b147 |
fix: per-item mute round-trip + multi-zone orphan-zone fallback & warnings
Two independent multi-zone bugs, plus operator-facing warnings, i18n, and regression tests guarding the data contracts. Bug 1 — per-item mute was a no-op end to end: - GET /api/devices/:id dropped the `muted` column from its assignments SELECT, so the dashboard toggle never reflected state (the muted=false case in particular). Column restored to the device payload. - Android player now honours the per-item mute flag for YouTube (initial state + live via the IFrame JS API). Bug 2 — items whose zone_id belongs to a different layout were silently dropped: - Player fallback (web + Android): an orphaned zone_id is recovered into the largest zone instead of vanishing, with telemetry. - server/lib/zone-validate.js is the single source of truth for the orphan rule (zone not in the device's active layout); used by the device payload (per-item `orphan` flag + `active_layout_zones`) and the device list (`orphan_count`). - Assign-time hardening: a stale zone_id (not in the device's active layout) is cleared to null on POST/PUT rather than persisted as a new orphan. - scripts/find-orphan-zone-items.js: read-only sweep for existing orphans. Dashboard warnings (operator-facing, never on the live player): - Per-item badge + reassign affordance, device-list glance, preview banner. - Graceful degradation: the zone selector falls back to /api/layouts/:id so it can't vanish on a stale payload. i18n: orphan-zone strings added to en/es/fr/de/pt/it (hi falls back by design; count strings interpolate through tn()). Tests: server/test/device-zone-contract.test.js adds 5 regression tests for the data contracts above (muted true/false round-trip, active_layout_zones, orphan flag + count, orphan-clears-on-reassign, assign-time clearing). 172/172 pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
1f2e923005
|
fix(#134): quiet false "reconnect" log + report HDMI output and UI render resolution (#136)
Two device-REPORTING fixes from the #134 investigation (the PiP rendering itself was #135). 1) "Device reconnects every ~45s" was a logging artifact, not instability. The player re-emits a full device:register on the SAME socket every ~45-60s (requestPlaylistRefresh) to pull a fresh playlist; the server logged "Device reconnected" for every register of a known device. The attached 4-day log showed 1415 "reconnected" vs 30 real socket connects and 0 heartbeat timeouts — the socket never dropped, so #134's "PiP lost between reconnects" was a misdiagnosis. Fix: only log a genuine reconnect (new socket); a same-socket re-register is a refresh (currentDeviceId === device_id) and stays quiet. The playlist still refreshes. 2) Device reported 720p while the monitor showed a 1080 signal. DeviceInfo reported getRealMetrics() — the UI RENDER SURFACE — but TV boxes render the UI at 720p and upscale to a 1080p HDMI signal. Now report BOTH: screen_width/height = the output mode (Display.Mode.physicalWidth/Height), render_width/height = the render surface (getRealMetrics). Two new nullable devices columns, stored on pairing INSERT + reconnect UPDATE, exposed via the device API, shown on the dashboard as "1920x1080 (UI 1280x720)" when they differ. Backward compatible (required + verified on emulator): a device that omits render_* — or sends no device_info at all — still registers, with render_* = null, on both the INSERT and UPDATE paths. New columns nullable; stores use `?? null` / `|| null`. All 167 server tests pass. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
7660d7433e
|
fix(#109): render Android PiP overlay above the YouTube WebView video plane (#135)
* 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> * fix(#109): image PiPs never painted — set slot token before decode Emulator e2e of an image PiP (a QR PNG) found the image area always blank (box background + title only). Pre-existing defect, also on main, independent of the occlusion reparent. Root cause in PipOverlay.show(): teardown() clears `current` to null, then loadImageInto() captured `token = current` (null) as its drop-if-replaced guard, but `current` was set to the new pip_id AFTER the media was built. The image decode finishes on a background thread and posts back after show() returns, so `token != current` (null != pip_id) was always true and every decoded bitmap was dropped. Web PiPs and the box/title were unaffected, which masked it. Fix: set `current = pip_id` before building media so loadImageInto's token matches. Verified on emulator — a QR image PiP now renders over both a static image and live YouTube (hardware screencap + the app's software view.draw capture both show it). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(#109): record web PiP (HTML+JS) verification on emulator Web PiP type loads its WebView and executes JS (a page stamping JS OK · <time> rendered over live YouTube). No code change — web PiPs don't use the image path that had the token bug. Completes the image/web/box content-type verification. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(#109): implement PiP close_button on Android (was a documented no-op) The server forwarded close_button (routes/pip.js) and it's in openapi.yaml, but no player rendered it — Tizen deferred "close-button focus" as non-MVP, the web player has none, and Android's PipOverlay never read the flag. So the documented field did nothing on any device. Implement it on Android: when close_button:true, a tappable ✕ floats at the box's top-right in a FrameLayout wrapper that is a SIBLING of the box — so it isn't clipped by the box outline or dimmed by the overlay opacity. Tapping it clears THIS overlay (id-matched via the captured token). Only the ✕ is clickable; the rest of the full-screen pipLayout stays touch-transparent, so taps elsewhere fall through to the playing content (no input regression). Verified on the emulator over live YouTube: the ✕ renders at the corner, and tapping it removes the overlay while the video keeps playing. Parity note: web/Tizen players still don't implement close_button; D-pad focus of the ✕ on non-touch TV hardware is intentionally not wired (MVP = touch/pointer, matching the Tizen focus deferral). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
7539603b17 | Merge #111: device-free preview, playlist + device surfaces (#104) | ||
|
|
647a7de1e6 | Merge #112: duplicate + replace playlist items (#105) | ||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |