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>
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>
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>
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>
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>
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>
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>
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>
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>
Two-token login: /login returns an mfa_pending token when TOTP is on; requireAuth/optionalAuth
REJECT mfa_pending (tightening #1 - else password-alone is a session). /totp/verify exchanges
it + a TOTP or recovery code for a full session (per-user lockout; recovery checked
independently of the decryptable secret). Enrollment: setup -> enable (confirm-then-enable) ->
recovery codes shown once; disable/regenerate require re-auth; regenerate replaces atomically;
status surfaces codes-remaining (tightening #3). API tokens + SSO bypass TOTP by construction.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
app.use('/api/auth/login', rateLimit(...)) etc. keyed on req.path, which Express strips to
'/' for mounted middleware - so /login, /register, /totp/verify shared ONE per-IP counter
(coupled limits; the new /totp/verify brute-force limit was not actually independent). Key
on originalUrl instead. Also adds the /api/auth/totp/verify 10/min limit (tightening #2).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
lib/totp.js: otplib wrapper; secret stored via secretbox (must be reversible to recompute
codes); recovery codes SHA-256-hashed (api_tokens discipline); verifyCode returns the
matched step and blocks intra-window replay via totp_last_step; decrypt failures return
null (no throw). lib/totp-lockout.js: per-user lockout for /totp/verify (#87 model).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
users.totp_secret_enc (secretbox-encrypted, reversible) + totp_enabled + totp_last_step
(replay guard), and the totp_recovery_codes table (SHA-256 hashed, single-use). Migrations
default everything off so existing accounts are untouched. otplib pinned ^12 (v13 is a
breaking plugin-rewrite with no authenticator/checkDelta).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The OTA was invisible server-side: /api/update/check and /download/apk returned without
logging, which is part of why the 1.9.0 auto-relaunch failure went unseen. Log every
version check (client version vs latest, update_available, whether an APK is staged) and
every APK download (a device actually applying an OTA), keyed on the CF-aware getClientIp
so production logs show the real per-device IP behind Cloudflare, not the edge.
Observability for the #96 auto-relaunch work (this is how we'll watch the OTA fire during
the relaunch testing). Part of #96.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 6-digit pairing code is generated client-side, so the server can't raise its entropy
without a player change. Instead, harden server-side (no client change):
- lib/pair-lockout.js: lock an IP out of POST /api/provision/pair after 5 failed claims
(15-min lockout), and expire stale provisioning codes after 15 min so a code is not
claimable indefinitely. A successful claim resets the IP.
- /pair enforces both. Only an UNKNOWN code (404) counts toward the lockout (a real guess);
an EXPIRED code (410) is a legitimate-but-stale code and does NOT count, so a slow bulk
rollout from one shared-NAT IP can't lock itself out. getClientIp is Cloudflare-aware
(CF-Connecting-IP validated against a trusted edge peer), so the lockout keys on the real
per-client IP, never a shared edge.
Unit-tested deterministically with injected time, incl. the bulk-rollout-never-locks case.
Closes#87
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
POST /api/provision was a second pairing endpoint that paired a device by code but,
unlike POST /api/provision/pair, did NOT assign a workspace, enforce checkDeviceLimit, or
emit device:paired / dashboard:device-added - a silently-diverging duplicate that no
client ever called. It now returns 410 Gone and points callers at /pair, so
/api/provision/pair is the single, fully-protected pairing endpoint. The mount stays in
the JWT-only partition, so a Bearer st_ token still gets 401 (requireAuth) before the 410.
Closes#90
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The non-security gaps named in the public-API self-review:
- gap-fix: zone_id (playlist items) + layout_id (device PUT) accepted and returned on read,
INCLUDING the cross-tenant rejection (the is_template OR workspace_id guard - the
security-relevant one).
- docs serving: /openapi.yaml serves the spec, /docs returns the Redoc page.
- i18n drift-guard: apitoken.* keys have full parity across en/es/fr/de/pt (a key missing
in one locale fails CI).
- token lifecycle branches: token-create workspace-membership validation and last_used_at
stamping (integration), plus the must_change_password gate (unit test via the in-memory
DB injection - cross-process WAL visibility is unreliable for that branch in-process).
119 tests total (was 108), all in the existing node --test job.
Closes#92
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Self-review follow-ups, kept as a separate commit so the review trail is honest.
- Spec drift: POST /widgets/preview was documented scope 'read' but the method-based
tokenScopeGate enforces 'write' for any POST, so a read-token integrator following the
published docs would hit a surprise 403. The code is right; fix the SPEC to match it.
- Guard it forever: test/openapi-contract.test.js cross-checks every spec operation's
x-required-scope against the enforcement rule, and that every documented path is a
public (token-reachable) router - both derived from the same config/api-surface.js.
Adds js-yaml (devDep) to parse the spec. Spec/enforcement drift now fails CI.
- Vendored Redoc: add frontend/vendor/README.md (library, version 2.3.9, source, update
steps) and drop the dangling //# sourceMappingURL line so /docs doesn't 404 in devtools.
Remaining (non-security) test-coverage gaps tracked in #92.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A dedicated public-API suite (boots the real server as a subprocess) so CI green proves
the token layer, not just the pre-existing tests:
- Partition firewall, derived from the SAME config/api-surface.js server.js mounts from:
every JWT-only router 401s a token; a public-surface snapshot fails if any router is
added to the token door; known-privileged routers asserted JWT-only.
- Threat model: role-strip gates, workspace-binding both directions (token ignores
X-Workspace-Id, JWT honors it), the scope ladder, the render bypass, token lifecycle,
and JWT no-regression.
- Device WS round-trip via socket.io-client (added as a devDep): valid device_token
registers + receives its playlist; wrong token rejected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- docs/openapi.yaml: the public, token-reachable surface only, with the auth model
(Bearer st_) and a per-operation x-required-scope (read<write<full). JWT-only routers
are excluded by design.
- Serve /openapi.yaml + /docs (Redoc via a vendored standalone bundle, no CDN so it
works air-gapped; /docs is CSP-exempt). docs/ is bundled into the release tarball.
- CI: redocly lint + a public-only guard that fails loudly if a JWT-only path ever leaks
into the spec.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- playlists: accept zone_id on item create + update, validated against a template or a
layout in the playlist's workspace (no cross-tenant zone reference).
- devices: accept layout_id on PUT /api/devices/:id (symmetry with the layouts route),
validated the same way; null clears it. Both are already returned in the GET SELECTs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- routes/tokens.js: create (returns the full secret once), list (never the secret),
revoke. Mounted JWT-only via api-surface.js so an API token can never mint, list or
revoke tokens - no self-escalation.
- Settings "API Tokens" section: create form (name + read/write/full scope), one-time
secret reveal with copy, token list, revoke; i18n across en/es/fr/de/pt.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Introduce the public API's token layer and make the router partition data-driven.
- api_tokens table: SHA-256 hashed secret, st_ prefix, workspace-bound, read/write/full scope.
- middleware/apiToken.js: bearerAuth front door (Bearer st_ -> token auth, else the
unchanged requireAuth); apiTokenAuth acts as the owner with platform powers stripped
to 'user' and the workspace binding made authoritative (X-Workspace-Id ignored);
tokenScopeGate (read=GET, write=mutations) + requireScope('full') for commands.
- config/api-surface.js: single source of truth for the PUBLIC (token front door) vs
JWT-ONLY (requireAuth) router partition. server.js mounts from these lists so the
mount list and the partition firewall test cannot drift.
- device-groups: operational group commands (reboot/shutdown) require the full scope.
A Bearer st_ token fails jwt.verify on the JWT-only routers (401), so privileged
surfaces (admin, workspaces, ai, provision, white-label) are unreachable by exclusion.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
POST /api/provision (the routes/provisioning.js router endpoint) pairs a device
by pairing_code with no rate limit - the limit at server.js:287 was bound only to
the /api/provision/pair override. An authenticated user could brute-force 6-digit
pairing codes against the bare endpoint to claim devices in the unclaimed pool.
Bind the rate limit to the /api/provision mount so it covers both pairing paths.
Verified: 6 rapid POSTs to /api/provision now 429 on the 6th (was unlimited);
/api/provision/pair still 429s on the 6th.
Closes#88
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Each playlist item can carry schedule blocks (active days, start/end
time-of-day, optional start/end dates). An item plays when the screen's
local "now" matches at least one block; an item with no blocks always
plays. #74 covers time-of-day/day-of-week windows including overnight
wrap; #75 covers inclusive date ranges (auto-expiry). Evaluation is
on-device, so dayparting and expiry work offline.
- Shared evaluator contract: shared/schedule-vectors.json (39 vectors —
DST US+AU, overnight-wrap anchoring, timezone correctness, date
boundaries). Canonical JS evaluator in server/lib/schedule-eval.js;
Kotlin and Tizen ports kept in lockstep by drift guards (Tizen byte-diff
test, Kotlin JUnit reads the shared JSON, new android-test CI job).
- All three players (web, Android, Tizen) filter by schedule against their
own clock, idle with a "Nothing scheduled" message + 30s re-check when
everything is filtered, and fail open on any evaluator error.
- Editor: per-item schedule modal + row badge in the playlist editor;
client validation mirrors the server; editing marks the playlist draft.
- Part B (behaviour change): device/group schedule overrides now evaluate
in each device's effective timezone instead of server-local time.
- Device detail shows the reported timezone + a clock-skew warning.
- i18n for en/es/fr/de/pt across all new strings (namespaced itemsched.*
to avoid colliding with the device-schedule calendar's schedule.*).
- CHANGELOG documents the feature, the Part B change, the fail-open
guarantee, and the scheduled-single-video re-render tradeoff.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A socket reconnecting with a device_id that no longer exists in `devices`
(e.g. the row was deleted server-side) hit the device_fingerprints insert
with an unknown foreign key. INSERT OR IGNORE does NOT suppress FOREIGN KEY
violations, so it threw a caught-but-noisy "Fingerprint tracking error" on
every such reconnect. Null out an unknown device_id before the insert; a
genuinely fresh device sends no device_id and was always fine.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A never-visited org had no cached white-label, so brand-prime fell through to the
ScreenTinker default baked into the static index.html and flashed it before
branding.js fetched the org brand. Now the /app route injects the resolved
instance / custom-domain branding into the shell as a <meta name="ssr-brand">
(CSP blocks inline <script>, so a meta carries it), and brand-prime applies that
as the fallback when the per-workspace brand is not cached yet - so the page
paints the configured brand on first load instead of ScreenTinker.
- server.js: /app resolves branding (publicBranding strips internal columns) and
injects the HTML-escaped JSON as a meta tag; falls back to plain sendFile on
any error so branding can never break the app shell.
- brand-prime.js: read meta[name=ssr-brand] when there is no rd_branding_<ws>.
Verified: the meta carries the resolved brand (default ScreenTinker and a
platform-default white-label), internal columns do not leak, 66 unit tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- .github/workflows/release.yml: on a v* tag - verify the tag matches VERSION
(fail-fast guard), run tests, build a source tarball + the unsigned Tizen .wgt
and publish a GitHub Release with generated notes, and build+push a multi-arch
(amd64 + arm64) image to ghcr.io/screentinker/screentinker:<version> + :latest.
The Release (artifacts) and the docker push are independent jobs, so an
arm64/QEMU docker failure does not block the GitHub Release and is re-runnable.
Nothing deploys to prod. APK-build-in-CI left as a TODO (keystore secret).
- Dockerfile + .dockerignore: multi-stage node:20-slim image with server +
frontend + VERSION + scripts; DATA_DIR=/data volume for db/uploads/jwt-secret.
Verified to build, boot, serve the dashboard + web player, and persist state.
- docker-compose.example.yml: /data volume, SELF_HOSTED, a node-fetch healthcheck
against /api/status, and an admin-lockout recovery note (reset-admin.js).
- server.js: resolve the OTA APK from DATA_DIR first (a container can mount one
at /data/ScreenTinker.apk), fall back to the legacy in-repo path, 404 gracefully.
- ci.yml: bump checkout/setup-node to v6 (clears the Node-20 action deprecation).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- server/version.js: shared version helper that reads the root VERSION file once
(fallback 0.0.0). Replaces the stale hardcoded 1.2.0 / 1.5.1 / 1.0.0 fallbacks
in /api/version, /api/update/check, and /api/status.
- config.js: DATA_DIR / DB_PATH / UPLOADS_DIR / CERTS_DIR env overrides for the
db, uploads, and certs/jwt-secret locations. Unset resolves to exactly the
legacy in-repo paths, so existing installs (including production) are
byte-for-byte unchanged. Guarded by test/config-paths.test.js.
- package.json: rename remote-display-server -> screentinker (+ lockfile name).
- scripts/bump-version.sh: one-shot bump across VERSION, package.json (+lock),
android (versionName and versionCode + 1), and the tizen widget version; makes
one commit plus an annotated tag; prints the push command, never pushes.
- .gitignore: global *.db / *.db-wal / *.db-shm / *.db.* so no database file
(including .db.devbak backups, at any path) can be committed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Image generation reused the single (text-endpoint) API key, which breaks the
common 'local LLM with no key + OpenAI for images' setup. Add an optional
image_api_key (encrypted, write-only, never returned); generate-design uses it
for image calls and falls back to the main key when blank (all-OpenAI setups).
Local sd.cpp / ComfyUI still need no key. Schema column + migration.
A prompt now produces a full sign: the LLM writes the design AND image prompts,
the server generates the images and composites them with the crisp text layer.
- lib/image-gen.js: text-to-image with 3 BYO/self-hostable backends, all behind
the SSRF guard: 'sdcpp' (local stable-diffusion.cpp OpenAI-compatible server,
exact small sizes that fit VRAM), 'openai' (cloud / OpenAI-compatible, snapped
sizes), 'comfyui' (prompt/history/view API).
- ai.js: prompt asks for a background_prompt (preferred — full-bleed atmosphere)
and an optional foreground image element; after the design is normalized, the
bg + fg images are generated best-effort (a failed image never fails the sign)
and returned as data URLs. New image_* settings (provider/base_url/model),
image_provider whitelist, schema column + migration.
- designer.js: AI-images section in settings; generate applies the background
image; publish bakes the background image into the HTML so it survives.
- server.js: raise JSON body limit to 12mb for embedded image data URLs.
Verified end-to-end on local Vulkan SDXL (RTX 5090): prompt -> bg+fg images on
the canvas -> publish creates a widget with the images embedded. 63/63.
Note: prod (not self-hosted) requires a PUBLIC image endpoint (e.g. OpenAI); the
SSRF guard blocks localhost there. Follow-up: upload generated images to the
content store and reference by URL to avoid multi-MB widget configs.
Models sometimes stacked text lines at the same y (unreadable) and emitted accent
shapes after text, so a band could hide the words.
- deoverlapTexts: push a line down only when it also overlaps horizontally
(leaves side-by-side text alone), with conservative line-height clearance so
real rendering doesn't re-overlap; shift the stack up if it ran past the bottom.
- Order shapes before text in the output so accent bands always render behind the
words.
Verified: 0 text-on-text overlaps across multiple prompts (Playwright DOM check);
unit test asserts overlapping lines get separated + shapes precede text. 63/63.
Text could run off the edge (long/large headlines, nowrap) and shapes placed at
the far edge (e.g. a bottom band at y=100) spilled over.
- Server-side fit pass on every generated element: shrink text fontSize so it
fits the canvas width (chars*fontSize*0.075, tuned for bold/uppercase
headlines) and height (incl. line-height), then nudge x/y within 4% margins;
clamp shapes so x+width<=100 and y+height<=100. Deterministic - doesn't rely on
the model getting layout right.
- Designer preview: vw -> cqw (+ container-type on the canvas) so text scales to
the canvas, not the browser window. The preview was overstating size vs what
actually publishes; now it matches. Published widget keeps vw (scales on the
player).
Verified: Playwright DOM check shows zero elements overflowing the canvas after
generation; unit test asserts long text is shrunk + repositioned in-bounds. 62/62.
- POST /api/ai/models lists the configured endpoint's models (OpenAI-compatible
/models) so the settings modal can populate a 'Load models' dropdown instead of
requiring users to type the model name. Combobox (datalist) so they can still
type a custom one. Admin only; same SSRF guard; uses the posted or saved key.
- Bump generate-design timeout 120s -> 180s for slow local endpoints.
Competitor pressure (Mandoe 'AI Magic Create'): prompt -> signage. We answer it
in a way that's actually BETTER for signage and costs the operator nothing.
Key idea: don't generate raw images (AI garbles text - fatal for menus/promos).
The LLM returns a STRUCTURED design spec (headline, supporting text, accent
shapes, palette) that the existing Designer renders with real fonts - crisp and
fully editable. Reuses the whole Designer.
BYOK, fully under the customer's control: each workspace configures its own
OpenAI-COMPATIBLE endpoint + key - OpenAI cloud OR self-hosted (Ollama / LM Studio
/ llama.cpp). Operator bears zero AI cost/liability.
- server/lib/secretbox.js: AES-256-GCM for the key at rest (never returned).
- routes/ai.js: GET/PUT /api/ai/settings (admin; key write-only) + POST
/generate-design (editor+). Output is strictly validated/normalized (cap count,
clamp ranges, px->%, strip HTML, validate colors) - never trust the model.
SSRF guard: hosted instances block private/internal targets; self-hosted (the
whole point of local AI) may point at localhost/LAN.
- Designer: an 'AI generate' panel (prompt + Generate) + a settings modal.
Verified end-to-end against local Ollama (llama3.1:8b): prompt -> editable design
on the canvas. Unit tests cover normalization + the SSRF guard. Suite 61/61.
Phase 2 (next): AI background images (OpenAI images / AUTOMATIC1111).
After uploading, content thumbnails were blank until the item was added to a
playlist/widget. The public /api/content/:id/thumbnail (and /file) endpoints are
reference-gated (an anonymous player with a UUID must not pull arbitrary tenants'
media), and a plain <img> can't send a Bearer token - so a just-uploaded item 403'd.
- Backend: add an authenticated bypass - a logged-in user who can access the
content's workspace (verified from the Bearer token) may view its file/thumbnail
even when unreferenced. Anonymous players still hit the reference gate.
- Frontend: the content library lazy-fetches thumbnails/previews WITH the token
and swaps in an object URL (IntersectionObserver keeps it under the rate limit;
the URL is revoked after load).
Verified: unreferenced thumbnail now 200 with a bearer token, still 403 anonymous.
Saving a layout grew its zone count on every server restart. Root cause: the
editor saved zones with a per-zone delete-then-POST loop, and POST /zones minted
a NEW uuid for every zone - so each save replaced the seeded ids (z-sh-1, ...)
with fresh uuids. schema.sql re-seeds template zones via INSERT OR IGNORE on every
boot, so the next restart re-added the now-missing canonical zone alongside the
renamed copy -> a 2-zone template became 4, 6, ... (worse for self-hosters who
rebuild often).
Fix:
- PUT /api/layouts/:id now accepts a zones[] and replaces them atomically in one
transaction, REUSING each zone's id when supplied. The editor sends the full
set in a single call, so the layout ends up with exactly those zones and ids
stay stable (also fixes fit_mode not persisting, and stops device->zone
assignments being orphaned by id churn).
- One-time dedupe migration removes positional-duplicate template zones, keeping
the canonical 'z-...' seeded id so the re-seed stays an idempotent no-op.
Verified: 2 atomic saves keep count + ids stable with fit updated; dedupe restores
a polluted 4-zone split template to its 2 canonical zones. Suite 56/56.
The boot summary counted any non-throwing statement, so UPDATE/index migrations
(which always succeed) made a healthy DB report 'applied N new column migration(s)'
every boot. Count only a successful ALTER ... ADD COLUMN (genuinely new), so the
line appears only when a column was actually added.
Self-hosters rebuilding could end up schema-behind-code, failing only at runtime
(a missing users.must_change_password locked out all logins). Two root causes:
1. The migration loop swallowed EVERY error (catch {}), so a real ALTER failure
was indistinguishable from the benign 'duplicate column' on an already-migrated
DB. Now only 'duplicate column'/'already exists' is treated as a no-op; any
other error is logged loudly, and a one-line summary reports how many new
column migrations actually applied this boot.
2. Nothing verified the schema after migrations. Added lib/schema-check.js:
verifyAndRepairSchema() checks the tables + columns the request path REQUIRES,
idempotently repairs missing repairable columns (logging each), and if anything
required is STILL missing, prints a loud FATAL block and exits - failing fast at
boot instead of at the first authed request.
Note: the reported 'audit_log missing' was a misdiagnosis - the code uses
activity_log (0 refs to audit_log), created by schema.sql on every boot.
Tests: healthy (no-op), auto-repair of must_change_password, missing-table report.
Platform admins can now cleanly remove a customer org (account ends) or a stray
workspace from the UI, instead of raw SQL that risks orphaning resources.
The tenant cascade isn't pure DB CASCADE - workspace-scoped tables (devices,
content, playlists, ...) are NO ACTION and must be purged before the workspace.
Extracted that logic out of deleteUserCascade into shared deleteWorkspaceCascade /
deleteOrgCascade helpers (one tested implementation; deleteUserCascade now reuses
the purgeWorkspaces extraction).
Backend (platform-admin only): GET /api/admin/orgs (list + owner + counts +
workspaces), DELETE /api/admin/orgs/:id, DELETE /api/admin/workspaces/:id.
UI: an Organizations section in Admin listing every org/workspace with a
type-the-name confirmation before the irreversible delete.
Tests: org/workspace cascade (real FKs) + endpoint gating/404. Suite 53/53.
MSPs onboarding customers as separate orgs had no way to create one with
AUTO_CREATE_ORG_ON_SIGNUP=false (the only path was signup auto-org). Add a
platform-admin 'Create organization' action.
POST /api/admin/orgs (requirePlatformAdmin) creates the org + its first 'Default'
workspace. organizations.owner_user_id is NOT NULL, so an org can't be ownerless;
the creating admin becomes org_owner + workspace_admin (mirrors the signup
bootstrap in routes/auth.js) - which also surfaces the org in their switcher.
Customer users are then added via the existing Add User / manage-memberships flow.
UI: 'Create organization' button + single-field modal in the Admin area (gated).
Tests: create (201 + memberships + audit), empty-name 400, non-admin/operator 403.
Multi-zone videos/images were cropped: every template zone inherited fit_mode
'cover' (fill+crop) and the layout editor had no control to change it, so a
landscape video in a tall split zone showed only a center strip. The player
already honors fit_mode (web object-fit, Android scaleType) - the gap was the UI
and the default. Add a per-zone Fit selector (Contain/Cover/Stretch) to the layout
editor, and make 'contain' (show the whole frame) the default for new zones, the
schema column, and the save fallbacks. Existing built-in templates are migrated
separately.
Follow-up to the layout cache. On a cold start with a cached playlist but no cached
layout yet (first run after shipping, or cleared cache), the player still rendered
fullscreen and flashed before the payload arrived. Now gate the optimistic cached
render on the layout being KNOWN (cache key present — null=fullscreen vs object=
zoned, both fine); if unknown, wait ~1s for the payload to drive the first render.
Eliminates the fullscreen flash on the very first pass too.
The player cached only the playlist, not the layout. On cold start it restored the
playlist and rendered immediately with layout=null -> fullscreen, then re-rendered
into zones once the server payload arrived (the 'fullscreen first, then split'
flash). Cache the layout alongside the playlist and restore it before the first
render; cleared on reset.