Commit graph

144 commits

Author SHA1 Message Date
ScreenTinker 6268c1a4c0 Let a screen-only panel clear its identity from the URL
A display panel has no keyboard, no pointer and usually no way to clear site
data, but the URL it loads is configurable from whatever manages it. Loading
the player with ?reset=<token> now discards this install's identity so the
panel returns as a new device with a fresh pairing code — the recovery path
when a panel is holding an identity that belongs to a different screen, and the
ordinary path when redeploying a panel to another site.

It applies once per token, which is the whole design. A configured URL is
permanent; nobody goes back and removes the parameter. A reset that fired on
every load would drop the pairing on every reboot and present as a screen that
cannot hold its pairing at all — which reads as an intermittent server fault
rather than the URL doing exactly what it was told. The applied token is
remembered, so ?reset=1 left in place forever resets exactly once; any other
value resets again.

The server URL is deliberately kept, since clearing it would strand a panel
that cannot be typed into, and the cached playlist and layout are dropped so
the new device does not come up showing the previous screen's content.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-28 14:58:21 -05:00
ScreenTinker 2bcc46bc26 Give each player install its own identity
The web player derived its fingerprint entirely from hardware traits: user
agent, screen geometry, colour depth, timezone, core count, platform and a
canvas raster. Every one of those describes a model rather than a unit, so two
identical panels produced the same value and the server, which matches on that
value globally, treated them as one device. Two UniFi Pro Displays at different
sites both produced web-m73u8w-5f; the second could not be brought online, and
the row ended up shared, each display evicting the other every thirty seconds.

The identity a player presents is now hardware plus a random per-install salt
kept in localStorage, so two identical panels differ from their first
connection. This is what the Tizen player has always done; the web player is
brought in line with it rather than given a new scheme.

The hardware value is still sent, but only as a hint, and only to move a caller
that has ALREADY authenticated with a device id and token onto its own row —
which is how an existing player carries its identity across this change. A
caller without credentials never resolves through it, however few rows it
appears to match: one row recorded does not mean one display exists, and that
distinction is the whole bug. Such a caller is provisioned a new device, which
costs one pairing code and cannot be wrong.

Older clients are unaffected. They send no hardware value, so they take the
exact-match path exactly as before, and both keep working: the APK's
fingerprint already includes ANDROID_ID and the Tizen player's is already a
stored random id, so neither ever shared an identity between units.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-28 14:54:00 -05:00
ScreenTinker f09dee810c Record where a player crashed, not just what it said
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
Three players died with "Cannot set properties of null (setting 'textContent')"
and it could not be traced. The message names no file, and every candidate line
in the current player was ruled out by inspection: the unguarded writes all
build their element with createElement, every getElementById target exists in
the markup, and the script runs after the markup. That points at an older
cached build still served by the service worker, which is exactly the case
where reading current source proves nothing.

The ErrorEvent already carried filename, lineno and colno. They were being
discarded. Keeping them makes the next occurrence name its own line.

Composed to fit the 200 characters the server stores, so the location is not
truncated away: message plus one location, basename only since the origin is
already known from the device. A promise rejection has no filename, so it falls
back to the first stack frame. A cross-origin script, which reports a bare
"Script error." with nothing else, says so rather than emitting :0:0 as if that
were an answer.

A resource load failure still is not a crash; a test guards that, since this
touched the handler that decides it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-28 14:17:43 -05:00
ScreenTinker b34d73dbb9 Report zero event-loop lag when a window recorded no samples
A sampling window that recorded nothing leaves the histogram empty, and an
empty IntervalHistogram reports its mean as NaN. Its percentiles return a floor
instead, which is why only the mean was affected and why this went unnoticed.

NaN then survives every arithmetic step in the sampler without complaint and
becomes visible only at the edge, where JSON.stringify renders it as null. So
/api/status served "mean_ms": null while nothing raised an error anywhere, and
any consumer of that gauge read null instead of a number.

Non-finite readings now report 0, which is the honest value: no samples means
no measured delay. Applied to every field so a later change to the histogram
source cannot reintroduce this one field at a time.

Found by CI rather than locally, because an idle window is far likelier on a
loaded runner with several test servers in flight. The failure was real; the
new tests establish the NaN premise and the null serialisation directly rather
than relying on that timing to reproduce.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-28 14:11:32 -05:00
ScreenTinker 792013e36c Record auth rate-limit rejections so they can be measured
The auth limiters are app.use middleware that return 429 before the handler
that writes activity_log, so a rejection left no trace anywhere — the limit
suppressed the record of itself. Four production IPs sit at exactly ten logins
a minute and there was no way to tell whether that is one attacker or an office
whose staff share an egress address, which is the difference between the
limiter working and the limiter locking out customers.

The rejection count does not answer that. The number of distinct accounts per
IP does: one account hammered is the limiter doing its job, several accounts
each denied a few times is a shared egress. Both are now recorded, and a
platform-admin-only endpoint reads the tally back.

Identifiers are salted-hashed with a per-process salt and only ever counted, so
this cannot accumulate into a roster of a customer's addresses. Memory is
bounded per key and overall, and says when a count was capped rather than
silently undercounting.

Behaviour is unchanged: same status, same body, and the recording is wrapped so
telemetry can never break the limiter. A test asserts ten through then 429 with
the identical response shape, since a diagnostic that alters what it measures
is worse than none.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-28 14:01:00 -05:00
ScreenTinker a93f65b20a Only store a device fingerprint against a device that still exists
A player that reconnects after its row was deleted sends the id it still has
cached. device_fingerprints.device_id has a foreign key to devices(id), so
writing that id back fails the constraint. The throw was caught, which is why
this looked harmless, but the catch abandons the whole fingerprint block:
last_seen is not updated, the reinstall link is not made, and the settings
restore never runs. That restore exists specifically for the post-delete
re-pair, so the failure landed exactly where the feature was meant to help and
a re-paired panel came back with its orientation, name and playlist reset.

Production shows 37 of these, timestamped identically to the "sending unpaired"
log lines — the same event seen from the other side.

The incoming id is preferred, then whatever is already stored, and only an id
that still resolves is written; otherwise NULL, which the column allows and
which ON DELETE SET NULL already leaves behind. The INSERT path a few lines
below had this guard; the UPDATE was missed, and it is the one that fires.

Tests cover the deleted-id reconnect, that last_seen still advances, and that
live ids are unaffected. One asserts the raw unguarded statement really does
raise FOREIGN KEY constraint failed, and another asserts the guard is present
in the handler itself, since the others exercise a mirror of that statement.

Also ignores *.sqlite / *.sqlite3, which the existing *.db rules missed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-28 13:54:40 -05:00
ScreenTinker 8f2195a6e3 Recover an unpaired player without needing a keyboard
A display panel usually has no keyboard and no pointer, so a recovery path that
waits for input is not a recovery path. When the server stopped recognising a
device, the player revealed the server-URL form — typing that cannot happen on a
screen-only panel — and hid the pairing section, which was the one thing that
would have rescued it. The screen then sat on "Device was removed from server"
until someone physically reloaded it, even though the player was still connected
to the right server and could have asked for a new code itself.

Both handlers now drop the stale credentials and reconnect on a short countdown.
Reconnecting re-registers with no device_id, so the server issues a fresh pairing
code and the existing registered handler puts it on screen. config.serverUrl is
known-good by construction — we are talking to that server at the moment we are
rejected — so there is nothing for a human to re-enter.

The URL field stays editable throughout, and typing cancels the countdown, so
someone who does have a remote and wants to repoint the player is not yanked
mid-edit. The countdown is the same helper the first-boot path already used,
lifted out and shared rather than duplicated; its input listener is bound once
at setup instead of per countdown, which would have stacked a listener each time.

The Android player already behaved this way (ProvisioningActivity repair mode);
this brings the web player in line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-28 13:17:22 -05:00
ScreenTinker e9cf7801c3 Alert once per outage instead of once per dedup window
A display going offline is one event, but the alert loop re-evaluated every
still-offline device on each 60s tick, so the 2-hour dedup window re-qualified
the same outage over and over. One closed browser tab produced six "your
display is offline" mails overnight, and would have kept going to the 24h cap.

Repeat suppression now keys on devices.offline_alert_heartbeat: the heartbeat
value an alert was already sent for. A device can only come back by sending a
heartbeat, so a later outage always carries a later value and the marker
invalidates itself on recovery — no cleanup, no state to reset. Keeping it on
the row also fixes a second source of duplicates: the in-memory window used to
empty on restart and re-alert the whole offline fleet.

The window stays, doing the job it is actually suited to — bounding how often a
flapping device can alert — and is checked before the marker is written, so a
rate-limited alert is deferred rather than marked and dropped.

The backfill runs once, via schema_migrations rather than the migrations array:
statements there re-run every boot, and an IS NULL backfill would then swallow
the first alert of any outage beginning after the last restart. It marks
currently-offline devices as already-alerted so upgrading does not itself mail
about outages the owner has already been told about six times.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-28 12:50:14 -05:00
ScreenTinker 9bcdaacd2c Show every screen's schedule on one calendar
The week view could only answer "what plays on THIS screen". With one screen
at a time an empty grid is ambiguous — nothing scheduled, or the schedule
points at a different screen? That ambiguity is what a user actually hit.

Adds an "All screens" scope alongside the per-screen one. Every block now
names its target, with a stable per-target colour and a legend, so a full
grid stays readable.

The scope for all=1 comes from the request's resolved tenancy and is filtered
on nothing else, so the tenant boundary rests entirely on that resolution.
Tests pin both halves: an ordinary tenant gains nothing by naming another
workspace in the query string, and the platform-admin act-as path still
resolves the workspace it asks for — the two are easy to mistake for each
other, so they are asserted separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-28 12:16:19 -05:00
ScreenTinker 0030acc526 Store a schedule in the timezone its screen runs in
Creation and playback disagreed about which clock a schedule's hours are on.
The player evaluated blocks in the device's zone — an operator override, else
whatever the player's OS reported. Creation defaulted to a bare 'UTC', because
the dialog never asked for a zone and the server filled the silence with one.

So hours typed as "09:00 to 17:00" were stored as UTC and evaluated somewhere
else. For anyone outside UTC the schedule was correct and appeared to do
nothing, opening hours later than intended, with nothing on screen to explain
why. A user in Asia/Tokyo hit exactly this and reported it as "I added
something and it didn't appear".

Both sides now resolve through lib/device-timezone, so they cannot drift: an
explicit device override wins, then the OS-reported zone, then null. A legacy
'UTC' override counts as unset, since that was the old default rather than a
deliberate choice and a genuine UTC deployment is indistinguishable from an
unconfigured one.

A new schedule inherits its target's zone — the device's, or for a group its
leader's, falling back to the oldest member that reports one. A zone named
explicitly by the caller still wins; this only fills the silence. A target that
has never reported one still lands on UTC, which is the previous behaviour made
explicit rather than assumed.

The dialog now states which clock the hours are on, and says so differently when
that clock is not the operator's own. Stating it is the other half of the fix:
the server can pick the right zone, but the user still has to be able to see it.

Tests pin both directions and, most importantly, that creation and playback
resolve identically from the same device row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 09:43:46 -05:00
ScreenTinker f4595f017a Validate kiosk style values as CSS rather than as HTML
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 kiosk page interpolates style.fontFamily and style.background into a <style>
block, escaped with escapeHtml. That is the wrong tool twice over: it escapes
& < > " ' but not { } ;, and inside a raw-text <style> element the entities it does
produce are never decoded, so it neither contains the value nor renders it correctly.

A value could therefore close the declaration, close the rule, and append its own —
putting an attacker-chosen rule on every panel showing the page. There is no XSS,
since </style> stays unreachable, but a url() in an injected rule is an outbound
request from every display, which is a beacon and a cross-site tracking channel.

Both values are now checked structurally rather than against a value allowlist,
because background is a free-text field: linear-gradient(), rgb() and url() are all
legitimate and keep working. Only characters that could terminate the declaration or
open a new rule are refused, along with comment syntax (which can swallow the
declarations that follow) and control characters. font-family needs no parentheses,
so it gets a tighter allowlist.

Tests cover both directions — injection refused and falling back to the default, and
ordinary gradients, colours and font stacks passing through untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 22:28:47 -05:00
ScreenTinker 866e35a2b2 Clear a device's OTA rate state when it proves its identity
The update check is deliberately unauthenticated — every client version has to be
able to ask, including old ones that never learned to send a token — and it keys the
rate breaker on the caller-supplied device_id. Keying on IP is not available either:
the fleet SNATs behind one address, so per-IP would collapse a whole site into a
single bucket.

The result was that the bucket belonged to whoever cited the id rather than to the
device that owns it. A handful of requests naming a panel's UUID left that panel in
rate-backoff, un-updatable for up to half an hour at a time and renewable
indefinitely, while every other device stayed healthy.

Rather than adding auth (which would strand old clients) the state is now
self-healing: when a device registers on the /device socket with a valid device_token
its bucket is cleared. Noise is still possible, but it now lasts until the panel's
next genuine reconnect instead of as long as someone keeps poking.

This is not an escape hatch from the breaker's real job. A device stuck in an update
loop is re-registering legitimately, and clearing its rate state on each genuine
reconnect is what a healthy device looks like; the loop protection that matters is
the download guard. The version-keyed bucket that covers old clients sending only
?version= is a separate namespace and is deliberately not reachable this way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 22:28:47 -05:00
ScreenTinker b255f2bfe1 Resolve proof-of-play references instead of trusting the reported id
Players replay a cached playlist, so the id reported on play_start can outlive the
row it names. play_logs.content_id carries a foreign key to content(id), and the id
went straight into the INSERT — so deleting a piece of content made every subsequent
play of it throw, and the whole event was discarded by a catch that logged no
identifiers. On production this fired roughly 360 times in six hours and wrote zero
rows in 24h: Reports was recording nothing at all, for everyone.

Widgets had a quieter version of the same bug. play_logs.widget_id exists and was
never written, so a widget play could not be attributed even when it did insert, and
play_end matched on content_id alone and so could never close a widget's open row.

The reported id is now looked up before use and written to whichever column it
belongs to. An id matching neither degrades to null references rather than losing the
event — content_name still records what played. A play event for a device that does
not exist is still refused; that foreign key is a real invariant, not an obstacle.

play_end matches on either column, and breaks ties on id: started_at has second
granularity, so two plays inside one second tie on it and the wrong row could be
closed. The new tests caught exactly that as flakiness before it was pinned.

The catch now logs the event, device, content and zone. Without them this was
undiagnosable in production.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 21:15:44 -05:00
ScreenTinker 76a8a16130 Update sharp to 0.35.x, and repair the corrupt PNG fixture it exposed
sharp decodes uploaded files directly (lib/content-ingest.js, routes/content.js
both call sharp(file.path) on whatever a user uploaded), so its bundled libvips is
part of the request path rather than a build-time detail. Moves 0.33.5 -> 0.35.3,
libvips 8.15 -> 8.18.

Validated against the calls this codebase actually makes, because it is a major
bump: metadata() still reports EXIF orientation (1/3/6/8 all round-trip, which is
what lib/media-orientation.js exifSwapsWH and the rotation-aware dimensions depend
on), a bare .rotate() still auto-orients, and resize().jpeg().toFile() is unchanged.
png/webp/jpeg/gif/avif all still encode and decode, and malformed input still throws
rather than crashing.

The new libpng is stricter, which surfaced a latent problem in the AUTH-01 test: its
1x1 PNG literal had a corrupt IDAT chunk whose stored CRC did not match its data. The
old decoder accepted it; the new one refuses with "vipspng: libpng read error", so no
thumbnail was written and the content-gate assertions failed with a 404 that reads
like an auth regression. Replaced with a PNG whose every chunk CRC verifies. The
stricter decode is the correct behaviour and is kept.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 21:15:44 -05:00
ScreenTinker cbc00515e2 Scope device serialization to what each endpoint actually needs
A device row carries two fields that are not ordinary data: device_token, the
credential the player proves with on the /device socket, and settings_pin, which
unlocks the player's on-device settings menu and so hands physical control of the
panel to anyone holding it.

device_token was already stripped everywhere. settings_pin was not — it went out on
both the collection and the detail endpoint. The dashboard does show it, but on one
screen only: the device detail page, which fetches a single device. The collection
endpoint had no consumer for it and was returning the PIN for every device in the
workspace on every load.

The detail endpoint keeps it, so that page is unchanged. The list no longer sends it.
Same data, much smaller blast radius, no feature lost.

Tests pin the split in both directions — absent from the list, present on the detail,
and the socket credential absent from both (asserted on the whole serialized payload,
not just the top-level key, so a nested echo would fail too).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 20:40:40 -05:00
ScreenTinker 59c536c923 Keep a solo widget mounted, and size its keyboard to the viewport
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>
2026-07-27 20:39:59 -05:00
ScreenTinker d4cf1d4123 Merge branch 'feat/self-service-password-reset' 2026-07-27 11:21:36 -05:00
ScreenTinker b7d55595af feat(auth): self-service password reset
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>
2026-07-27 11:19:39 -05:00
ScreenTinker 090b6c12cb fix(pairing): expire a pairing code on device liveness, not row age
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>
2026-07-27 10:59:38 -05:00
ScreenTinker 8e3dd3ae14 Merge branch 'fix/recovery-grants' into release/auth-campaign 2026-07-26 16:22:59 -05:00
ScreenTinker d23a5205d4 Merge branch 'fix/pin-generation-csprng' into release/auth-campaign
# Conflicts:
#	server/server.js
2026-07-26 16:22:59 -05:00
ScreenTinker 0e9a842eb3 Merge branch 'fix/screenshot-workspace-authz' into release/auth-campaign 2026-07-26 16:21:59 -05:00
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 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 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 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 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 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 178af029a4
Directory board: JSON/CSV import + logo-replaces-title + fix images on player (#195)
* feat(widgets): bulk import for the directory board (JSON / CSV / TSV / text)

Adds an "Import from JSON / CSV" button to the directory-board editor. Paste JSON
(the { company, tenantsByFloor, advertisements, backgroundImages } shape plus
categories[]/floors[]/flat-array/bare-floor-map variants), a CSV/TSV/pipe/semicolon
table (with or without a header — vacant/yes/1 => available, quoted fields), or a
sectioned "room name" text list, and it auto-fills title, footer, floors->categories,
rooms/names/details/availability, and background-image URLs. "Replace / append" toggle.

Tolerant key matching (room/suite/unit/id, name/tenant/company, details/subtitle, …);
warns on things it can't use (bare-filename background images, headerless columns).
parseDirectoryImport is pure and was unit-tested in node across every format.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(widgets): directory board — logo replaces title, and images load on the player

Two on-screen bugs on the directory board:

1. A logo did not remove the title text — both rendered, stacking the wordmark over
   the name. renderDirectoryBoard (and the directory-search header) now gate the title
   h1 behind !logoSrc, so a logo replaces the title. New render test guards it.

2. Logo + background images did not show on the player (NS_ERROR_DOM_CORP_FAILED,
   0 bytes). The player embeds widgets in a sandbox="allow-scripts" (opaque-origin)
   iframe, so /api/content image requests are cross-origin, and the helmet default
   Cross-Origin-Resource-Policy: same-origin blocks them. Set CORP: cross-origin (+
   ACAO:*) on the content file + thumbnail routes, matching the existing /uploads/content
   static route. Content already serves publicly, so no new exposure. Verified in a real
   sandboxed iframe: same-origin blocks, cross-origin loads.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 13:53:34 -05:00
screentinker 76f072bf4b
fix(tizen): decode-gated image double-buffer to kill the black flash between stills [#187] (#193)
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 Tizen .wgt player black-flashed between IMAGE items on slow decode HW
(Samsung OM55B / SSSP). Root cause: playCurrent() called clearStage() before
renderImage() set img.src, so the stage was empty (black) until the new image
decoded. Images had no decode-gated double-buffer — video gained one in #167
(938a43a), which made the always-present image flash conspicuous by contrast.
renderImage has been byte-identical since the first Tizen commit, so there was
nothing to revert; the buffer had to be ADDED, mirroring the video path.

- preloadImage()/_takePreloadImage()/_releasePreloadImage(): one-ahead,
  image-only decode-gated buffer modeled on preloadVideo()/_takePreload().
  Detached <img>, src set, warmed via HTMLImageElement.decode() (feature-
  detected — onload/complete fallback for Tizen 5.0 / SSSP6). Warmed when the
  current image begins its dwell and from the group-sync boundary tick.
- renderImage now SWAPS: take the pre-decoded <img> (or decode a fresh one) and
  only THEN clearStage()+append, in one synchronous block — the compositor
  never sees an empty stage. Never clear-then-load on the image path.
- Scoped to images only: playCurrent() skips the up-front clearStage() solely
  for image targets (same branch order as the dispatch); video/youtube/widget
  keep their pre-dispatch clear untouched. onerror and decode() rejection route
  to skipSoon(); stale-index guard blocks mounting a stale decode over the
  current item after next()/gotoIndex/load(); one-ahead with stale release on
  index move, load(), stop(), and group-sync exit. #A1 single-item heal intact.

Tests:
- server/test/tizen-image-blackflash.test.js (new, 4/4): loads the real
  player.js in a vm context with a test-controllable decode() Promise and proves
  the invariant — across image->image the #stage is NEVER without a mounted
  <img> (old element held until the new image's decode()/onload resolves, then
  swap). Covers decode() supported, decode() absent (onload fallback), and
  broken-image (decode-reject / onerror) -> skipSoon. Proven to VIOLATE on the
  old clear-then-load ordering and HOLD on the fix.
- server/test/pip-overlay.test.js: the decode-gate makes image mount async,
  which broke its older shim (no decode()/onload/complete). Teach the shim
  element complete/naturalWidth so renderImage takes its synchronous
  complete-fallback branch and mounts. Test-only.

Full server suite 486/486. node -c clean. Headless proves the DOM ordering
invariant (the flash's precondition); final black-frame sign-off needs a real
OM55B panel (manual steps in the test-file header).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 18:29:39 -05:00
screentinker ba0663edc1
fix(pairing): close deferred-offline reclaim race + idempotent same-code adopt (#192)
A fresh unclaimed player that reconnects (same fingerprint) INSIDE the server's
~5s deferred-offline grace hit a false 'active on another connection' reclaim
reject, then collided on UNIQUE(devices.pairing_code) on the fall-through INSERT
and wedged unclaimed with no content. Real trial customer (web player) hit it.

server/ws/deviceSocket.js:
- Fix A (guard): gate the liveConn reclaim reject on !inDeferredOffline
  (pendingOfflines.has(id)). A device mid-deferred-offline is a zombie, not live,
  so a same-fingerprint reconnect is a legit reconnect, not a hijack. A genuinely
  live socket (never disconnected -> no pending-offline) still rejects a cloned
  fingerprint -> anti-hijack boundary preserved (documented).
- Fix B (idempotency): when the unclaimed old row holds the SAME pairing_code the
  reconnecting player presents, ADOPT/refresh it (mirror the claimed-reclaim path,
  but no device:paired) instead of INSERT-colliding. Differing-code case unchanged.
- deferOffline is NOT shrunk (it exists to prevent transient-blip flapping).

server/player/index.html:
- The cold-boot flap source: an unfiltered pageshow handler ran verifyLivenessSoon()
  on every load, opening+registering a socket early, which the boot connect() then
  tore down and rebuilt (connect->register->disconnect->reconnect). Guard it with
  ev.persisted (mirror the pagehide guard) so only real bfcache restores trigger it.

server/test/pairing-race.test.js:
- Forces the race against the real socket server (log-gated reconnect inside the
  deferred-offline window), asserts no false reject / no UNIQUE collision / single
  claimable row; + a hijack case asserting a cloned fingerprint on a genuinely-live
  display is still rejected. Web- and android-shaped fingerprints. Fails 2/4 on
  pre-fix code, 4/4 with the fix.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 16:05:50 -05:00
screentinker bc9e72ec0b
fix(content): render YouTube Shorts in 9:16 instead of a landscape frame (#184) (#189)
Vertical Shorts were played in a player forced to 100%x100% on a landscape
frame, so they looked wrong (pillarboxed/small). Option A: detect vertical at
ingest, persist it, and have every player honor it.

- Ingest (routes/content.js): detect a Short from the /shorts/ URL form OR
  portrait oEmbed dims (oEmbed now queried with the ORIGINAL url so /shorts/
  reports its true dimensions), and persist it as st_aspect=vertical on the
  stored embed URL. That's the only signal players get (remote_url), so it must
  be captured at ingest, not re-derived per loop. YouTube ignores the unknown
  param; players read the video id, not the full URL, to build the embed.
- Players read st_aspect=vertical and center a 9:16 box (fills a portrait screen,
  pillarboxes cleanly on landscape) instead of 100%x100%:
  web (player/index.html), Android (WebViewSupport.youtubeEmbedHtml), Tizen
  (player.js single-zone + zone paths). Dashboard library uses a static thumbnail,
  so it's unaffected.

Not doing Option B (yt-dlp): runtime dep + storage/bandwidth + maintenance +
YouTube ToS; embed-disabled Shorts already skip gracefully.

Tests: youtube-shorts.test.js (4) — /shorts/ and portrait-dims tag vertical,
landscape stays untagged, /shorts/ tags even if oEmbed fails. Android compiles;
web player inline JS + Tizen player.js parse.

Note: pre-existing Shorts added before this aren't retagged (would need an oEmbed
backfill) — re-add to fix, or a follow-up migration.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 08:41:43 -05:00
screentinker a15086540f
feat(widgets): directory-search widget (interactive search of a directory board, live-sync) (#188)
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
* feat(widgets): add directory-search widget

An interactive, walk-up search view of an existing directory-board. It
references a source board by id (no data copy), so a venue can run the
scrolling board on a main screen and a search view on a tablet, letting
people find an entry instantly.

Server (routes/widgets.js):
- register 'directory-search' + renderDirectorySearch(): resolves the source
  board, inlines its categories as one \u003c-guarded JSON blob, renders all
  text via textContent (XSS-safe), live case-insensitive filter over
  identifier/name/subtitle (debounced), grouped results, available styling,
  optional touch on-screen QWERTY keyboard that drives the same filter path.
- missing / non-directory-board source -> friendly full-page fallback, not a 500.
- live-sync while open is out of scope; left a // TODO for a poll hook.

Frontend editor (views/widgets.js): type + magnifier icon, source-board
dropdown (from loaded widgets, filtered to directory-board), title, logo
(reuses the board's picker), placeholder text, theme, on-screen-keyboard toggle;
getConfigFromForm case. i18n: widget.dirsearch.* + type keys in en/es/it/de/pt/fr.
docs: openapi widget_type enum. Tests: server/test/directory-search.test.js.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(widgets): live-sync for directory-search (poll source board, no reload)

Reflect directory-board edits on an open directory-search page without a reload.

- New public GET /api/widgets/:id/data.json returns { categories } for a
  directory-board (404 for missing/wrong-type so the page keeps last-good data
  on a transient miss). CORS-open (ACAO:*) + no-store so a null-origin sandboxed
  widget iframe can read it; exposes only data already public via /render.
  Exempted from CSP + auth in server.js alongside /render.
- directory-search page inlines its source_widget_id and polls the board's
  data.json every 30s via a relative URL (works behind a proxy/base path and
  from a null-origin iframe). Only rebuilds + rerenders when the data actually
  changed, so a mid-search view isn't disturbed; skips while document.hidden;
  keeps last-good data on any fetch error. Flatten logic factored into
  buildFlat() and reused by the poll.

Tests: data.json feed (categories, CORS header, 404s) + poll wiring in
directory-search.test.js (13/13). Verified live in a browser (page.clock
fast-forward): editing the board updates the search page with no reload.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(android): let player WebViews take touch focus for interactive widgets

directory-search is served through the existing generic widget path
(loadUrl <server>/api/widgets/:id/render), so it already renders on Android
with JS + DOM storage + mixed-content enabled, same-origin (so its live-sync
fetch of the source board's data.json works), and no touch blocking.

Add isFocusable/isFocusableInTouchMode to the shared WebView config so the
search field reliably takes a tap/cursor inside the kiosk lock-task WebView.
Harmless for passive widgets (board/YouTube have no focusable inputs); the
widget's own on-screen keyboard still drives the filter when the system IME
is suppressed. Verified with :app:compileDebugKotlin.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 08:00:22 -05:00
screentinker 51b0b006b1
fix(pairing): reinstalled panel reclaims its device row instead of being blocked [Bold] (#180)
Bold Media Group's fleet broke on the 1.9.3->1.9.6 upgrade. Their MDM does an
uninstall/reinstall (app data wiped), so the player registers with
{ pairing_code, fingerprint } and NO device_id and shows a pairing code — but the
dashboard reported "code does not exist". Deleting the device_fingerprints row fixed
it, which pinpointed the fingerprint-reclaim guard in server/ws/deviceSocket.js.

Root cause: the reclaim guard was
`stillAlive = !!liveConn || secondsSince < reclaimSettleSeconds; if (stillAlive) reject`.
On an in-place reinstall the old row heartbeat seconds ago, so `secondsSince < 300` is
ALWAYS true -> it emitted device:auth-error and returned BEFORE the pairing_code INSERT,
so the code the player displayed never existed server-side.

The settle window's real purpose was to REMATCH an existing fingerprint back to its
device row on reinstall — not to force a fresh re-pair. So the fix keys off claim status,
not the timer (server-only; no APK change — reviewed and confirmed unnecessary):
- Reject ONLY when the old row has a genuinely LIVE socket (liveConn) — the real anti-
  hijack boundary. Unchanged.
- CLAIMED old row (user_id set) -> RECLAIM it regardless of the settle window: reuse the
  row, rotate the token, emit device:registered{online} + device:paired. The panel returns
  straight to paired (no operator re-pair, no orphaned duplicate row), preserving name /
  claim / playlist / content. device:paired drives the app off the pairing screen, so the
  fresh code it showed is irrelevant.
- UNCLAIMED old row -> fall through to the pairing_code path and PROVISION FRESH with the
  shown code (reclaiming would leave a stale/null code -> "code does not exist"). #150
  relinks the fingerprint to the new row.
`reclaimSettleSeconds` is now vestigial for this path. Trade-off: a fingerprint-only reclaim
of a CLAIMED-but-offline device is no longer delayed ~300s — not a new attack class (the old
code already granted it once the window elapsed); liveConn remains the hard boundary. Truly
closing that window without a re-pair needs client keystore attestation (a future APK).

Also fixes a latent crash this newly exercises: middleware/subscription.js getUserPlan()
dereferenced an undefined user in its else branch ("Cannot set properties of undefined
(setting 'trial_active')") when the user/plan JOIN missed. Under the claimed-reclaim path
that ran checkDeviceAccess->getUserPlan, the throw was swallowed by the reclaim try/catch and
silently dropped the device to provision-fresh. Guard: `if (!user) return null`.

Tests (server/test/fingerprint-reclaim.test.js):
- NEW: a CLAIMED reinstall reclaims the SAME row, emits device:paired, creates no duplicate,
  keeps the fingerprint linked — regardless of the settle window (the Bold repro, fixed right).
- NEW: recent heartbeat + no live socket, UNCLAIMED -> provisions fresh with the shown code.
- NEW: a LIVE old socket still rejects and creates no new row (security preserved).
- Updated the #143 gone-device test to expect provision-fresh for an unclaimed row, and the
  log-noise assertion to the "reclaim rejected" message.
465/465 server tests pass. Server-only: NOT deployed, no version bump, Android untouched.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 23:07:50 -05:00
screentinker cf4c71d7d0
feat(email): SMTP transport as an alternative to Microsoft Graph [#173] (#179)
Adds a pluggable email transport so self-hosters without Azure/M365 can send
mail through any standard SMTP server (Postfix, Gmail, Mailgun, SendGrid, corp
relay). Graph stays the default; behavior is byte-for-byte unchanged when
EMAIL_TRANSPORT is unset or "graph".

- config: EMAIL_TRANSPORT ("graph"|"smtp", default graph) + SMTP_HOST/PORT/
  SECURE/USER/PASSWORD/FROM.
- services/email.js: branch by transport behind the SAME public sendEmail()/
  isConfigured() surface. SMTP via nodemailer (lazy-required, like MSAL).
  Shared across both transports: the "[ScreenTinker] " subject prefix (unless
  rawSubject), the GRAPH_DEV_RESTRICT_TO allow-list, html-from-text derivation,
  and the never-throws contract (failures log + return sent:false). SMTP_SECURE
  true=implicit TLS(465)/false=STARTTLS(587). Auth optional (unauthenticated
  relay ok); SMTP_USER without SMTP_PASSWORD is flagged. SMTP_FROM parses
  "Name <addr>". New emailConfigStatus() for startup diagnostics.
- server.js: startup logs the transport and a LOUD error when the selected
  transport is partially configured (some fields set, others missing) or when
  EMAIL_TRANSPORT is invalid (falls back to graph). A fully-unset transport
  stays a silent stdout fallback (unchanged dev behavior).
- nodemailer ^6.9.16 added as a production dep (bundled in the Docker image).
- .env.example + README: SMTP config section, Gmail example, transport table.
- test/email-transport.test.js: 15 tests — transport selection, config
  validation (missing/partial/invalid), SMTP message building (from/prefix/
  fromName override/text alt), sendEmail routing (mocked nodemailer), rawSubject,
  dev-restrict on smtp, and the smtp_error never-throws path.

462/462 server tests pass. Boot verified for all four states (configured,
misconfigured, invalid, default).

Closes #173

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 15:56:22 -05:00
screentinker 9c70fcc790
feat(diagnostics): device incident log — offline cause, network-vs-reboot, display-sleep (#175)
* feat(diagnostics): device incident log — why a screen went offline/black, with device-attested cause

Field request (Bold/s_t_r_o_b_e): "screens go offline randomly — let us see the cause." Answers it
across the fleet with a unified incident log, and — the key insight — lets the DEVICE disambiguate
the cause the server can't: if the app process survived the gap it was a NETWORK problem (not a
reboot), and it can even tell a dropped Wi-Fi/Ethernet link from a link-up-but-server-unreachable
(router/upstream) failure.

Schema:
- device_status_log gains reason + detail (WHY each offline transition happened).
- NEW device_events table (unified incident feed): type (offline/online/display_off/display_on/
  crash/reboot/network/app_error) + reason + detail, indexed, age-pruned + per-device capped.

Server:
- Capture the socket.io disconnect REASON (transport_close/ping_timeout/transport_error) instead of
  discarding it — recorded in the offline-cause log. devices.offline_reason stays on the EXIT-SIGNAL
  contract (crashed/clean_exit/silent) — a separate axis, preserved (violent death = 'silent').
- device:event handler (typed incidents) + device:connectivity-report handler (device-attested).
  lib/incident-classify.js (pure, unit-tested) composes reason+detail: cold_start->reboot;
  link_lost->network "Wi-Fi/Ethernet link lost"; else network "LAN up, server unreachable
  (router/upstream)"; appends SSID / weak-signal (rssi<-75) / IP-changed. On a report it upgrades the
  most-recent offline row from the server's guess to the device's ground truth.
- heartbeat timeout -> 'heartbeat_timeout'; retention/cap for device_events.
- Device-detail API returns statusLog.reason/detail + the last 50 device_events.

Device (Android WebSocketService): ConnectivityManager default-network callback (link-lost during a
gap) + Wi-Fi SSID/RSSI + IP snapshot -> device:connectivity-report on reconnect (app survived =>
network); ACTION_SCREEN_ON/OFF receiver -> device:event display_on/off ("screen went black"). All
guarded/feature-detected; no manifest change; compiles clean.

Web + Tizen players: reconnect connectivity-report (link_lost from navigator.onLine during the gap)
+ visibilitychange -> display_off/on. Best-effort (no wifi detail in a browser). Tizen exit-signal
marker slice untouched.

CMS (device-detail): the offline cause on the uptime-timeline hover + a new "Recent incidents" panel
(merged offline periods + typed events, friendly labels, detail, relative time + down-duration).

Built as a 4-way parallel agent fan-out over disjoint domains against a locked contract, then
integrated. Verified: full server suite 443/443 (incl. the seam fix keeping the exit-signal contract
intact), Android compileDebugKotlin clean, all players + CMS node -c clean.

Refs #170.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(diagnostics): internet-reachability probe — split "our server down" from "no internet" (#170)

Follow-up to the incident log: when a device's link is UP but it's offline, "router/upstream" was a
catch-all. The device now probes a public host (1.1.1.1 / 8.8.8.8 :443) DURING the gap, so the cause
pinpoints blame:
  - link_lost=true                     -> Wi‑Fi/Ethernet link lost (device's own link)
  - link up, internet_ok=true          -> server_down: internet reachable, OUR server was unreachable
  - link up, internet_ok=false         -> no_internet: router/ISP down
  - link up, no probe result           -> generic router/upstream (unchanged fallback)

- Android WebSocketService: fire a short daemon-thread TCP probe (443, either host) at disconnect;
  the result rides the connectivity-report as internet_ok (omitted if the gap ends before it finishes).
- lib/incident-classify.js: 3-way split on internet_ok; new reasons server_down / no_internet.
- Frontend i18n: device.event.server_down / .no_internet labels.
- Tests: +3 classify cases (server_down, no_internet, link_lost wins over internet_ok). 12/12.

Verified: classify 12/12, Android compileDebugKotlin clean, node -c clean. Refs #170.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(diagnostics): log an 'upgrade' incident (old → new app_version) — server-side (#170)

When a device reports an app_version different from the stored one, applyDeviceInfo logs an
'upgrade' device_events row (detail 'old → new'). Server-side, so it covers Android/Tizen/web with
no client change; a fresh pair (no prior version) isn't counted. Adds device.event.upgrade label.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 11:26:04 -05:00