A device row carries two fields that are not ordinary data: device_token, the
credential the player proves with on the /device socket, and settings_pin, which
unlocks the player's on-device settings menu and so hands physical control of the
panel to anyone holding it.
device_token was already stripped everywhere. settings_pin was not — it went out on
both the collection and the detail endpoint. The dashboard does show it, but on one
screen only: the device detail page, which fetches a single device. The collection
endpoint had no consumer for it and was returning the PIN for every device in the
workspace on every load.
The detail endpoint keeps it, so that page is unchanged. The list no longer sends it.
Same data, much smaller blast radius, no feature lost.
Tests pin the split in both directions — absent from the list, present on the detail,
and the socket credential absent from both (asserted on the whole serialized payload,
not just the top-level key, so a nested echo would fail too).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two problems on a panel showing one fullscreen widget, both visible as flashing.
The player re-navigated the WebView every duration_sec. PlaylistController.next()
requests a playlist refresh between plays and playCurrentItem() re-issues the item
unconditionally, so a one-item playlist reloaded the same URL forever. The existing
dedupe guard only covers the playlist-update path, so it logged "not restarting"
AFTER the reload had already happened. On an interactive widget that also discarded
whatever the viewer had typed.
showWidget() is now idempotent: same URL with the widget already on screen returns
without re-navigating, and the cached URL is cleared at every media-type transition
so switching away and back still reloads. The refresh itself is untouched — schedule
re-evaluation and dayparting still run on the timer, and widgets keep refreshing
their own data client-side (directory-search polls its board every 30s and preserves
the current query). The web player already behaved this way via reevaluateHeldWidget;
this brings the Android player to parity.
Separately, the directory-search keyboard was laid out in fixed pixels for a
1920-wide viewport. A panel's CSS viewport is its resolution over its density, so a
1080p screen at 240dpi presents 1280x720 — where four rows of 56px keys took ~37% of
the height instead of ~24%, and the lone max-width:700px breakpoint never fired to
correct it. Key metrics are now clamped against vh. The clamp maxima are the previous
fixed values and both vh terms exceed them at 1080 tall, so a 1080 viewport renders
pixel-identically; shorter viewports scale down. The breakpoint no longer re-pins .key,
which would have undone the clamp.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Until now the only ways back into an account were an admin setting your password for you
or shell access to run scripts/reset-admin.js. A self-hosted operator who forgot their
password had no path at all, and the admin-reset route explicitly refuses to reset a
platform admin's password — so a single-admin instance was unrecoverable without a shell.
The per-account login lockout added recently makes that sharper: a user who forgets their
password will hit the lockout and see the same generic error, with no way out.
Two unauthenticated endpoints (they must be — the user cannot log in):
POST /api/auth/forgot-password { email } -> always the same 200
POST /api/auth/reset-password { token, password } -> 200 / 400
The properties that matter, each covered by a test:
- NO ENUMERATION. The request endpoint answers identically — same status, same body —
for a real address, an unknown one, an SSO identity with no local password, and a
malformed string. The frontend shows the same confirmation even on a network error,
so the client cannot leak what the server refused to.
- NO MFA BYPASS. Completing a reset does NOT issue a session; the user signs in
afterwards, so a TOTP-enabled account still clears its second factor. Returning a token
here would turn "read one email" into a full session without the second factor.
- SINGLE USE, SHORT LIVED. 32 random bytes, stored only as a SHA-256 hash (same
discipline as email verification, recovery codes and API tokens), 1h TTL, and the
redeeming UPDATE is conditioned on the hash still being present so concurrent
redemptions cannot both win.
- LOCAL ACCOUNTS ONLY. SSO identities have no local password; no token is minted.
- IT ACTUALLY UNBLOCKS YOU. A completed reset clears the per-account login lockout and
must_change_password, otherwise someone who locked themselves out would reset and still
be locked out.
Rate limited: 5/min on the request (it sends mail to a caller-supplied address), 10/min
on the redeem. If no email transport is configured the response is unchanged — no oracle —
but the server logs loudly, because the user will otherwise wait for mail that cannot
arrive and the generic response cannot tell them.
Frontend: a "Forgot your password?" link on the sign-in card, a request card, and a
new-password card. app.js had to learn #/reset-password explicitly — the auth guard
rewrites any unauthenticated hash to #/login, which would have discarded the one-time
token in the emailed link and made it silently do nothing.
Migration adds users.password_reset_hash / password_reset_expires: additive, nullable,
idempotent; a code-only rollback leaves two dead columns.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A screen that was still connected and still displaying its pairing code could not be
paired. Reloading the player produced the same code, and the on-screen instruction
("restart the display to get a new code") could not help.
devices.created_at is written once, at first registration, and the row is never recreated:
a player persists its device_id and its pairing code in local storage and re-registers
with them forever. Expiry was measured from created_at, so 15 minutes after first boot the
row became permanently unclaimable while the device kept heartbeating — and a restart
reused the stored identity and reproduced the same code, so there was no way out.
Observed in production: an unclaimed web player, still online and heartbeating, whose row
was created 4 days 20 hours earlier and had been unpairable for all but its first 15
minutes. Prod is carrying several such rows; alpha has some 13 days old.
Key expiry on last_heartbeat instead, falling back to created_at for a row that has never
checked in. That answers the question the operator actually has — is this screen still
there showing me this code? — while keeping the property the expiry exists for: a device
that has genuinely gone away still expires.
Trade-off, taken deliberately: a code stays claimable while its screen is connected rather
than for a fixed 15 minutes. That is what the product implies, since the code is on the
screen the whole time, and guessing is bounded by lib/pair-lockout (5 failures per IP per
15 min) and the 5/min route limit rather than by this TTL.
SERVER-ONLY. The player's device:registered handler reads only device_id and device_token
and has no way to display a server-issued code, so reissuing one would have left fielded
players showing a stale code — strictly worse. This fix needs no player update and
un-strands every already-affected device in the field on deploy.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
scripts/reset-admin.js mints a JWT carrying `recovery: true`, and middleware/auth.js
accepted that claim on its own with no database involvement. Three consequences:
- NOT REVOCABLE. The only way to invalidate an outstanding recovery token was to rotate
JWT_SECRET, which logs out every user on the instance.
- NOT ENUMERABLE. Nobody could answer "is a recovery token outstanding right now?"
- NOT AUDITED. The synthetic id ('recovery-<nonce>') is not a users row, so every
activity_log insert for it failed the user_id foreign key and was swallowed by a catch —
a break-glass session left no trace at all.
A `recovery_grants` row per minted token turns all three around: DELETE revokes, SELECT
enumerates, expires_at bounds, and used_at + source_ip record when and from where it was
first exercised. The migration is additive and idempotent, so re-running is a no-op and a
code-only rollback just leaves an unused table.
The grant is session-scoped, NOT single-use-per-request. Recovery means many requests —
load the dashboard, list users, reset a password — so consuming the grant on the first
would make break-glass unusable, a worse outcome than the narrow replay window it closes.
Revocation and expiry are the controls; used_at is the audit stamp.
Also fixed, because it is the mechanism that hid this: logActivity now rewrites a
'recovery-*' id to a NULL user_id with the identity in `details`, so break-glass actions
are actually recorded instead of failing the FK; and a dropped audit row now logs a loud
[AUDIT-DROP] line naming the action and increments a counter, rather than vanishing into
console.error.
The token is written to a 0600 file instead of stdout — under systemd or Docker, printing
it meant journald captured a live admin credential well past its lifetime. Added --list
and --revoke-all.
In-flight recovery tokens minted before this change stop working; they live one hour and
were unrevocable, which is the problem being fixed. Minting already required a working DB,
so redeeming against one is not a new dependency.
test/session-token-resolution.test.js now mints a real grant for its recovery token, so
its assertions keep testing that break-glass is refused on those surfaces for lack of a
users row — not for the unrelated new reason that the token is invalid.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The on-device settings PIN (devices.settings_pin, minted at pairing) and the pairing code
assigned to imported devices both came from
`Math.floor(100000 + Math.random() * 900000)`.
Math.random is not a CSPRNG. V8 implements it as xorshift128+, whose internal state is
recoverable from a handful of consecutive outputs, and every call in a process draws from
that one shared stream. Both values are also observable by ordinary users — settings_pin
is returned in device API responses today — so a user who collects a few outputs could
predict the values minted around them, including for other tenants.
lib/numeric-code.sixDigitCode() uses crypto.randomInt, which is CSPRNG-backed and
rejection-samples so the distribution stays uniform. Range is 100000..999999 inclusive,
identical to the old expression, so codes are still exactly six digits with no leading
zero — the on-device keypad and pairing UI are unchanged.
Deliberately NOT converted, because neither gates access: the image-generation seed in
lib/image-gen.js, and the anti-burn-in pixel jitter inside generated widget HTML.
Also unchanged: the settings_pin backfill in db/database.js, which uses SQLite's random()
— that is ChaCha20 seeded from OS entropy, not a weak PRNG.
This is the generator half of the finding only. The separate half — that settings_pin is
returned to every workspace member, including read-only roles — is a response-shape change
and waits on the consumer enumeration.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
GET /api/devices/:id/screenshot returns a live picture of what a screen is showing, but
it was still authorized pre-tenancy: `device.user_id !== user.id`, with a role bypass
listing 'admin'/'superadmin'. Three consequences, all now covered by tests:
- `device.user_id &&` SHORT-CIRCUITED. A device with no user_id — never paired, or its
owner deleted — skipped the ownership test entirely, so any authenticated account on the
instance could read it. An unpaired panel displays its pairing code on screen, so that
image is also a route to claiming the device (AUTH-10, out of scope here but connected).
- 'platform_admin' was absent from the bypass list. #14 renamed 'superadmin' to
'platform_admin', so an actual platform admin fell through to the ownership test and was
denied unless they happened to own the row.
- Workspace members other than the owner were denied a device they administer through
every other endpoint.
Now uses accessContext() against the device's workspace — the same helper routes/devices.js
uses — which covers direct membership, org-level access and platform staff in one call. A
device with no workspace is denied outright rather than defaulting open.
Deliberately unchanged: the ?token= query-parameter mechanism on this route, which is a
separate finding with its own blast radius.
No response shape change: still 200 / 401 / 403 / 404 with the same bodies.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The only throttle on POST /api/auth/login was the per-IP limiter in server.js. That
bounds one noisy source and nothing else: it does not bound a distributed attempt, and
it is only as accurate as a deployment's proxy configuration. Nothing counted failures
against the account actually being attacked, and nothing cleared such a count on success
because no such count existed.
lib/login-lockout.js mirrors lib/totp-lockout.js and lib/pair-lockout.js so there is one
lockout idiom here rather than three. 10 failed passwords lock an account for 15 minutes.
Keyed on user.id, never on the submitted email: the email is attacker-supplied and
unbounded, so keying on it would let anyone grow the Map without limit — the same class
of bug fixed elsewhere in this campaign. A user id only exists for a real account, so the
key space is bounded by the user table and needs no eviction sweep, exactly like
totp-lockout.
A locked account returns the SAME 401 and body as a wrong password. A distinct 429 would
tell an attacker "this account exists and is under attack", turning login into an
account-existence oracle; the test asserts the locked response is byte-identical to both
the wrong-password and unknown-account responses. The trade is that a locked-out
legitimate user sees the generic message, so the trip is recorded in activity_log
(auth:login_locked) for the operator instead.
The counter is cleared as soon as the password verifies — before the TOTP and
email-verification branches, which return early and never reach issueSession, so a reset
placed there would never fire for those accounts. SSO paths do not share this code and
are unaffected.
Frontend needs no change: login.js renders any non-ok body's `error` string verbatim, and
the body is unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The diag widget runs in a null-origin sandboxed iframe, so it cannot carry a session and
its telemetry POST must stay unauthenticated. But the handler stored into a plain Map
keyed on a value taken from the request body, with no cap, no TTL and no eviction — an
unauthenticated caller could add entries until the process died. On this product a dead
server is a fleet-wide reconnect, so a bound here is a fleet-safety control.
Two changes:
- lib/bounded-snapshot-store.js: a "latest snapshot per key" store with a global entry cap
and a TTL, evicting least-recently-WRITTEN. The cap is GLOBAL rather than per-IP on
purpose — signage sites egress through one NAT address, so a per-IP limit punishes a
whole venue for one noisy panel and does nothing about a distributed writer. Same
reasoning the OTA download guard already documents ("NEVER per-IP (SNAT)"). A live panel
rewrites its key every 2.5s, so only entries the dashboard already treats as stale
(>15s) are ever eligible for eviction.
- The POST now answers 204 instead of res.json({ok:true}). The reporting widget ignores
the response (fetch(...).catch()), and services/activity.js activityLogger wraps
res.json — so this also stops an anonymous caller from writing one activity_log row, and
running two synchronous statements, per report.
Read contract unchanged: a live key returns its object, an unknown OR expired key returns
null — the shape frontend/js/views/device-detail.js already handles ("no report yet"), and
it treats anything older than 15s as stale regardless, so the 60s TTL is 4x looser than
what the UI honours. No client change; no rate limiter added.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
getClientIp() decides the value every per-IP control keys on — the auth/pairing rate
limiters, lib/pair-lockout, and activity_log.ip_address — so a caller must never be able
to choose it. It believed CF-Connecting-IP whenever the immediate peer was in the
`trust proxy` list, which includes loopback/linklocal/uniquelocal.
Those entries are correct for X-Forwarded-For: a proxy APPENDS to that header and Express
walks the chain right-to-left, so a client-supplied value cannot become the resolved
address. CF-Connecting-IP has no chain — a local reverse proxy passes through whatever
single value the client sent — so treating a loopback peer as evidence the request came
through Cloudflare means trusting the client.
Gate it on the published Cloudflare ranges alone. This is also the portable behaviour:
most self-hosted installs are not behind Cloudflare, and for them the header is now
simply ignored, with attribution falling back to req.ip under whatever `trust proxy` the
operator configured. Installs that do front with Cloudflare are unaffected — their peer
really is a CF edge.
Documented the distinction at config/cloudflareIps.js so the two lists are not conflated
again. No response shape or DB change; no client impact.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Uploaded files are served from the SAME ORIGIN as the dashboard, so how a browser
interprets them is a security boundary. Two things decided that interpretation, and
both were caller-controlled: the stored extension came from
`path.extname(originalname)`, and the only type check read `file.mimetype` — a request
header. A caller could therefore choose to have their bytes served as an active
document from the app origin.
Two independent invariants now hold the boundary:
1. INGEST — lib/upload-sniff.js sniffs magic bytes after multer writes a neutral
`.part` file (diskStorage names the file before any bytes exist, so the sniff cannot
happen there), maps the result through a hardcoded mime->extension allowlist, renames
accordingly, and stores the sniffed mime. Unsupported bytes are refused with a 400.
2. SERVING — upload responses carry `Content-Security-Policy: sandbox`, so if a response
is ever treated as a document it lands in an opaque origin with scripts disabled.
Anything outside the inline-safe extension set is additionally forced to download.
This holds regardless of how a file reached disk, so a future gap in (1) is contained
rather than exploitable.
Applied at every instance of the pattern, not just the first: lib/content-ingest.js,
the /replace route, the four content-serving paths across server.js and routes/content.js
(the latter pair currently shadowed by mount order, which is not a guarantee), and the
ZIP-import path in routes/status.js, which took its extension from the archive entry.
SVG stays accepted and stays inline: white-label logos are SVG, and octet-stream +
nosniff makes <img> fail. Scripts in an SVG never run in an image context, and the
sandbox CSP covers the one case where they would — a direct navigation. SVG is also no
longer handed to sharp, which removes the librsvg path where the open libvips CVEs live.
Existing rows are untouched — no migration. The four upload fixtures in agency.test.js
uploaded `Buffer.from('x')` declared as image/png; that is the exact "declared type is a
lie" case this closes, so the fixtures now use real PNG bytes. No assertion changed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
optionalAuth was exported but never mounted on any route (verified by grep across
server, frontend, scripts and tests: only its own definition, its export, and one
stale comment referenced it). It also carried a second, slightly different copy of
the token-resolution logic - its own user column list, and no forced-password-change
check - which is exactly the drift the preceding commit consolidates away.
Removing it rather than porting it to resolveSessionUser: a "set req.user if a token
happens to be present" middleware is a few lines on top of the shared resolver if a
route ever needs one, and an unused export is a standing invitation to mount it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
The webpage-widget preview note claimed: "the site blocks embedding in a browser
— it will still display on the device screen." The second half is false. The
widget renders the URL in an <iframe> (renderWebpage), and the device player
loads that page in a Chromium WebView, so a site sending X-Frame-Options /
CSP frame-ancestors (Amazon, Google, most large sites/banks) is refused on the
device exactly as in the browser preview. The note set the wrong expectation —
a customer (and we) chased CORS and "should work on device" when the live
device screen was blank too.
Reword to tell the truth in all 6 languages (en/es/fr/de/it/pt), both the
frontend i18n key (widget.webpage_blocked_note) and the player's
preview_webpage_blocked string: if the preview is blank the site blocks
embedding and won't display on the device either — try a page that allows it.
Copy-only; no behaviour change. This is not an Amazon-side fix (embedding refusal
is the site's choice) — just accurate messaging.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
getUserPlan()'s auto-downgrade was guarded on `subscription_status !== 'active'`,
but that column DEFAULTs to 'active' and is only ever changed by Stripe webhook
events. For trial users who never touch Stripe — the entire population it's meant
to catch — the condition was always false, so the downgrade never ran and every
signup kept Pro free forever.
Re-key the guard on the real signals:
- trial expired (!trial_active), AND
- stripe_subscription_id IS NULL (never paid), AND
- plan_id === trial_plan (still on the plan the trial granted), AND
- plan_name !== 'free'
The plan_id === trial_plan clause is load-bearing: it protects comped / hand-
granted plans (e.g. a manually-set enterprise plan, where plan_id !== trial_plan)
from being silently downgraded. Grandfathered accounts (trial_started IS NULL)
never enter the block at all, so the ~home cohort is untouched. Added a comment
documenting the subscription_status-default trap so it isn't reintroduced.
Enforcement stays forward-only/lazy — the downgrade happens in the resolver on a
user's next request; no mass update here.
Downstream (deviceSocket.checkDeviceAccess, traced, unchanged): a genuinely-
expired free-tier trial now resolves to free and its device-limit block correctly
caps it to 1 device; grandfathered home (2 devices) and paid users are not
blocked. NOTE: the separate "Trial Expired" screen branch there is a pre-existing
dead condition (it needs trial_started set AND plan_name='free' at once, but the
downgrade clears trial_started) — left as-is per scope; flagged for follow-up.
Tests (new trial-expiry.test.js — there was none, which is how this shipped):
lapsed trial downgrades; comped enterprise (plan_id!=trial_plan) not downgraded;
grandfathered home (trial_started NULL) not downgraded; paid user not downgraded;
in-window trial not downgraded; plus a regression pinning that subscription_status
='active' no longer shields a lapsed trial. Suite 563/563.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
Ran `npm audit fix` (no --force) in server/. Only transitive dependencies
moved, within existing semver ranges — package.json is unchanged, so this is
purely a package-lock.json update:
ws 8.18.3 -> 8.21.1, qs -> 6.15.3, body-parser -> 1.20.6,
engine.io -> 6.6.9, js-yaml -> 4.3.0, plus express/socket.io sub-deps.
Vulnerabilities: 13 (6 moderate, 7 high) -> 2 high.
Verified: full server suite 556/556 (incl. socket-handler + reconnect-storm
tests that exercise ws); boot smoke OK (server starts, /api/version responds,
socket.io/engine.io handshake returns 200).
Left for a separate, deliberate change (both need breaking major bumps):
- nodemailer 6 -> 9 (email send API; several CRLF/SSRF advisories)
- sharp 0.33 -> 0.35 (libvips CVEs; thumbnail/image path)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
YouTube Shorts never fire the ENDED state via the IFrame API, and some
Android TV WebViews drop ENDED even for regular videos. The player advanced
solely on onStateChange ENDED, so a missing event stalled the playlist
indefinitely.
Arm a duration-based fallback timer in onReady (getDuration + 3s slack) that
calls nextItem() if ENDED never arrives. It is cleared on a real ENDED, on
onError, when a newer player is created, and in teardownCurrentMedia so a
stale timer can't force a spurious advance after rotation. Skipped when
looping (single-item playlists) and when duration is 0 (live streams).
The Tizen player is not affected: it embeds YouTube as a plain iframe and
already advances multi-item playlists on a duration timer rather than the
YT JS API, so it never waits for ENDED.
Closes#215
Refs #184
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Add Device modal now shows the server URL and full Smart TV player URL
- Smart TV note changed from bare /player to full URL (dynamic via JS)
- /download/apk error page now includes a download link to GitHub Releases
- i18n keys added in en + es, old smart_tv_note removed
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
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>
The cold-start cached-playlist restore runs at top-level during initial
script execution: it calls startPlaybackAt(0) -> playCurrentItem ->
renderContent, whose first statement is `renderSeq++`. But renderSeq was
declared with `let` next to the buffered-video code far below, so it was
still in the temporal dead zone on that early path:
ReferenceError: can't access lexical declaration 'renderSeq' before
initialization (renderContent -> playCurrentItem -> startPlaybackAt)
Result: any paired device with a cached playlist + known layout threw on
cold load and rendered nothing. Regression from the warm-play/buffered
render work, which made renderContent touch renderSeq at its very top.
Fix: declare `let renderSeq = 0` with the other top-level player state so
it is initialized before the restore path can call renderContent. No
behavior change to the buffered-render logic.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reconcile advanceTimer on mode enter/exit via reconcileAdvanceTimerForMode in applyWallMode/applyGroupSync — fixes the group-entry zombie timer and the solo-exit frozen image. Closes#200.
Pre-release review follow-up to #204: fixes the Android superseded-wipe playlist wedge + GL leak, and adds the stale-item guard to web/Tizen renderVideoBuffered.
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.
* fix(web-player): buffered widget swap + solo-board hold to end directory-board black flicker
A fullscreen widget (e.g. a solo directory board) re-rendered on the advance timer:
renderContent tore the container down to black (innerHTML='') BEFORE the replacement
iframe finished loading, and a single/only-active widget re-advanced to itself every
duration_sec — so the board cycled black every few seconds. That reload was ALSO the
only thing refreshing the board's static, server-rendered data, so simply holding it
in place would freeze the data.
- Buffered swap: build the new widget iframe hidden OVER the current content and reveal
it on 'load', then tear down the outgoing content — no black frame on any widget
transition. On a load timeout, keep the last-good board and discard the dead hidden
frame via a shared cleanup path (don't reveal a blank frame); a transient server blip
self-heals on the next refresh.
- Solo/held widget (nextActiveIndex === currentIndex): hold in place and refresh its
DATA on a decoupled interval (WIDGET_SOLO_REFRESH_MS = 60s) via the buffered swap,
instead of re-querying the DB + re-rendering full HTML every duration_sec, fleet-wide.
Scoped to non-wall fullscreen widgets; wall+widget keeps the legacy path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(web-player): route held directory-board refresh through nextItem (schedule-aware)
Follow-up to the buffered widget swap: the solo/held board refreshed via a bespoke
self-rescheduling loop that never re-evaluated the schedule — so a board could outlive
its daypart, and a newly-active sibling item was never picked up (the player stuck on
the board). Delete the duplicate loop entirely and advance via nextItem in both the
held (WIDGET_SOLO_REFRESH_MS cadence) and rotating (duration) cases: nextItem
re-evaluates the schedule every cycle and re-renders the held board through the buffered
swap (still no flash), and drops the duplicate code path that caused the bug.
Verified: the timer-lifecycle harness (6 scenarios / 68 assertions) still passes,
including widget->video transition and the leak/timer-count checks.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
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>
* 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>
The Tizen .wgt player black-flashed between IMAGE items on slow decode HW
(Samsung OM55B / SSSP). Root cause: playCurrent() called clearStage() before
renderImage() set img.src, so the stage was empty (black) until the new image
decoded. Images had no decode-gated double-buffer — video gained one in #167
(938a43a), which made the always-present image flash conspicuous by contrast.
renderImage has been byte-identical since the first Tizen commit, so there was
nothing to revert; the buffer had to be ADDED, mirroring the video path.
- preloadImage()/_takePreloadImage()/_releasePreloadImage(): one-ahead,
image-only decode-gated buffer modeled on preloadVideo()/_takePreload().
Detached <img>, src set, warmed via HTMLImageElement.decode() (feature-
detected — onload/complete fallback for Tizen 5.0 / SSSP6). Warmed when the
current image begins its dwell and from the group-sync boundary tick.
- renderImage now SWAPS: take the pre-decoded <img> (or decode a fresh one) and
only THEN clearStage()+append, in one synchronous block — the compositor
never sees an empty stage. Never clear-then-load on the image path.
- Scoped to images only: playCurrent() skips the up-front clearStage() solely
for image targets (same branch order as the dispatch); video/youtube/widget
keep their pre-dispatch clear untouched. onerror and decode() rejection route
to skipSoon(); stale-index guard blocks mounting a stale decode over the
current item after next()/gotoIndex/load(); one-ahead with stale release on
index move, load(), stop(), and group-sync exit. #A1 single-item heal intact.
Tests:
- server/test/tizen-image-blackflash.test.js (new, 4/4): loads the real
player.js in a vm context with a test-controllable decode() Promise and proves
the invariant — across image->image the #stage is NEVER without a mounted
<img> (old element held until the new image's decode()/onload resolves, then
swap). Covers decode() supported, decode() absent (onload fallback), and
broken-image (decode-reject / onerror) -> skipSoon. Proven to VIOLATE on the
old clear-then-load ordering and HOLD on the fix.
- server/test/pip-overlay.test.js: the decode-gate makes image mount async,
which broke its older shim (no decode()/onload/complete). Teach the shim
element complete/naturalWidth so renderImage takes its synchronous
complete-fallback branch and mounts. Test-only.
Full server suite 486/486. node -c clean. Headless proves the DOM ordering
invariant (the flash's precondition); final black-frame sign-off needs a real
OM55B panel (manual steps in the test-file header).
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
A fresh unclaimed player that reconnects (same fingerprint) INSIDE the server's
~5s deferred-offline grace hit a false 'active on another connection' reclaim
reject, then collided on UNIQUE(devices.pairing_code) on the fall-through INSERT
and wedged unclaimed with no content. Real trial customer (web player) hit it.
server/ws/deviceSocket.js:
- Fix A (guard): gate the liveConn reclaim reject on !inDeferredOffline
(pendingOfflines.has(id)). A device mid-deferred-offline is a zombie, not live,
so a same-fingerprint reconnect is a legit reconnect, not a hijack. A genuinely
live socket (never disconnected -> no pending-offline) still rejects a cloned
fingerprint -> anti-hijack boundary preserved (documented).
- Fix B (idempotency): when the unclaimed old row holds the SAME pairing_code the
reconnecting player presents, ADOPT/refresh it (mirror the claimed-reclaim path,
but no device:paired) instead of INSERT-colliding. Differing-code case unchanged.
- deferOffline is NOT shrunk (it exists to prevent transient-blip flapping).
server/player/index.html:
- The cold-boot flap source: an unfiltered pageshow handler ran verifyLivenessSoon()
on every load, opening+registering a socket early, which the boot connect() then
tore down and rebuilt (connect->register->disconnect->reconnect). Guard it with
ev.persisted (mirror the pagehide guard) so only real bfcache restores trigger it.
server/test/pairing-race.test.js:
- Forces the race against the real socket server (log-gated reconnect inside the
deferred-offline window), asserts no false reject / no UNIQUE collision / single
claimable row; + a hijack case asserting a cloned fingerprint on a genuinely-live
display is still rejected. Web- and android-shaped fingerprints. Fails 2/4 on
pre-fix code, 4/4 with the fix.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
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>
* 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>
Bold Media Group's fleet broke on the 1.9.3->1.9.6 upgrade. Their MDM does an
uninstall/reinstall (app data wiped), so the player registers with
{ pairing_code, fingerprint } and NO device_id and shows a pairing code — but the
dashboard reported "code does not exist". Deleting the device_fingerprints row fixed
it, which pinpointed the fingerprint-reclaim guard in server/ws/deviceSocket.js.
Root cause: the reclaim guard was
`stillAlive = !!liveConn || secondsSince < reclaimSettleSeconds; if (stillAlive) reject`.
On an in-place reinstall the old row heartbeat seconds ago, so `secondsSince < 300` is
ALWAYS true -> it emitted device:auth-error and returned BEFORE the pairing_code INSERT,
so the code the player displayed never existed server-side.
The settle window's real purpose was to REMATCH an existing fingerprint back to its
device row on reinstall — not to force a fresh re-pair. So the fix keys off claim status,
not the timer (server-only; no APK change — reviewed and confirmed unnecessary):
- Reject ONLY when the old row has a genuinely LIVE socket (liveConn) — the real anti-
hijack boundary. Unchanged.
- CLAIMED old row (user_id set) -> RECLAIM it regardless of the settle window: reuse the
row, rotate the token, emit device:registered{online} + device:paired. The panel returns
straight to paired (no operator re-pair, no orphaned duplicate row), preserving name /
claim / playlist / content. device:paired drives the app off the pairing screen, so the
fresh code it showed is irrelevant.
- UNCLAIMED old row -> fall through to the pairing_code path and PROVISION FRESH with the
shown code (reclaiming would leave a stale/null code -> "code does not exist"). #150
relinks the fingerprint to the new row.
`reclaimSettleSeconds` is now vestigial for this path. Trade-off: a fingerprint-only reclaim
of a CLAIMED-but-offline device is no longer delayed ~300s — not a new attack class (the old
code already granted it once the window elapsed); liveConn remains the hard boundary. Truly
closing that window without a re-pair needs client keystore attestation (a future APK).
Also fixes a latent crash this newly exercises: middleware/subscription.js getUserPlan()
dereferenced an undefined user in its else branch ("Cannot set properties of undefined
(setting 'trial_active')") when the user/plan JOIN missed. Under the claimed-reclaim path
that ran checkDeviceAccess->getUserPlan, the throw was swallowed by the reclaim try/catch and
silently dropped the device to provision-fresh. Guard: `if (!user) return null`.
Tests (server/test/fingerprint-reclaim.test.js):
- NEW: a CLAIMED reinstall reclaims the SAME row, emits device:paired, creates no duplicate,
keeps the fingerprint linked — regardless of the settle window (the Bold repro, fixed right).
- NEW: recent heartbeat + no live socket, UNCLAIMED -> provisions fresh with the shown code.
- NEW: a LIVE old socket still rejects and creates no new row (security preserved).
- Updated the #143 gone-device test to expect provision-fresh for an unclaimed row, and the
log-noise assertion to the "reclaim rejected" message.
465/465 server tests pass. Server-only: NOT deployed, no version bump, Android untouched.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Adds a pluggable email transport so self-hosters without Azure/M365 can send
mail through any standard SMTP server (Postfix, Gmail, Mailgun, SendGrid, corp
relay). Graph stays the default; behavior is byte-for-byte unchanged when
EMAIL_TRANSPORT is unset or "graph".
- config: EMAIL_TRANSPORT ("graph"|"smtp", default graph) + SMTP_HOST/PORT/
SECURE/USER/PASSWORD/FROM.
- services/email.js: branch by transport behind the SAME public sendEmail()/
isConfigured() surface. SMTP via nodemailer (lazy-required, like MSAL).
Shared across both transports: the "[ScreenTinker] " subject prefix (unless
rawSubject), the GRAPH_DEV_RESTRICT_TO allow-list, html-from-text derivation,
and the never-throws contract (failures log + return sent:false). SMTP_SECURE
true=implicit TLS(465)/false=STARTTLS(587). Auth optional (unauthenticated
relay ok); SMTP_USER without SMTP_PASSWORD is flagged. SMTP_FROM parses
"Name <addr>". New emailConfigStatus() for startup diagnostics.
- server.js: startup logs the transport and a LOUD error when the selected
transport is partially configured (some fields set, others missing) or when
EMAIL_TRANSPORT is invalid (falls back to graph). A fully-unset transport
stays a silent stdout fallback (unchanged dev behavior).
- nodemailer ^6.9.16 added as a production dep (bundled in the Docker image).
- .env.example + README: SMTP config section, Gmail example, transport table.
- test/email-transport.test.js: 15 tests — transport selection, config
validation (missing/partial/invalid), SMTP message building (from/prefix/
fromName override/text alt), sendEmail routing (mocked nodemailer), rawSubject,
dev-restrict on smtp, and the smtp_error never-throws path.
462/462 server tests pass. Boot verified for all four states (configured,
misconfigured, invalid, default).
Closes#173
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
express.static runs with index:false, so a bare /integrations/ fell through to the
SPA catch-all and rendered the dashboard login instead of the integrations hub — the
top-nav "Integrations" link, the canonical, and the sitemap entry all dead-ended at
login. Add an explicit route (like /agency, /sitemap.xml): /integrations/ -> the hub's
index.html, and /integrations -> 301 /integrations/. Spoke pages are real .html files
already served by static. Folded into a re-cut of v1.9.6.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* 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>
The subprocess-booting test suites hand-picked fixed ports in a cramped ~3955-4021 range, and
156-schedule-read-path deviated to a RANDOM port (3900 + rand%90) that overlapped those fixed
ports. Under CI load two servers could race on the same port, surfacing as flaky "no such table:
devices" / "FOREIGN KEY constraint failed" (a server answering a request against a half-migrated
or wrong DB). It's environmental — the suites pass locally and in isolation.
Fix: a shared test/helpers/free-port.js (bind :0 on loopback, read the OS-assigned port, release)
called in before() so every suite gets a guaranteed-unique ephemeral port — concurrent suites can
no longer collide, and no one has to hand-assign ports.
- Codemod converted 30 suites: const PORT = <fixed|random> -> let PORT (+ BASE) assigned via
`PORT = await freePort()` at the top of before().
- 3 hand-fixed (different structure): 148-eviction-storm (lowercase `base`), boot-health (no
before() — allocates PORT + a throwaway SEED_PORT inside the test, replacing the hardcoded
3894), totp-keyrotation (no before() — allocates at the test start before bootServer()).
No fixed 39xx/40xx ports remain. Full server suite 435/435; the 4 hand-touched suites pass in
isolation. Pure test-infra change — no app code touched.
* fix(content): rotation-aware media dimensions — portrait no longer stored landscape (#170)
Ingest recorded CODED width/height and ignored rotation, so a portrait phone video
(coded 1920x1080 + 90° Display-Matrix) or a portrait photo (EXIF orientation 6) was
stored LANDSCAPE. The player then rendered it wrong-aspect and letterboxed — the
"portrait content degraded + blue bar at the bottom" symptom in #170. The reporter's
workaround (pre-rotate + mark Landscape) is exactly what this bug forces.
- lib/media-orientation.js (new): pure, unit-tested display-dimension helpers = single
source of truth for ingest AND the backfill. videoDisplayDims() reads the modern
Display-Matrix side_data rotation (falls back to the legacy tags.rotate, sign-normalized);
imageDisplayDims() honors EXIF orientation 5..8. Odd quarter-turns swap W/H.
- lib/content-ingest.js: use the helpers for stored dims; add sharp .rotate() so image
THUMBNAILS are auto-oriented too (video thumbs were already auto-rotated by ffmpeg).
- scripts/backfill-rotation-dims.js (new): idempotent, dry-run-by-default maintenance to
correct already-uploaded portrait media (re-probe -> fix dims -> regenerate image thumbs).
- test/media-orientation.test.js: 5 bites (tag + Display-Matrix, sign/normalize, EXIF 5..8,
the blue-bar landscape->portrait case, null-safety).
Scopes #170 to its residual-on-1.9.4 issues; the 1.9.3 "never displays" slice was #162 +
the remote_url-null download fix, already shipped in 1.9.4. The slow low-res/orientation-
cycling first load is tracked separately in #170 pending repro data.
Refs #170.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(android): honor EXIF orientation in ImageLoader so portrait photos render upright (#170)
Completes the rotation-aware media fix on the PLAYER side. The server ingest fix (this
branch) corrects stored dimensions + auto-orients the thumbnail, but the panel draws the
full-res original via BitmapFactory, which ignores EXIF — so a portrait photo (landscape
pixels tagged "rotate 90") still rendered sideways on the screen. QA root-cause pass on
#170 caught this gap: the Android player reads no stored dims and applied no EXIF.
ImageLoader now reads the EXIF orientation (from the file for cached content, from the byte
stream for remote_url images — ExifInterface(stream) is API 24+, minSdk is 24) and rotates/
flips the decoded bitmap via a Matrix (all 8 orientations). NORMAL/UNDEFINED is a no-op (no
extra allocation); a transformed copy recycles the source; OOM falls back to the source
rather than crashing. Videos were already correct (ExoPlayer honors the rotation matrix).
Verified: :app:compileDebugKotlin clean.
Refs #170. Rides with the server rotation-dims fix on this branch.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
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>
* 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>
* 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>
* 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>
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>
When the web player socket reconnects before the device is paired,
register() omitted device_id and device_token (gated behind config.paired).
This caused the server's fingerprint reclaim guard to treat the reconnect
as a fresh anonymous registration with a colliding fingerprint, firing
device:auth-error.
Now device_id and device_token are sent whenever they exist, regardless
of paired status. The pairing code is also reused across reconnects
instead of generating a new random code each time.
Closes#163
Co-authored-by: BlazzzPlay <fabianma7@gmail.com>
The v4-exit-signal-phase3 tests eval a hardcoded LINE RANGE out of tizen/js/app.js
(harness(TIZEN, 663, 697)). The #162 stage-owner fix inserted ~19 lines above that
block, so the slice no longer captured the crash/pagehide handlers and the three B/wgt
tests failed. Re-point the range to 682-716 (block content unchanged, verified identical).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The server provisions a unique settings-menu PIN per device, but nothing surfaced
it — leaving the on-device hidden settings menu effectively unopenable. Show the
PIN on the device Info tab (native players only), with i18n across 6 locales.
Also backfill a unique 6-digit PIN for already-paired devices that predate the
settings_pin column, so the existing fleet isn't locked out (delivered on their
next reconnect via the existing device:paired re-send). Idempotent UPDATE.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
- Remove stray brace that broke compilation (MainActivity line 985)
- Server generates unique 6-digit PIN per device during pairing
- PIN stored in encrypted SharedPreferences (ServerConfig.settingsPin)
- Fallback: generate random PIN locally if server doesn't send one
- Include settings_pin in device:paired on pair + reconnect
- DB migration: settings_pin column on devices table
- Hint changed from hardcoded 0000 to generic 'PIN' string
* 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>
The -patchN scheme (e.g. 1.9.2-patch3) parses as a semver prerelease, so decide()'s
superseded-prerelease guard refused to offer a newer stable core (1.9.3) to the existing fleet —
stranding every 1.9.2-patchN device on OTA (Force Update didn't help; the re-check re-returned
superseded-prerelease). isReleased() now counts -patchN as a shipped release, so those devices get
offered 1.9.3 via normal OTA, while GENUINE prereleases (-beta/-rc/-alpha) keep prerelease semantics
and newer cores are never downgraded. 6 new tests + 14 existing OTA tests green (388/388 suite).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CI flaked on the 300k-row prune non-blocking assert: a healthy chunked prune hit a 417ms max
event-loop gap on a shared runner, over the strict 250ms bar (the same test passed on the prior
commit; the release bump changed no logic). These probes exist to catch a MULTI-SECOND freeze (the
pre-fix whole-table sort froze 40-48s) — not to enforce a sub-300ms latency SLA — so a strict bar is
fragile under runner contention/GC. Widen both to <1500ms: still << "seconds" (catches any real
regression) but robust on CI. No production code changed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The dashboard CSP (script-src 'self') blocked Cloudflare's Web Analytics beacon. Add the two exact
entries the beacon needs (both required — script-only loads but silently can't report):
- script-src: https://static.cloudflareinsights.com (beacon script loads)
- connect-src: https://cloudflareinsights.com (beacon POSTs analytics back)
Exact domains, no wildcards. connect-src already had 'wss:'/'ws:' (socket.io) + 'https:' — those stay,
so the dashboard socket is unaffected; the explicit CF domain documents intent and survives any future
tightening of the broad 'https:'.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Server-side keystone: the server now honors the v4 liveness contract uniformly across the MIXED
fleet (v4 + old pre-v4 + disconnected), all three clients depending on it.
- UNIFORM heartbeat-ack: emitted from the single shared device:heartbeat handler (uniform by
construction; no per-client/per-path branch), BEFORE the auth guard so a known device's watchdog
stays armed. Harmless to old clients (they ignore it).
- RECONNECT-WINDOW ack-gap fix (ackableHeartbeat): ack a KNOWN device (authed socket OR a device_id
that resolves) even mid-reconnect; NOT anonymous/never-authenticated sockets (degrade-safe);
identity-agnostic. No state mutation before requireDeviceAuth (auth surface unchanged; device_ids
are uuidv4).
- DASHBOARD LIVENESS (deriveLiveness): server-derived, VERSION-AGNOSTIC Healthy/Degraded/Offline
from signals every client sends (socket presence, heartbeat age, reconnect frequency); no client
status-push.
- IDENTITY CAPTURE (capture-don't-act): client_type/client_version/platform/contract_version columns;
degrades to legacy/unknown for old clients; NEVER breaks register.
- A-BUCKET FIX (QA): recordReconnect + persistIdentity gated on !isPlaylistRefresh (a ~45-60s refresh
is not a reconnect/new identity — matches #134), and the identity write is change-detected — closing
the WAL write-amplification (A1) and the benign-refresh -> false-"Degraded" (A2) regressions.
New lib/liveness.js (pure helpers, unit-tested). 30 new tests (uniform ack, ack-gap, mixed fleet,
identity capture, cross-client conformance, refresh-gate reproduce-then-prove); 366/366 total.
OTA artifact-availability is a separate concern (out of scope).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
/player v4 — client-only (served web player). Brings the browser player onto the locked v4
liveness contract, IDENTICAL on the wire to the now-v4 APK and .wgt:
- v4 liveness watchdog: consume device:heartbeat-ack, lastServerMessageAt refreshes on ANY inbound
(onAny + engine ping) while ARMING gates on the ack (degrade-safe); 45s±10s jittered threshold;
backoff 1s/30s/±0.2 on the socket.io Manager; NO status/health poll; teardown-before-reopen (#148).
- Browser-specific half-open triggers (the /player-unique part): Page Visibility, sleep/resume
(pageshow/bfcache), network change (online) — all drive the SAME #148 teardown-first reconnect.
- THROTTLE-AWARE: silence computed by timestamp (not timer-fire-count); watchdog does NOT act while
the tab is HIDDEN (throttled-timer gap is expected); on becoming visible, verifyLivenessSoon()
resets the grace and reconnects ONLY if genuinely dead — never spuriously tears down a live socket.
- v4 client identity block on register (client_type=player / client_version / platform / v4).
Verified: arm-after-ack both directions, v4 values MATCH .wgt exactly, browser-trigger no-duplicate-
socket, throttle-aware reproduce-then-prove (real extracted checkLiveness/verifyLivenessSoon),
foreground-recovery one-socket. Depends on the server device:heartbeat-ack (core pass) — degrade-safe
until then.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.
Close the disk-fill trap: with wal_autocheckpoint=0 a dead worker means nothing checkpoints.
Controller now respawns an unexpectedly-dead worker (bounded: RespawnMax/RespawnWindowMs +
backoff); on exhaustion it re-arms a conservative inline autocheckpoint (FallbackPages) on the
main connection + reclaims the backlog, logging loudly. Clean stopWalCheckpointer() teardown is
distinguished via a 'stopping' flag so SIGTERM never triggers respawn. Env-gated worker
fault-injection (WAL_CKPT_FAIL_START) for tests. Local only — no bump/tag.
Disable wal_autocheckpoint on the main connection; run PASSIVE checkpoints from a
worker_threads worker with its OWN better-sqlite3 handle, escalating to TRUNCATE on a
size high-water or PASSIVE-starvation. Removes the synchronous fsync-heavy checkpoint
from the event loop. Config: walCheckpointIntervalMs/HighWaterMB/StarvationRuns.
Local only — no bump/tag.
Field-safe SERVER net. A device opening duplicate/rapid sockets (the APK duplicate-socket bug,
separate track) currently churns through evictions during the reconnect-throttle's 30s
post-restart WARM-UP (only the hard ceiling 20 applies then, so an 8-in-9s burst passes
undamped and each new socket evicts the prior). This makes the server absorb it: a thrashing
PAIRED device converges to ONE stable connection and stays online.
- lib/session-settle.js (decision only; bounded, swept): shouldHold(deviceId, incumbentAlive)
— true only when a socket was accepted for this device within SESSION_SETTLE_WINDOW_MS
(config, default 2500ms) AND the incumbent is alive. Warm-up-independent.
- deviceSocket register gate (just before evictPriorSocket): if a LIVE incumbent exists and
we're inside the window, SOFT-REFUSE the new socket (device:throttled reason=session_settle
+ disconnect) and keep the incumbent; else accept + evict + (re)arm the window.
- LIVENESS SAFEGUARD (load-bearing): only hold when the incumbent socket is actually in the
/device namespace — a dead/half-open incumbent is replaced, NEVER stranding the device (max
hold is the 2.5s window from the incumbent's accept, then any new socket is accepted).
- Soft refusal, NEVER a quarantine (reuses patch1's paired-safe philosophy); single-session
enforcement intact for a legitimate move; unpaired/abusive flapping still caught by the
existing limiters. O(1), no loop impact.
Tests (liveness first-class): live incumbent holds + DEAD incumbent replaced (not stranded);
storm of 6 sockets converges to ONE, stays online, not quarantined (during warm-up); single-
session move past the window replaces cleanly; unit decision + bounded sweep. The
evicted-socket-rearm test shrinks its settle window so it still exercises the eviction path.
Suite 336/336.
Item 2: when the heartbeat checker marks a device offline it now also disconnects any socket
it still holds for it, so DB-offline can't diverge from socket-state into a silent half-open
(defensive — the live-socket guard already defers genuinely-live sockets).
Item 3: tighten half-open detection WITHOUT reintroducing the TV-WebKit decode-load risk the
30s pong-timeout was chosen for — lower only pingInterval 30s->15s (probe more often), KEEP
pingTimeout at 30s. Detection = interval+timeout = 45s (was 60s), and the client inherits
these via the handshake so BOTH ends detect a dead peer ~25% sooner. (Deliberately did NOT
drop pingTimeout to ~20s: MAXHUB is a video-playing TV-class device and the code comment
warns tighter timeouts cause spurious drops under decode load.)
Item 4: SO_KEEPALIVE on every accepted connection (lib/tcp-keepalive.js) so a half-open TCP
can't persist indefinitely at the OS layer, independent of the app ping.
Tests: server closes a non-ponging peer within ~pingInterval+pingTimeout while a ponging peer
survives; a device whose transport dies ends offline with its connection torn down; keepalive
applied to each accepted connection (and never breaks setup on error). Suite 328/328.
The flap-limiter could 30-min quarantine a PAIRED, legitimate device on reconnect churn.
Behind Bold's single SNAT IP a repeated edge flush -> every device reconnects -> trips flap
-> quarantined -> a recoverable blip becomes a SUSTAINED FLEET-WIDE LOCKOUT we caused.
check(key, now, {paired}) now skips (and clears) the quarantine escalation for a paired
device — it still gets the brief soft cooldown if it truly hammers, but never the long
lockout. The register gate computes paired = device_id && validateDeviceToken(...) (a
matching STORED token, false for missing/mismatch) so a spoofed device_id can't claim the
exemption; unpaired/anon flapping (attacker / unprovisioned hammering) still quarantines.
Tests: unpaired flapper still quarantined; paired never quarantined (soft cooldown only);
paired creds RELEASE an in-flight quarantine; N paired devices from one SNAT IP all admitted
on reconnect and never quarantined across repeated flush cycles.