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>
The chips are labelled "in view" but were tallied from the alert query result.
Alerts are fetched per state — one request instead of one per county — so the
feed routinely carries warnings hundreds of miles away. A Kenosha-centred map
reported "2x Tornado Warning" for tornadoes in Calumet and Winnebago, neither
of them on screen and neither reachable, since the view is bounded to two
counties.
Warnings are now sorted into three states rather than two:
- Unreachable: outside the bounded frame the map can ever show. Not drawn, not
counted. This is what produced the phantom tornado count.
- Reachable but off-screen: drawn, so it can slide into view at the edge as the
frame widens, but not claimed as "in view".
- On screen: tallied into the chips, recomputed on moveend/zoomend, because
framing settles asynchronously and it is the settled zoom that decides what
"in view" means.
Bounds come off the GeoJSON coordinates directly, covering every ring of a
MultiPolygon, rather than building a throwaway layer per feature to ask Leaflet
for an extent.
Asset version bumped to 3 so players holding the cached copy pick this up.
The auto-framing fitted the view to whatever warning polygons were active, so a
storm a few counties away pulled the frame out to cover it and the configured
area shrank to an unreadable corner of a half-state view. On signage that is
read at a glance, a map that wanders is worse than one that shows less.
Framing is now centred and bounded:
- The map never pans. The centre stays on the configured point and only the
zoom responds, because the box handed to fitBounds is symmetric about home.
- Zoom-out is capped at `max_counties` (default 2) county-widths in every
direction, with longitude scaled by cos(lat) so the budget is the same
distance on the ground north and south.
- Warnings entirely outside that box are not chased at all; the configured view
is held. Warnings clearing returns to it rather than staying parked on the
last storm.
- A floor on the frame keeps one small cell overhead from zooming to street
level, and fit padding drops to 24px, which on a PiP-sized overlay was
discarding a third of the width per side.
The overlay assets are served max-age=14400, so a player that had already
loaded them kept the old copy for four hours and silently ignored a redeploy.
The page URL and its script tag now carry a version, documented to be bumped
together.
Tests cover the invariants against the shipped frameFor source rather than a
copy of it: centred after reframing, capped at the county budget, small cells
floored, distant storms not chased.
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>
The previous commit's `--exclude='.env.*'` also matched `.env.example`, silently
dropping the config template self-hosters need from the release tarball.
Exclude `.env*` wholesale, then explicitly re-add `.env.example` (tar cf / tar rf /
gzip), so a new secret file is excluded by DEFAULT and the one legitimate file is a
named exception rather than something a glob has to be written carefully enough to
spare. The credential audit now subtracts `.env.example` from its matches, and a
second check fails the upload outright if the template is absent - the regression
this commit fixes would have been caught by that check.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The complete-tarball step archives `server/` wholesale, and tar includes dotfiles,
so any gitignored local config living under it (.env, .mcp.json, key material) is
packaged unless excluded by name - gitignore has no effect on tar.
Two changes:
- Add excludes for .env / .env.* / .mcp.json and jks|keystore|pem|key|p12|pfx.
- Follow the tar with an audit that inspects what is actually IN the archive and
refuses to upload when anything credential-shaped is present.
The exclude list fails OPEN (a new file added under server/ ships unless someone
remembers to exclude it); the audit fails CLOSED, which is the property that
matters. .env.example is deliberately shipped and is not matched.
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>
The "Dashboard updated. Reload now" toast (fired when /api/version's hash changes
after a deploy) used `href="javascript:location.reload()"`. The dashboard CSP is
`script-src 'self'` with no 'unsafe-inline', which blocks `javascript:` URIs — so
the link was dead: clicking it did nothing but log a CSP violation. Users had to
hard-refresh manually.
Build the link and attach a real click listener (first-party script, CSP-clean)
instead of the inline javascript: href. No behaviour change beyond the link now
working; text unchanged.
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>
The android-test job ran ./gradlew with no Gradle caching, so every run
re-downloaded the full Gradle distribution (gradle-8.5-bin.zip, ~130MB) from
services.gradle.org. A transient "Connection reset" mid-download failed the job
before any test ran (stack bottomed out in org.gradle.wrapper.Install.forceFetch)
— e.g. run 30063222009 on the npm-audit-fix PR, which passed on a plain re-run.
Add `cache: gradle` to the existing setup-java step. It caches ~/.gradle/caches
and ~/.gradle/wrapper; the wrapper cache holds the distribution zip, so cache-hit
runs skip the download entirely — removing the network dependency that flaked.
Also speeds the job up (build cache reuse).
Residual: a cold cache (first run per key, or a gradle-wrapper.properties change)
still downloads once. If that proves flaky too, a download retry can be layered on,
but caching turns "download every run" into "download rarely".
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>
The setup screen buttons used a bare <ripple> which only reacts to
state_pressed (touch). D-pad navigation triggers state_focused, which the
ripple ignored, leaving the focused button nearly invisible against the
dark background from TV viewing distance.
Wrap the button shape in a <selector> (kept inside the ripple so touch
still gets the press ripple) that adds a bright 3dp white stroke on
state_focused.
Closes#209
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>
Android and Tizen never emitted device:play-event, so Reports showed Total
Plays / Hours / proof-of-play as all zero for those devices (only the web
player logged plays). Both now emit play_start on show and play_end on
advance/teardown, mirroring the web player's contract (leader-gated for walls,
widget-id fallback so durations close). Server side unchanged — play_logs and
the reports queries were already waiting for the events.
Tizen parity with the web player (from the parity audit):
- audio: landscape <video> honors item.muted (warm-muted for autoplay, then
applies the real state) instead of force-muting; wall followers stay silent
- device:mute-changed: real-time per-item mute of the on-screen video
- device:remote-key: D-pad/volume/mute/home, BACK=info overlay, POWER=screen-off
- device:remote-touch: normalized-coordinate touch injection
- buffered widget swap: reveal the new iframe on load then clear (no black flash)
- diagnostic info overlay toggled by the dashboard BACK key
Still muted on Tizen: the portrait AVPlay video path and transition-composited
video (would need webapis.avplay volume APIs / renderVideoBuffered work).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>