Commit graph

793 commits

Author SHA1 Message Date
ScreenTinker b1092d0d62 Merge branch 'fix/login-lockout' into release/auth-campaign 2026-07-26 16:21:59 -05:00
ScreenTinker 8a651ebfcb Merge branch 'fix/widget-telemetry-bounded' into release/auth-campaign 2026-07-26 16:21:59 -05:00
ScreenTinker c588f40243 Merge branch 'fix/client-ip-attribution' into release/auth-campaign 2026-07-26 16:21:22 -05:00
ScreenTinker f289609380 fix(auth): back break-glass recovery with a revocable, auditable grant
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>
2026-07-26 14:49:03 -05:00
ScreenTinker dce0bc6f54 fix(devices): generate access-gating six-digit codes with a CSPRNG
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>
2026-07-26 14:24:03 -05:00
ScreenTinker dda6f5b41e fix(devices): authorize the screenshot route on the device's workspace
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>
2026-07-26 14:17:54 -05:00
ScreenTinker 9130aa5f7d feat(auth): bound password login per account, not only per IP
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>
2026-07-26 14:07:16 -05:00
ScreenTinker 8a28761b12 fix(widgets): bound the unauthenticated telemetry store, and stop it writing rows
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>
2026-07-26 10:36:09 -05:00
ScreenTinker 4b13dadb4d fix(logging): gate CF-Connecting-IP on a Cloudflare peer, not any trusted proxy
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>
2026-07-26 10:23:04 -05:00
ScreenTinker 6b082cfad0 fix(uploads): derive stored type from file content, and never serve uploads as documents
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>
2026-07-25 11:47:09 -05:00
ScreenTinker 8a55798eaf fix(release): keep .env.example in the tarball, and assert it is there
Some checks failed
CI / Unit tests (node --test) (push) Has been cancelled
CI / OpenAPI spec lint (push) Has been cancelled
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Has been cancelled
CI / Boot smoke + version check (push) Has been cancelled
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>
2026-07-24 21:41:58 -05:00
ScreenTinker 24067c39c4 chore(release): exclude local config from the release tarball, and gate on it
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>
2026-07-24 21:27:30 -05:00
ScreenTinker 593458d519 chore(release): v1.9.15 2026-07-24 21:12:22 -05:00
ScreenTinker 6dd78e078a refactor(auth): drop the unused optionalAuth middleware
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>
2026-07-24 21:03:25 -05:00
ScreenTinker c4b5a8679e refactor(auth): centralise session token resolution across manual verify sites
Six places verified a session JWT inline instead of going through requireAuth,
each repeating a slightly different subset of its checks. Introduce
resolveSessionUser() in middleware/auth.js as the single definition of "this
token is a usable session, and here is whose it is", and route all of them
through it: the three /api/status token routes, the screenshot route, the
content-reference gate, and the /dashboard socket handshake. requireAuth is now
a thin wrapper over the same helper, so the two cannot drift.

Also:
- Give the pre-TOTP token a distinct audience so it is redeemable only through
  verifyMfaPendingToken (POST /api/auth/totp/verify). verifyToken refuses any
  token carrying an audience, so a token minted for one purpose cannot be
  redeemed on another path.
- The dashboard socket handshake now takes userId/userRole from the live users
  row rather than from the token claim, so role changes take effect on the next
  connection instead of riding the token's remaining lifetime.
- Add test/session-token-resolution.test.js covering all six surfaces,
  including the socket handshake.

Every call site keeps the status code and error body it returned before.
Net query cost: the content-reference gate and the socket handshake each gain
one users-by-id lookup (the same one requireAuth already does per request); the
other four are unchanged or replace an equivalent lookup.

In-flight pre-TOTP tokens are invalidated by the audience change; they live 5
minutes, so the window is a re-login at worst.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-24 20:58:05 -05:00
ScreenTinker e3c7282bb7 chore: ignore local tooling and scratch output 2026-07-24 20:57:22 -05:00
screentinker 2b137bc40b
fix(widgets): honest webpage-widget note — blocked sites don't work on device (#230)
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
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>
2026-07-24 19:17:00 -05:00
screentinker d82b65059c
fix(dashboard): make the "Reload now" update toast actually clickable (#229)
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>
2026-07-24 16:41:18 -05:00
ScreenTinker df7ecd6881 chore(release): v1.9.14 2026-07-24 15:54:27 -05:00
screentinker c3a5261057
fix(subscription): make the trial-expiry auto-downgrade actually fire (#228)
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>
2026-07-24 15:51:46 -05:00
screentinker e91d87fbfd
feat(stripe): enable promotion codes on checkout sessions (#227)
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
Add 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>
2026-07-23 23:44:18 -05:00
screentinker eb794cefbd
ci(android): cache Gradle to stop wrapper-download flakes (#226)
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>
2026-07-23 22:24:21 -05:00
ScreenTinker 7b4e5bf416 chore(release): v1.9.14-beta1 2026-07-23 22:10:05 -05:00
screentinker 5a352423ac
chore(server): npm audit fix — resolve 11 of 13 advisories (lockfile only) (#225)
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>
2026-07-23 22:09:45 -05:00
ScreenTinker 98473d57f6 chore(release): v1.9.13
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
2026-07-23 12:52:01 -05:00
ScreenTinker ea99ae5e8d chore(release): v1.9.13-beta1 2026-07-23 12:39:30 -05:00
screentinker 8529be5a30
feat(content): subtitle/caption support as a content property (#223)
Subtitles/captions are set once in the content library and applied
automatically by the player — no in-player controls (the player stays bare).

- DB: 4 new content columns (captions_enabled, captions_lang for YouTube;
  subtitle_url, subtitle_lang for uploaded videos), all default off/NULL.
- buildSnapshotItems: denormalize the 4 fields into published_snapshot so the
  player receives them (enumerated query).
- content.js PUT: accept the 4 fields (subtitle_url only clearable here).
- POST /:id/subtitle: dedicated .vtt uploader (separate multer, since the main
  filter is video/image-only); stores the file in the content dir, records
  subtitle_url + subtitle_lang. Old subtitle file replaced; DELETE cleans up
  the sidecar.
- Player: YouTube -> loadModule('captions') + setOption(...languageCode) in
  onReady (best-effort, wrapped). Uploaded video -> a <track kind="subtitles">
  appended to the <video>, forced mode='showing' on load (same-origin, so
  CORS-clean like the video).
- Edit modal: YouTube gets an enable-captions checkbox + language; uploaded
  video gets a .vtt file picker + language + a remove-subtitle option. en/es.

Honest limitation (in the PR): YouTube caption control via the IFrame API is
undocumented/version-dependent and only works if the video actually has
captions — hence best-effort and wrapped so it can never break playback.

Test: content-subtitles.test.js — the 4 fields survive publish -> snapshot,
the .vtt upload endpoint stores + records the file, and a non-.vtt is rejected.
Suite 550/550.

Closes #216

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 12:33:35 -05:00
screentinker 8b661a7347
feat(content): batch operations — multi-select, batch delete, batch move (#224)
The content library had no batch operations — every item was managed one at a
time. Add multi-select with batch delete and batch move.

Backend (content.js):
- POST /content/batch/delete — array of ids, atomic: validates + authorizes
  EVERY id first (malformed/missing/forbidden rejects the whole batch), then
  deletes in one transaction. Reuses the single-delete teardown.
- POST /content/batch/move — array of ids + target folder_id, same atomic
  validate-all-first; target folder must share each item's workspace. Folder is
  organizational (not in the snapshot), so no device push.
- Refactor: extract purgeContentRow() (file removal + snapshot scrub + row
  delete + affected-device collection) and pushContentUpdates(); DELETE /:id now
  uses them, so single + batch share one scrub path (no duplication). Add a
  boolean contentWritable() mirroring checkContentWrite's authorization.
- 500-item cap per batch; UUID validation guards the snapshot-scrub LIKE.

Frontend (content-library):
- Per-card selection checkbox, select-all/none (visible), shift-click range.
- Selection persists across folders/pages (issue-aligned cross-page selection);
  cleared after a successful batch op.
- Batch toolbar (shown when >0 selected): count, move-to-folder picker, delete
  with click-again confirm. Selected cards get an outline.
- api.batchDeleteContent / batchMoveContent; en/es i18n.

Not included: batch "set expiry" (listed in the issue's toolbar sketch but only
delete/move had endpoint specs) — deferred; PUT already does per-item expiry.

Test: content-batch-ops.test.js — batch delete removes rows+files+scrubs
snapshots; atomic rejection leaves valid rows intact; malformed id -> 400;
batch move reassigns folder; cross-workspace folder refused; empty batch -> 400.
Suite 553/553.

Closes #213

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 12:29:44 -05:00
screentinker 5c6d508032
feat(content): multi-file upload (#222)
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
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>
2026-07-23 11:38:37 -05:00
screentinker 792b105035
feat(content): server-side search, type filter, and sort (#221)
Content discovery was client-side only, scoped to the items already rendered
on the current page — searching "logo" on page 1 couldn't find logos on page
2 or in another folder.

Server (GET /api/content):
- ?q= text search on filename (LIKE, workspace-wide — a search ignores the
  open folder so nothing is missed). LIKE metacharacters are escaped so a
  filename with % or _ matches literally.
- ?type=video|image|youtube|web — youtube (video/youtube) and web (other
  remote_url) are split from plain uploaded video/image so the four UI buckets
  map cleanly.
- ?sort=date_desc|date_asc|name|size — whitelisted (never interpolates user
  input into ORDER BY); default keeps the legacy newest-first ordering.

Frontend (content-library):
- Type filter + sort dropdowns; search debounced (300ms) and now hits the
  server instead of filtering the DOM.
- Result count shown while a search/type filter is active.
- en/es i18n.

api.getContent gains an opts arg ({q,type,sort}); folder_id is omitted while
searching to match the server's workspace-wide behaviour.

Test: content-search-filter-sort.test.js mounts the real router and covers
substring match, LIKE-escape (literal %), the type buckets, name/size sort,
the ORDER BY injection guard, and combined filters. Suite 541/541.

Closes #214

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 11:38:22 -05:00
screentinker ad03a5ec0a
feat(content): unstable-connection mode — cap YouTube at 720p for weak WiFi (#220)
On Android TV with weak/unstable WiFi, YouTube embeds auto-select 1080p+
and buffer/stall. Add a per-item "Unstable connection" flag that biases the
YouTube embed toward a 720p ceiling.

- DB: content.unstable_connection INTEGER NOT NULL DEFAULT 0 (non-destructive,
  existing rows unchanged).
- buildSnapshotItems: denormalize the flag into published_snapshot so it
  reaches the player (that query enumerates columns, so it had to be added
  explicitly — covered by a new test). preview-payload reuses the same query.
- content.js PUT: accept + coerce unstable_connection to 0/1.
- Player: playerVars.vq='hd720' + best-effort setPlaybackQuality('hd720') in
  onReady when the flag is set. Both are hints YouTube may still override, but
  together they bias the initial selection down to 720p.
- Edit modal: YouTube-only checkbox + hint; en/es i18n.

Scoped to the core ask; the issue's optional "Shorts get inverse hd1080"
refinement is intentionally left out (dubious for signage, and forcing higher
quality on a weak link is the opposite of the goal).

Closes #217

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 11:38:18 -05:00
screentinker 9d6c3c79b0
fix(web-player): YouTube ENDED safety net for Shorts + flaky Android TV (#219)
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>
2026-07-23 11:34:25 -05:00
screentinker 11c2890433
fix(android): visible D-pad focus stroke on setup buttons (#218)
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>
2026-07-23 11:34:21 -05:00
Fabian Mendoza e7483dfc24
feat(ui): show server URL in Add Display modal + GitHub Releases link on /download/apk (#210)
- 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
2026-07-23 10:25:00 -05:00
Fabian Mendoza 9e0048eec2
fix(content): respect current folder when uploading files (#211)
Previously the dashboard upload always sent files to root (folder_id=NULL)
because the upload flow never read or forwarded the current folder context.
The agency upload already handled this correctly — this applies the same pattern.

Changes:
- api.js: uploadContent() accepts optional folderId, appends to FormData
- content-library.js: handleFiles() passes state.currentFolderId
- content.js: POST / reads folder_id from multipart body
2026-07-23 10:24:57 -05:00
ScreenTinker 79ab849641 chore(release): v1.9.12
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
2026-07-22 21:09:09 -05:00
ScreenTinker b938fce368 feat(auth,tizen): TOTP 2FA UI, email verification on signup, Tizen SSSP install
Three features from this session, full server suite green (535/535).

TOTP 2FA (#100) — backend shipped without a UI; add it:
- Login: mfa_required -> 6-digit challenge (recovery codes accepted) -> /totp/verify.
- Settings > Account: enable (QR + confirm -> recovery codes once), regenerate,
  disable; SSO accounts see "managed by your identity provider".
- /totp/setup returns a server-rendered qr_data_url (bundled qrcode dep). keyuri
  folds the request Host into the issuer so multi-instance accounts are
  distinguishable in the authenticator app.

Email verification on signup — hosted HARD-block / self-host SOFT-nudge:
- email_verified column; existing users asked on first login (SSO + platform
  admins grandfathered); single-use 24h tokens (SHA-256 hashed).
- Gate engages only when email is configured (never locks out a no-mail instance).
  GET /verify-email + POST /resend-verification (generic, no account enumeration).
- Client: "confirm your email" flow + resend, verified/error toasts, self-host
  banner; onAuthSuccess refuses a tokenless response (defensive).

Tizen SSSP URL-Launcher install — Fusion-style one-URL native install:
- Server hosts /tizen/sssp_config.xml (dynamic <size>, always matches the served
  .wgt) + /tizen/ScreenTinker.wgt + a human landing. lib/wgt-cache.js resolves the
  signed .wgt (/data mount wins, mirroring the APK).
- build-wgt.sh also emits a static sssp_config.xml for CDN hosting.
- Retail panels require a Samsung Partner cert; dev-mode is SDB self-signed only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 21:08:50 -05:00
ScreenTinker 363f8de809 fix(web-player): hoist renderSeq to top-level state — fixes cold-start TDZ crash
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>
2026-07-22 14:34:35 -05:00
ScreenTinker 07419fee1f feat(players): proof-of-play on Android+Tizen; close Tizen parity gaps
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
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>
2026-07-22 09:22:18 -05:00
ScreenTinker 2d4af97f67 docs: fix double-escaped < in API reference, add README hero image
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
- openapi.yaml: use the numeric entity &#60; so Redoc renders
  'read < write < full' in the scope-ladder nav + section header,
  instead of the double-escaped 'read &lt; write &lt; full'
- README: add a centered dashboard hero image + quick-links row
  (Live demo / API reference / Self-hosting guide / Discord) at the top,
  and refresh the Support section

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 22:51:55 -05:00
screentinker 8c0bf77428
fix(web-player): reconcile advanceTimer on group/wall mode transitions (#200) (#208)
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
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.
2026-07-21 09:00:42 -05:00
screentinker 3efae1d2d6
feat(designer): edit designer-made widgets in the designer (+ legacy reconstruction) (#207)
Designs round-trip for visual editing: store the design source in the widget config, reroute Edit to the designer, reconstruct legacy HTML-only designs, PUT the original in place.
2026-07-21 09:00:36 -05:00
screentinker 397c4e1aec
fix(designer): let weather elements switch units to metric (#206)
Some checks failed
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
Shaders / Compile transition shaders (real WebGL) (push) Has been cancelled
Adds the missing Imperial/Metric units selector to the designer's weather element properties.
2026-07-20 18:22:42 -05:00
ScreenTinker 6b5b401f7c chore(release): v1.9.11 2026-07-20 16:59:56 -05:00
screentinker ba00dd2811
fix(transition-engine): Android supersede wedge/leak + web/Tizen stale-video guard (#205)
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.
2026-07-20 16:58:28 -05:00
screentinker 96b71a0d56
feat: transition engine — GL wipes across web, Tizen & Android (+ image↔video) (#204)
Client-side GL Transitions v1 across all three players with never-blank degradation: shader lib + generated manifest + real-WebGL CI, transition-as-widget normalization, persistent WebGL/GLES2 compositors (web/Tizen/native Android), image↔video wipes, SSRF-hardened media proxy, decode-gated image preload, dashboard picker. Fixes the playlist change-fingerprint that dropped transition edits. Alpha + on-device soak validated.
2026-07-20 16:45:32 -05:00
ScreenTinker af89eaa75b chore(release): v1.9.10
Some checks failed
CI / Unit tests (node --test) (push) Has been cancelled
CI / OpenAPI spec lint (push) Has been cancelled
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Has been cancelled
CI / Boot smoke + version check (push) Has been cancelled
2026-07-17 20:24:39 -05:00
screentinker 335681b907
feat(directory-board): panel-ring scroll + in-place refresh + per-device frame diagnostic (#203)
Compositor panel-ring board scroll (smooth on Blink+Gecko, no blank-on-refresh), a per-device frame-rate diagnostic widget + dashboard card, and web/Android/Tizen device-id passthrough to widget render URLs.
2026-07-17 20:17:21 -05:00
screentinker bb6c7597da
fix(web-player): buffered widget swap + schedule-aware solo-board hold (directory-board black flicker) (#202)
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
* 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>
2026-07-17 13:42:12 -05:00
screentinker 5c1cb4b992
fix(server): floor duration_sec to prevent widget zero-duration player loop (#199)
A duration_sec=0 assignment (especially a widget) made the player schedule a 0ms
auto-advance, self-looping and black-screening the TV. #198 fixed the Android
client; this hardens the source so a 0 can't be stored or served in the first
place. assignments.js accepted an explicit 0 on the POST/PUT/copy write paths —
the `= 10` destructure default only covers an ABSENT field, not an explicit 0.

- Add normalizeDuration() and apply it on all assignment write paths so any
  missing/invalid/<1 duration is floored to the 10s default.
- Add an idempotent migration repairing existing playlist_items rows with
  duration_sec IS NULL OR < 1 (fixes the live widget on existing DBs).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 13:41:52 -05:00