A customer read the device page's IP as their screen's address and reported it as wrong.
It was not wrong, it was a different thing: devices.ip_address is the PUBLIC address the
server sees the connection arrive from. Both are useful — you want the public one to
recognise a site, and the local one to actually reach the panel — so the page now shows
each, labelled.
The player already computed its own address for the connectivity report; it just never
reported it. Read straight off the interfaces, so Ethernet panels get it too, and it needs
no permission. Stored on device_telemetry beside wifi_ssid/wifi_rssi, where the
per-heartbeat network facts already live, rather than as another devices column.
The same customer saw "Unknown" for the Wi-Fi name and assumed it needed device-owner
access. It needs LOCATION: Android 8.1+ returns the literal "<unknown ssid>" to an app
without it. So "Unknown" was us reporting a permission gap as if the network had no name.
The player now distinguishes not-allowed-to-know from genuinely-no-Wi-Fi, and the page says
"Needs location permission" instead of a blank. The permission is declared but NEVER
requested at startup and nothing else uses it — a signage player demanding location to
display a network name is a bad trade. It is an opt-in row on the setup screen, using the
same Enable/Manage pattern, and refusing it changes that one field and nothing else.
Also caught by the test suite, and worth recording: the first version of this dropped the
comma in the device SELECT list ("t.uptime_seconds t.local_ip"), which 500'd the endpoint
and failed seven tests that never mention telemetry. Verified end to end afterwards —
public and local addresses both returned, distinct, from a real request.
The admin plan table read /api/subscription/plans, which filters `active = 1` because
that endpoint feeds the public pricing page. So the one screen meant to show the
operator what plans exist could not show a hidden one — a comped or beta tier was
invisible to us as well as to customers, with no way to see it existed or who was on it.
Found immediately after creating exactly such a plan.
GET /api/admin/plans (platform-admin only) returns every plan plus, per plan, the number
of accounts, organisations and screens on it. Visible plans sort first so the list still
reads like the pricing ladder, with hidden ones after and badged.
The public endpoint is deliberately untouched: hiding a plan has to keep working, and
the test pins BOTH directions because they pull against each other — the admin list must
include an inactive plan, and the public list must never leak one.
Counts are the point, not decoration: "how many people are on what plan" is the question
you actually ask of this screen, and it was answerable only by hand in SQLite.
Also carries a warning for accounts whose plan no longer resolves. Both users.plan_id and
organizations.plan_id are FK-enforced to plans.id and there is no delete-plan route, so
this should be unreachable — but migrations here do rebuild tables with foreign keys off
(the tenant-cascade one rebuilt thirteen), and that is exactly how a row would be
orphaned. Six lines for a state that would otherwise be silent.
Strings added to en/de/es/fr/it/pt. Not hi: it has no admin translations at all, lookup
falls back to English, and four Hindi strings among forty English ones would read worse
than consistent English.
A customer blocked a screen once to see what the button did, then spent an evening
unable to get it back. Three separate faults stacked up.
1. Unblock did not stick. applyToDevice() restores `blocked` on re-pair — deliberately,
so a block cannot be shrugged off by deleting the device — which makes the SAVED copy
the real authority. Unblock only ever wrote `devices`, so the saved row stayed 1 and the
next delete + re-pair silently re-blocked. There was no way out from the dashboard at
all: unblock, re-pair, refused, repeat. Block and unblock now both mirror to the saved
copy, so the survives-a-re-pair property is deliberate rather than a leftover.
2. The refusal was invisible. handleServerRejection() clears credentials and calls
onUnpaired, but only ProvisioningActivity ever assigned that callback — and it is long
gone by the time playback is running. So the screen sat on "Connecting to server" and
the player eventually blamed the URL, sending the operator off checking their network
while the server had already said exactly what was wrong. MainActivity now handles it.
(This half was mine: clearing those leaked callbacks to stop the relaunch loop removed
the only thing that surfaced a rejection. It was a broken path — it fired into a
destroyed Activity — but it was the only one, and MainActivity should have owned it.)
3. The reason was thrown away. The server sends device:auth-error {error: "Device
blocked"} and the client discarded it. It is kept now, and a blocked screen says so
instead of implying a network fault. Localised in all six languages, matching the other
on-screen status strings.
Also ran on prod: one stale saved block cleared (fingerprint ef6540376599, the reporter's
tablet), DB backed up first. It was the only such row.
Tests pin both directions, because the two are easy to confuse: unblock must clear the
saved copy, AND a genuine block must still survive a delete + re-pair.
A player stands down from self-updating when another device owner manages the panel,
on the assumption that the MDM distributes packages instead. That assumption does not
always hold: an operator may run an MDM for policy alone and still want ScreenTinker's
OTA to own the player. Until now there was no way to say so — the stand-down was a
client-side decision with no operator input.
OTA_ALLOW_MANAGED_DEVICES=1 makes the server advertise `allow_managed: true` in
/api/update/check, and players skip the stand-down. Default off: the safe behaviour
stays the default, and only an explicit opt-in changes it.
Absence is not consent. The client parses the field with a false default, so a newer
player against an older server that has never heard of it still stands down; and the
server always emits the key, so a player can tell "the operator said no" from "this
server has no opinion". Config parsing is strict for the same reason — only 1/true
enable it, and anything else, including a plausible typo like "ture" or "yes", lands
on the safe side rather than riding JavaScript truthiness.
This deliberately does NOT grant silent install. Off device-owner, and without
DELEGATION_PACKAGE_INSTALLATION delegated by the MDM, Android still raises a confirm
dialog somebody has to accept, so the override alone will not fix a fleet whose
installs are failing at that dialog — delegating the scope is the real fix there. The
README says so at the point of use, because reaching for this flag is the natural
mistake.
Only reachable because the stand-down now runs after the version check rather than
before it; it needs the server's answer in hand to consult.
Follow-up to #233, which made the upload ceiling configurable — the right call,
500MB is genuinely too low for video.
An environment variable is a string, so the value reached multer's
limits.fileSize as text where a number is expected. That survives some
comparisons through coercion and misbehaves in others, which is the worst kind
of bug to find later; the line directly above it already used parseInt for the
same reason. It is parsed properly now, and a suffix is accepted — someone
raising a limit for video is choosing "about 2GB", and 2147483648 is easy to
mistype by a factor of ten.
An unparseable value falls back to the default rather than becoming NaN or
zero. Either would reject every upload on the instance, from a typo in an env
file, with nothing on screen to explain it.
The documentation matters as much as the code here. MAX_FILE_SIZE is the LAST
limit in the chain: nginx caps the request body with client_max_body_size and
returns 413 before the app is reached — our own deployment sets 500M — and
Cloudflare caps uploads per plan at the edge. Raising the variable alone often
changes nothing, so the README now says so, with the nginx directive and a note
that an upload failing with nothing in the server log never reached the server.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
Thanks — the hard-coded 500MB cap was genuinely too low for video, and making it configurable is the right call.
Merging as-is for the credit; a follow-up commit fixes two things this needs to actually work:
1. `process.env.MAX_FILE_SIZE` is a string, so the value reached multer as text rather than a number — the line directly above uses `parseInt()` for the same reason.
2. Raising it alone is not enough behind a reverse proxy. nginx caps request bodies at `client_max_body_size` (500M on our own deployment) and returns 413 before the app sees the upload, and Cloudflare's own cap applies too. That is now documented in the README alongside the variable.
The follow-up also accepts a suffix (`MAX_FILE_SIZE=2GB`) since typing the byte count is easy to get wrong.
Two mistakes in the previous commit, both of which broke CI.
The lockfile was not regenerated after adding puppeteer-core to
devDependencies, and `npm ci` requires the two to agree — so every job that
installs dependencies failed before running anything.
The smoke test was also placed in test/, which I described as keeping it out of
`npm test`. It does not: `node --test` globs that directory, so the runner
picked it up regardless of intent, tried to drive a browser as a unit test, and
failed. It now lives beside the server as smoke-ui.js, with a note saying why,
so the next person does not put it back.
Verified the way it should have been the first time: npm ci succeeds, native
modules still load, npm test is 807/807 with no browser involved, and
`npm run smoke` is 32/32 on its own.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
A whole class of defect found today was invisible to the unit suite, to a
syntax check and to review, and appeared only in front of a browser: a context
menu whose only item read "schedule.ctx_new", pointer handlers stacking on every
calendar render so one drop fired five PUTs, and a week grid that scrolled
sideways on a phone. Nothing in the repo could have caught any of them.
This keeps the checks that earned their place and throws away the scratch
scripts around them. It boots a server, drives every view, and asserts each view
renders, none raises an uncaught error, no untranslated key reaches the screen,
the calendar binds its handlers once however many times it re-renders, and
nothing overflows horizontally at phone width.
Deliberately NOT part of `npm test`. It needs a real browser, which CI does not
have, so it is `npm run smoke` and exits 0 with an explanation when puppeteer or
Chrome is missing — a test that fails for want of tooling teaches people to
ignore failures. puppeteer-core rather than puppeteer, so installing it does not
pull down a private copy of Chrome; it drives whichever one is already there.
Verified both ways: 32/32 against current main, and it fails on the listener
stacking when that fix is reverted. The missing-key case is covered by the unit
guard instead, since a context menu only exists once it has been opened.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
Three loose ends from the interface review.
Inviting a colleague is a core action and had no entry in the navigation at
all. The only route was an unlabelled icon beside the workspace name, or typing
the URL. There is now a Members item, translated, which resolves to the active
workspace so the static link needs no id. The Teams entry it sits near stays
hidden, since that feature is still switched off.
A native title= is hover-only, so the icon-only buttons — rename a wall, remove
a device from one, manage members — explained themselves on a desktop and said
nothing on a touchscreen. Long-pressing one now shows its label. The text was
already there and already translated; it simply had no way to reach a finger.
The last one is the bug that took a real screen dark. A device row can vanish
while its socket is still heartbeating, and the telemetry insert then failed a
foreign key. That throw was fatal in a way that is hard to guess: the
safe-socket wrapper reads a throwing handler as a broken one and disconnects
the socket server-side, and socket.io deliberately does not retry that kind of
disconnect — so the player sat doing nothing until a person reloaded it. A
heartbeat for a device that no longer exists is an ordinary race, not a fault
worth ending a connection over; the write is skipped and the register path
answers unpaired, which is the reply that actually helps the client recover.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
A title= is a tooltip the user reads and an aria-label is what a screen reader
says, but fourteen of them were hardcoded English. They were invisible to the
key checks added earlier precisely because they never call t() — so a French
user hovering the only route to workspace members read "Manage members", and a
German screen reader announced every modal's close button as "Close".
The user-visible ones matter most: the workspace switcher's Manage members and
Rename, the video wall's rename and remove, and the dashboard's select-for-wall.
All are translated into every active locale, along with the close buttons.
A test now rejects a capitalised literal in a title or aria-label, since that is
the shape this takes and nothing else catches it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
An audit of every view turned up two problems with the in-product help.
The tips only appeared on :hover. On a tablet or a phone there is no hover, so
the entire explanation layer was invisible to touch users — a large share of
the people administering signage — and unreachable from a keyboard. Tapping a
marker now opens it, Escape or a tap elsewhere closes it, and the marker is
focusable so Tab reaches it and a screen reader announces it. Bound once at the
document level and applied by observing the DOM, because views render from
about twenty call sites and modals appear later still; hooking each one would
have left the next new route silently unreachable again.
Four views had no tip at all. Playlists is the important one: a playlist is the
concept the reported confusion was actually about, and the page said nothing
about what one is or how it reaches a screen. Activity and Settings now have
one too. Help does not, because it is the help.
The schedule tip described a product that no longer exists — it said to click
Add Schedule, predating the drag, resize and right-click gestures. Rewritten.
All four are translated into every active locale rather than left to fall back
to English, since a tip falling back is a non-English user being handed an
English paragraph at the moment they are confused. hi.js stays deliberately
empty per the note in that file. Tests now check that every tip is translated
everywhere, and that a tip marker never names a string that does not exist.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
10pm to 4am is an ordinary signage schedule and the playback engine has always
understood it — schedule-eval treats an end before a start as a wrap. The
calendar did not. It computed four minus twenty-two, got negative eighteen
hours, and drew an eighteen-pixel sliver at 10pm with nothing at all after
midnight. The schedule played correctly while appearing broken.
An overnight window is now split into the pieces a week grid can draw: the part
before midnight on its own day, the part after it on the next, squared off
where they meet so they read as one window rather than two schedules. The
tooltip names the whole span, since neither half shows it alone. A Saturday
night spill is simply not drawn rather than wrapped round to Sunday, where it
would appear to have played six days early.
Dragging one is refused. A drag describes a window inside a single day, so
applying it to a wrap would clamp it into that day and silently destroy the
schedule — the same reason a recurring schedule's day cannot be dragged.
Verified in a browser against a real 22:00 to 04:00 schedule: 88px on Tuesday
night, 176px on Wednesday morning, alongside an ordinary daytime block.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
A widget playlist item carries its id in widget_id and has no content_id at
all. The player sent only content_id, so a widget play arrived with nothing
identifiable and was written with both columns null — and play_end bound
content_id to BOTH columns, so that row could never match itself and was never
closed or given a duration.
Nothing looked broken: a row existed for every play. It just named neither what
had played nor which widget, and never ended. Reports read empty for any screen
showing a widget, which is most of the interesting ones. Seen on a live screen
playing a single widget: one open row, both columns null.
The player now sends widget_id alongside content_id, and a name falling back
through the fields a widget item actually has, so the event records what played
even when neither id resolves. The server prefers an explicit widget_id and
keeps the old content_id sniff as the fallback for players that predate this,
so an older client that puts a widget id in content_id still attributes
correctly.
Found by reading a real screen's proof-of-play rather than the code. The first
attempt at the fix broke the statement outright — the explanatory comment was
placed inside the SQL template literal, where a JS comment becomes SQL, and the
server logged `near "/": syntax error` on every play_end. Comments now sit
above db.prepare(), with a note saying why.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
Driving the app in a real browser showed a context menu whose only item read
"schedule.ctx_new". t() returns the KEY when a string is missing — it never
returns undefined — so a missing key renders literally, and the common
`t('x') || 'A readable default'` guard is dead code: the key is truthy, the
default can never fire, and the pattern hides the problem instead of covering
it. Every occurrence of it in the app was doing exactly that.
Nineteen strings were affected, most of them predating this work: fifteen in
the self-hosted update panel and four in video walls, all of which have been
showing raw keys to users. The intended text was recovered from the dead
defaults, so the wording is the authors' own, and the defaults are removed
rather than left to imply a safety net that does not exist.
A test now walks the views for the keys they actually ask for and fails on any
that English does not define, and separately rejects the `|| default` pattern.
Neither problem is visible to a syntax check, a unit test, or review — only to
someone looking at the screen — so the guard is the only thing that keeps them
from coming back.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
A user reported not knowing how to get content onto a screen. There was already
onboarding — a modal wizard — but it is gated on a localStorage flag: skip it
once and it never comes back, and it never knew whether you succeeded at
anything. Someone who closed it was left with no thread to pull, which is
exactly what was described.
A second tour would repeat that mistake. Tours are dismissed and forgotten, and
they describe the product rather than the account. This is a checklist on the
dashboard that reads real state, so it cannot claim you have done something you
have not, it is still there tomorrow, and it names the one thing to do next
rather than everything the product can do.
The steps are the shortest true path to a screen showing something: connect a
screen, add content, put it in a playlist, send it to the screen. Only the last
one cannot be satisfied by creating an object and walking away — a screen has to
actually be pointed at something — so an account full of playlists with nothing
playing is correctly reported as unfinished, which is the failure that was
reported. Steps stay in dependency order, so nobody is sent to a page they
cannot use yet.
It disappears on its own once the first screen is live and can be hidden before
then, so it never nags someone who already knows the product. Once hidden or
finished it costs no extra request at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
The drag gestures did nothing on a phone. touch-action was set to none only
once the pointer had already travelled far enough to count as a drag, and by
then it is too late: a browser decides at touch-START whether a gesture scrolls
the page, so the page scrolled, the pointer stream was cancelled, and the block
never moved. The rule that works for a mouse cannot work for a finger.
Touch now arms by HOLDING. A press that stays put for a moment takes the
gesture over — at which point scrolling is suppressed and the block dims — while
a press that moves first is left alone as the scroll it plainly is. Everything
that is not a drag still scrolls exactly as a phone user expects. A mouse or pen
is unchanged and arms as soon as it has travelled.
Tapping empty space now creates a default one-hour slot at that time. On a
phone that is the only practical way to create, since drawing a range with a
finger is awkward, and on a desktop it is a shortcut worth having anyway.
The arming rule is a function rather than a pointerType check at each site, so
the touch and mouse paths cannot drift apart, and it is tested — including that
the hold is long enough to mean intent without feeling stuck.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
Direct manipulation existed but was awkward, and one part of it was outright
broken. A drag was recognised on ANY pointer movement, so the pixel or two of
travel in an ordinary click counted as a drag and suppressed click-to-edit —
the most common interaction on the calendar would have felt broken. A press now
has to travel a few pixels before it becomes a drag.
At 28px per hour a fifteen-minute block was seven pixels tall. Legible, but not
something a pointer can reliably hit, and its resize grip would have covered the
whole block. Rows are 44px, which makes the smallest block an 11px target while
still fitting a full day on a laptop screen; a test pins both halves of that
trade so neither can be tuned away silently. That height had been written as a
bare 28 in five places in the view that all had to agree with the module — it is
now one constant.
The rest is feedback. A block shows a grab cursor, dims while it is being moved
so it is clear what is travelling, and its grip is taller with a visible edge.
While dragging, the grid switches to a grabbing cursor and suppresses touch
scrolling, so the gesture works on a touchscreen instead of panning the page.
Pointer capture is released and the chrome reset on every exit path, including
a cancelled drag.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
The calendar rendered schedules but could not be used to change them. Creating
or moving anything meant opening a dialog and typing times, which is the wrong
instrument on a week grid: the grid already shows exactly where a thing goes, so
the grid should be where it is put. My previous change made the grid easier to
READ — all screens at once, a colour and a name per target — and left the
interaction untouched, which was only half of what was asked for.
Three gestures now share one pointer loop. Dragging empty space draws a slot and
opens the dialog prefilled with the time drawn, so the gesture supplies the
times and the dialog supplies only what it alone knows. Dragging a block moves
it. Dragging its bottom grip resizes the end. A live ghost shows the range as a
readable time while dragging, and nothing is committed until release, so an
accidental nudge costs nothing. Right-click acts on what is under the pointer:
new here, or edit, duplicate and delete on a block.
Dragging a repeating schedule sideways is refused. A one-off's day IS its date,
but a repeating one's day comes from its rule, so moving an instance across
columns would rewrite the recurrence for every other occurrence — a different
operation, and not one a mouse gesture should perform silently. Changing a
repeating schedule's TIME does still edit the whole series, since a series has
one time of day, so that is confirmed out loud rather than assumed.
The arithmetic is a separate module of pure functions, because it is the part
that fails quietly: a block that ends before it starts, a move near midnight
truncated instead of slid back, or a stamp built with toISOString() putting
anyone west of Greenwich on the previous day. Tests pin each of those. That last
one was already present in the create path and is fixed here too.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
socket.io does not retry every disconnect. On 'io server disconnect' it stands
down deliberately and waits to be told to reconnect. The player assumed the
opposite in two places: the disconnect handler stopped the watchdog because
"socket.io owns the reconnect once it KNOWS it's down", and verifyLivenessSoon
skipped a present-but-disconnected socket for the same stated reason.
So when the server closed a socket — a handler throwing, a deploy, an eviction
— nothing was left watching and the player stayed down until someone reloaded
the page. That is what it does on a wall: nothing, indefinitely, with no error
on screen. It happened to a live panel whose heartbeat hit a constraint error;
the server dropped the socket and the display sat dark until reloaded by hand.
A supervisor now backs up every disconnect the client did not itself initiate.
It re-establishes only a socket that is genuinely not connected, and only after
a grace longer than socket.io's maximum backoff, so the reconnection socket.io
does own is never raced. Our own teardown is excluded, since connect() closes
the previous socket before opening the next and supervising that would fight
the attempt already in flight. A resume now hands a stranded socket to the
supervisor rather than assuming someone else has it.
The decisions are pure functions alongside the existing watchdogShouldReconnect,
so they are testable without a browser, and a test asserts the grace still
exceeds the configured backoff ceiling if either is ever retuned.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
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
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
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
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
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
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
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
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
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
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>
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>
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>
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>
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>
A device row carries two fields that are not ordinary data: device_token, the
credential the player proves with on the /device socket, and settings_pin, which
unlocks the player's on-device settings menu and so hands physical control of the
panel to anyone holding it.
device_token was already stripped everywhere. settings_pin was not — it went out on
both the collection and the detail endpoint. The dashboard does show it, but on one
screen only: the device detail page, which fetches a single device. The collection
endpoint had no consumer for it and was returning the PIN for every device in the
workspace on every load.
The detail endpoint keeps it, so that page is unchanged. The list no longer sends it.
Same data, much smaller blast radius, no feature lost.
Tests pin the split in both directions — absent from the list, present on the detail,
and the socket credential absent from both (asserted on the whole serialized payload,
not just the top-level key, so a nested echo would fail too).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two problems on a panel showing one fullscreen widget, both visible as flashing.
The player re-navigated the WebView every duration_sec. PlaylistController.next()
requests a playlist refresh between plays and playCurrentItem() re-issues the item
unconditionally, so a one-item playlist reloaded the same URL forever. The existing
dedupe guard only covers the playlist-update path, so it logged "not restarting"
AFTER the reload had already happened. On an interactive widget that also discarded
whatever the viewer had typed.
showWidget() is now idempotent: same URL with the widget already on screen returns
without re-navigating, and the cached URL is cleared at every media-type transition
so switching away and back still reloads. The refresh itself is untouched — schedule
re-evaluation and dayparting still run on the timer, and widgets keep refreshing
their own data client-side (directory-search polls its board every 30s and preserves
the current query). The web player already behaved this way via reevaluateHeldWidget;
this brings the Android player to parity.
Separately, the directory-search keyboard was laid out in fixed pixels for a
1920-wide viewport. A panel's CSS viewport is its resolution over its density, so a
1080p screen at 240dpi presents 1280x720 — where four rows of 56px keys took ~37% of
the height instead of ~24%, and the lone max-width:700px breakpoint never fired to
correct it. Key metrics are now clamped against vh. The clamp maxima are the previous
fixed values and both vh terms exceed them at 1080 tall, so a 1080 viewport renders
pixel-identically; shorter viewports scale down. The breakpoint no longer re-pins .key,
which would have undone the clamp.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Until now the only ways back into an account were an admin setting your password for you
or shell access to run scripts/reset-admin.js. A self-hosted operator who forgot their
password had no path at all, and the admin-reset route explicitly refuses to reset a
platform admin's password — so a single-admin instance was unrecoverable without a shell.
The per-account login lockout added recently makes that sharper: a user who forgets their
password will hit the lockout and see the same generic error, with no way out.
Two unauthenticated endpoints (they must be — the user cannot log in):
POST /api/auth/forgot-password { email } -> always the same 200
POST /api/auth/reset-password { token, password } -> 200 / 400
The properties that matter, each covered by a test:
- NO ENUMERATION. The request endpoint answers identically — same status, same body —
for a real address, an unknown one, an SSO identity with no local password, and a
malformed string. The frontend shows the same confirmation even on a network error,
so the client cannot leak what the server refused to.
- NO MFA BYPASS. Completing a reset does NOT issue a session; the user signs in
afterwards, so a TOTP-enabled account still clears its second factor. Returning a token
here would turn "read one email" into a full session without the second factor.
- SINGLE USE, SHORT LIVED. 32 random bytes, stored only as a SHA-256 hash (same
discipline as email verification, recovery codes and API tokens), 1h TTL, and the
redeeming UPDATE is conditioned on the hash still being present so concurrent
redemptions cannot both win.
- LOCAL ACCOUNTS ONLY. SSO identities have no local password; no token is minted.
- IT ACTUALLY UNBLOCKS YOU. A completed reset clears the per-account login lockout and
must_change_password, otherwise someone who locked themselves out would reset and still
be locked out.
Rate limited: 5/min on the request (it sends mail to a caller-supplied address), 10/min
on the redeem. If no email transport is configured the response is unchanged — no oracle —
but the server logs loudly, because the user will otherwise wait for mail that cannot
arrive and the generic response cannot tell them.
Frontend: a "Forgot your password?" link on the sign-in card, a request card, and a
new-password card. app.js had to learn #/reset-password explicitly — the auth guard
rewrites any unauthenticated hash to #/login, which would have discarded the one-time
token in the emailed link and made it silently do nothing.
Migration adds users.password_reset_hash / password_reset_expires: additive, nullable,
idempotent; a code-only rollback leaves two dead columns.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A screen that was still connected and still displaying its pairing code could not be
paired. Reloading the player produced the same code, and the on-screen instruction
("restart the display to get a new code") could not help.
devices.created_at is written once, at first registration, and the row is never recreated:
a player persists its device_id and its pairing code in local storage and re-registers
with them forever. Expiry was measured from created_at, so 15 minutes after first boot the
row became permanently unclaimable while the device kept heartbeating — and a restart
reused the stored identity and reproduced the same code, so there was no way out.
Observed in production: an unclaimed web player, still online and heartbeating, whose row
was created 4 days 20 hours earlier and had been unpairable for all but its first 15
minutes. Prod is carrying several such rows; alpha has some 13 days old.
Key expiry on last_heartbeat instead, falling back to created_at for a row that has never
checked in. That answers the question the operator actually has — is this screen still
there showing me this code? — while keeping the property the expiry exists for: a device
that has genuinely gone away still expires.
Trade-off, taken deliberately: a code stays claimable while its screen is connected rather
than for a fixed 15 minutes. That is what the product implies, since the code is on the
screen the whole time, and guessing is bounded by lib/pair-lockout (5 failures per IP per
15 min) and the 5/min route limit rather than by this TTL.
SERVER-ONLY. The player's device:registered handler reads only device_id and device_token
and has no way to display a server-issued code, so reissuing one would have left fielded
players showing a stale code — strictly worse. This fix needs no player update and
un-strands every already-affected device in the field on deploy.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>