Commit graph

581 commits

Author SHA1 Message Date
ScreenTinker 741bc7b6a3 chore(release): v1.9.34 2026-08-13 22:10:07 -05:00
ScreenTinker 463e21f4d9 chore(release): v1.9.34-alpha14 2026-08-13 21:19:29 -05:00
ScreenTinker d6c1a36d8c chore(release): v1.9.34-alpha13 2026-08-13 20:49:24 -05:00
ScreenTinker 66df64a798 chore(release): v1.9.34-alpha12 2026-08-13 20:20:21 -05:00
screentinker 26c059c1b8
Every build from alpha10 onward sorted below alpha8 (#270)
A plain string compare on the prerelease tag put "alpha11" below "alpha8", because
'1' < '8'. The OTA check therefore answered client-newer and refused to offer the
update — while reporting the newer build as `latest` in the same response:

  latest_version: 1.9.34-alpha11   current_version: 1.9.34-alpha8
  update_available: false          reason: "client-newer"

So a fleet on alpha8 or alpha9 could not be moved forward at all, silently, and
nothing about the symptom pointed at version ordering. alpha10 was never really on
offer either; the last update that genuinely worked was alpha6 -> alpha8, where the
lexical order happens to be right by luck.

This is what semver specifies for a single alphanumeric identifier, and it is
simply not what the naming means. lib/version-precedence.js compares digit runs
NUMERICALLY, so alpha8 < alpha9 < alpha10 < alpha11, while leaving everything else
alphabetical — beta still outranks alpha, rc still outranks beta, and a release
still outranks any prerelease of the same core.

Dot-separated identifiers are compared per semver and a shorter run loses, so
moving the naming to the semver-correct `-alpha.11` form later needs no further
change here.

TWO comparators carried the assumption, each with a comment asserting lexical was
fine "for our naming" — true only while the counter stayed below 10. Both now use
the shared helper rather than a third copy drifting into the same trap:
  - lib/ota-breaker.js      the Android OTA path
  - lib/brightsign-update.js  the BrightSign host package, where a wrong-way
    comparison replaces the script that boots the player

lib/ghcr-check.js was checked and is unaffected: it rejects prerelease strings
outright rather than ordering them.

Tests pin the exact stranding case end to end — decide('1.9.34-alpha8',
'1.9.34-alpha11') must be an offer, not client-newer — plus the reverse direction,
so a future change cannot merely invert it.

1668/1668 pass.
2026-08-13 20:20:17 -05:00
ScreenTinker 8b0601b7bc chore(release): v1.9.34-alpha11 2026-08-13 19:57:50 -05:00
ScreenTinker 0b10776701 chore(release): v1.9.34-alpha10 2026-08-13 19:00:53 -05:00
screentinker 58fdf8122c
Install statistics: send on opt-in, name a blocked firewall, and add an operator collector (#268)
Three changes, all about the same failure: sharing appears to be on while nothing
actually arrives.

SEND ON OPT-IN. Turning sharing on now reports immediately instead of waiting for
the next daily tick. Two reasons: the operator is standing right there, and
"nothing has been sent" for the next 24h reads as broken at exactly the moment
someone is checking whether it works. It also means an egress-filtered network
fails HERE, where we can name the host to unblock, rather than silently tonight
where nobody is watching.

NAME THE FAILURE. Failed attempts are now recorded separately from successes, so
Settings can say which address did not answer and why, instead of showing an empty
"nothing sent yet". A blocked outbound connection is the normal failure on a
self-hosted box and is otherwise completely invisible — the operator cannot tell a
firewall from a broken feature. A later success clears the complaint, so a stale
warning never outlives the problem it describes. Docs gained a section on it, and
the UI states plainly that nothing needs opening inbound.

OPERATOR COLLECTOR, ADDITIVE. TELEMETRY_EXTRA_ENDPOINT lets an operator post the
same three fields to their own collector.

The naming is the point. It replaces TELEMETRY_ENDPOINT, which was a true override
— and an override is the wrong shape here, because a variable called "endpoint"
that silently redirected the report someone agreed to SHARE would make the opt-in
mean something other than what the UI says. Our address is hard-wired and not
overridable; theirs is explicitly additional and named so it cannot be mistaken for
a replacement. Settings lists every destination a report goes to.

The operator collector is independent of the sharing switch, because it is their
server posting to their host and our opt-in has no business gating it. So an
operator who wants internal fleet numbers with nothing leaving for us sets it and
leaves sharing off — supported on purpose, and tested.

Destinations are attempted separately: one unreachable collector must not cost the
other its report.

1662/1662 pass. Tests pin the properties that matter: an operator collector never
replaces the shared report, sharing-off still sends nothing to us whatever else is
configured, one dead destination does not stop the other, and a failure records the
address actually tried.
2026-08-13 19:00:37 -05:00
ScreenTinker 13534d9b61 chore(release): v1.9.34-alpha9 2026-08-13 17:27:29 -05:00
screentinker e9bd8ac8af
Opt-in install statistics (#267)
There is no way to answer "how many screens run ScreenTinker?". The product is
self-hostable by design, so most installs are invisible to us on purpose — and
should stay that way. This asks once, and reports only if the operator says yes.

The entire payload is three fields:

    { instance_id, version, screen_count }

instance_id is a random UUID minted on first use and kept in app_settings. It
carries nothing about the install; its only job is to let two reports from the
same server be recognised as one server, so a count is a count rather than a sum
of duplicates. That makes a report pseudonymous rather than anonymous, and the
wording shown to operators says so rather than claiming otherwise.

The payload is short on purpose. Every field added costs participation, and
participation is the only thing that makes the resulting number worth quoting.
Player-platform counts were considered and left out: release assets are already
published per platform, so GitHub's per-asset download counts answer "where should
effort go" at zero privacy cost and without asking anyone for anything.

Verifiability is the feature, not the copy. Settings shows the ACTUAL payload this
server would send, generated live from its own data, plus what it last really sent
and when. The payload is built in one function so a reviewer can check it at a
glance, and the test fails if a field is ever added.

Both answers persist. Declining is remembered as 'off' rather than falling back to
'unasked', so the prompt cannot return after an update — re-prompting is how
telemetry earns its reputation and gets patched out.

Collector side is inert unless TELEMETRY_COLLECTOR=1, so a normal install never
exposes the endpoint. Reports upsert on instance_id rather than appending, so an
install reporting daily occupies one row rather than 365 a year. The source IP is
never read or stored — receiving one is unavoidable, logging it would quietly turn
a pseudonymous report into an identifiable one.

Tests pin the negative promises, which are the ones that rot silently: sends
nothing before consent, sends nothing after a decline, payload is exactly three
keys, id survives a restart, a failed send never records a phantom report. Screen
count excludes unpaired provisioning rows, which would otherwise overstate the one
number this exists to state honestly.

docs/telemetry.md documents the payload, what is not sent, how to verify it, and
that any published total is a floor rather than a basis for extrapolation.

1657/1657 pass.
2026-08-13 17:27:10 -05:00
ScreenTinker 8b162ecce2 chore(release): v1.9.34-alpha8
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
2026-08-13 14:32:54 -05:00
ScreenTinker 1fc50ec263 chore(release): v1.9.34-alpha7 2026-08-13 13:41:46 -05:00
screentinker 128a5be1b1
Node 22 preparation: upgrade runbook, changelog notes, and the one test that breaks (#265)
* Document the Node.js upgrade procedure and this build's reinstall requirement

Upgrading the runtime does not go through scripts/upgrade.sh, so nothing
reinstalls dependencies — which is precisely when the one remaining native
module goes stale. The runbook now covers the version floor imposed by
--env-file-if-exists, why the better-sqlite3 pin is exact, and why a version
without a matching prebuild can turn Restart=always into a boot loop.

Also records that this build changes dependencies in both directions: rolling
back past it needs the reinstall too, because earlier builds import sharp at
runtime and this one drops it from production dependencies.

Kept deployment-neutral — no hostnames, addresses, or environment specifics.

* Fix the only test that fails on Node 22

Node 22 added a built-in `navigator` global, defined as a getter with no
setter. The test's shim assigned to it, which throws "only a getter" under
'use strict' on 22 while being a normal assignment on Node 20, where the global
does not exist at all. It is configurable, so define it instead of assigning.

Defining it unconditionally is also the better fixture: Node 22's own navigator
reports the HOST locale, so a test reading its language would otherwise depend
on the machine or CI runner it happens to run on.

This was the single failure in an otherwise clean Node 22 run (1639/1640 with
better-sqlite3 12.9.0), and it is confined to test code — no production server
or frontend file assigns to globalThis.navigator.

1649/1649 on Node 20.
2026-08-13 12:37:36 -05:00
screentinker c436a44c89
Pin better-sqlite3 to 12.9.0 (was ^9.4.3) (#264)
Prepares for the Node 22 move by decoupling it from the database driver, so the
two upgrades land as independently reversible steps rather than one flag day.

9.6.0 cannot work on Node 22. It uses the raw V8 API (220 v8:: references, zero
napi_), so it is ABI-locked per Node major, and its GitHub release assets carry
prebuilds for ABI 108/115/120 only — nothing for Node 22's 127. Its install script
is `prebuild-install || node-gyp rebuild --release`, so on Node 22 it silently
falls through to compiling raw-V8 code against Node 22 headers. That is not just
slow: lib/preflight-deps.js rebuilds synchronously before the server listens, and
prod's systemd unit is TimeoutStartSec=90 with Restart=always, so a slow or failing
compile is an unbootable loop rather than the intended self-heal.

12.9.0 ships prebuilds for BOTH Node 20 (ABI 115) and Node 22 (127), so neither the
current runtime nor the target has to compile anything.

THE PIN IS EXACT ON PURPOSE — ^12.9.0 would defeat it. 12.10.0 dropped the Node 20
prebuild while still advertising "20.x" in engines, so a caret resolves to 12.11.x
and reintroduces the from-source compile on today's runtime. Verified against the
release assets per version:
    12.0.0 / 12.2.0 / 12.4.5 / 12.6.2 / 12.9.0   ABIs 115,127,...
    12.10.0 / 12.10.1 / 12.11.1                  ABIs 127,137,141,147 — no 115
The reasoning is recorded in preflight-deps.js, which is where anyone hitting the
matching failure will already be reading.

13.x was considered and rejected FOR NOW: it is the first N-API release, which ends
the per-major ABI problem for good (8 prebuilds keyed by platform, not ABI) and is
where we should eventually land — but engines is ">=22", so it cannot be adopted
while prod, alpha and CI all run Node 20. It is also three weeks old with three
patch releases, which is young for the one component that owns all the data.

No API changes to absorb: every major from 10 to 13 bumped only for dropping EOL
Node/Electron versions, so the ~1486 .prepare(), 46 .transaction() and 59 .pragma()
call sites are untouched.

Verified on Node 20: installed from a PREBUILT binary (no obj.target, so no
compilation), opens a real database, and the WAL path the #149 checkpointer depends
on still works — journal_mode=WAL engages on a file DB, pragma(...,{simple:false})
returns the expected shape, and a second connection from another handle reads and
runs wal_checkpoint(TRUNCATE). 1649/1649 tests pass.
2026-08-13 12:18:47 -05:00
screentinker 13c9c67335
Drop sharp: pure-JS image ops on a worker thread (#263)
* spike: replace sharp with pure-JS image ops (jimp + jsquash WASM)

Removes the last native dependency from the ingest path, so the server no longer
needs a per-platform/per-ABI prebuilt to thumbnail an image. Motivated by getting
the server onto hardware with no toolchain, but the ABI tax is paid on every
install — it is the same failure class lib/preflight-deps.js exists to explain.

lib/image-ops.js is the whole surface: metadata() and writeThumbnail(), which are
the only two things ingest ever asked sharp for.

Format parity holds. jpeg/png/gif/tiff/bmp are native to Jimp; webp and avif go
through @jsquash WASM, whose bundled .wasm must be compiled by hand because the
packages locate it with fetch(file://) and Node has no file:// fetch — the only
symptom otherwise is a bare "fetch failed". heic is unsupported, as it already
was: sharp advertises heif but its prebuilt libvips refuses HEVC.

#170 is preserved by a different mechanism. Jimp applies EXIF orientation at
decode and rewrites the tag to 1, so metadata() reports display dimensions and
imageDisplayDims() runs as a no-op instead of swapping W/H a second time. The
helper stays in the path so the rule keeps living in one place.

Verified: 1643/1643 tests pass, and ingest was exercised in a child process with
node_modules/sharp moved aside — jpeg, EXIF-rotated jpeg, png, webp, avif, gif
all measured and thumbnailed correctly, corrupt input still yields nulls with no
phantom thumbnail_path.

KNOWN BLOCKER, do not ship as-is: Jimp is pure JS on the main thread, where sharp
handed work to a libvips threadpool. A 12MP photo goes 65ms -> 1079ms, and the
event loop stalls for 1003ms of it (sharp: zero stalls). thumbnail-backfill.js
walks a whole library at boot, so this reproduces #240 exactly — blocked loop,
missed heartbeats, panels marked offline, reconnect churn. Needs a worker_thread
offload before this is viable; image-ops.js is the seam for it.

* Run image decoding on a worker thread

Fixes the blocker the previous commit shipped with. Pure-JS decoding costs ~1s of
solid CPU for a 12MP photo, and in-process that is not a slow upload but a stalled
event loop — no heartbeats, no socket traffic. thumbnail-backfill.js walks a whole
library at boot, so it reproduced #240 (blocked loop -> missed heartbeats -> panels
offline -> reconnect churn) from our own maintenance. sharp never did this because
libvips works on a threadpool.

image-ops.js is now a dispatcher over image-ops-worker.js; the work moved unchanged
to image-ops-core.js, so callers and their failure contract are untouched.

Measured on a 12MP photo: 1079ms wall with the loop stalled 1003ms, to 1881ms wall
for two ops with ZERO stalls and 185 timer ticks serviced. Wall time is worse and
that is fine — it is off the main thread now.

Design notes, all load-bearing:
  - ONE JOB AT A TIME. A decoded 12MP bitmap is ~48MB of RGBA; overlapping jobs
    multiply peak memory by queue depth, which is the wrong failure on the small
    targets this change exists to reach. Costs no throughput — the work is CPU-bound
    and one busy worker already saturates its core.
  - unref'd while idle, ref'd only in flight. Otherwise scripts/backfill-rotation-
    dims.js never exits and `node --test` hangs forever. Verified: a CLI-style run
    exits in 104ms, code 0.
  - decode failures reply as messages, so one bad upload cannot tear down the worker
    and take unrelated queued jobs with it.
  - in-process fallback if a thread cannot be had, warned rather than silent.

test/image-ops.test.js pins the loop-liveness property, which no functional test
would catch. Its thresholds were mutation-tested against the inline path: the first
version passed there too (4MP stalls only ~355ms, under a non-flaky threshold), so
the fixture is 12MP and the thresholds sit in the gap between the two behaviours —
worker ~90 ticks/~0ms, inline ~3 ticks/~897ms. It now fails inline, as a guard must.

1647/1647 pass. Ingest re-verified with node_modules/sharp moved aside.

* Measure and thumbnail an image from a single decode

Ingest asked for metadata() then writeThumbnail(), which decoded the file twice.
That pairing was free under sharp, whose .metadata() only parses the header, but
every decode here is a full one — ~1s for a 12MP photo — so the naive translation
doubled the most expensive thing on the ingest path.

image-ops.measureAndThumbnail() returns both from one decode. Full ingest of a
12MP photo: 2 decodes/~1.9s -> 1150ms, still with zero event-loop stalls.

The subtlety is the failure contract. In the two-call version width and height
were assigned BEFORE the thumbnail was attempted, so a failed thumbnail still left
usable dimensions on the row — the player needs them to letterbox. Merging naively
would have turned any thumbnail failure into total metadata loss. So a WRITE
failure is reported ({thumbnailWritten:false, thumbnailError}) with the dimensions
intact, and the caller sets thumbnail_path only when the write succeeded, keeping
the phantom-path discipline. A DECODE failure still throws — there is nothing to
report about an unreadable image.

backfill-rotation-dims.js deliberately keeps the separate calls: it probes every
image row but regenerates a thumbnail only for the few whose dimensions changed,
so pairing them there would decode files it has no reason to thumbnail.

Tests count decodes rather than timing them — an exact property, and a wall-clock
comparison would be flaky under load. The count filters for reads of the file under
test: Node's ESM loader also goes through fs.promises.readFile, so a raw call count
picks up jimp's and the WASM codecs' lazy loading and reads 30 instead of 1.

1649/1649 pass. Ingest re-verified across all 7 formats with sharp moved aside.

* Dockerfile: sharp is no longer a production dependency

--omit=dev now leaves it out entirely; better-sqlite3 is the only native module
the builder stage still needs a toolchain for.
2026-08-13 11:40:13 -05:00
ScreenTinker 72fd2314b5 chore(release): v1.9.34-alpha6 2026-08-12 15:15:43 -05:00
ScreenTinker 350ca58f22 chore(release): v1.9.34-alpha5 2026-08-12 14:49:13 -05:00
ScreenTinker 3617a1a116 Link start cannot be navigated to: a bearer token does not survive it
"Authentication required" on every click of Link. The Settings button did
`location.href = /api/auth/oidc/<slug>/link/start`, which is a top-level
navigation -- and this app's session lives in localStorage and travels as an
Authorization header, so the request arrived anonymous and requireAuth refused
it, correctly.

The login /start route works precisely because it needs no session. Copying its
shape for a route that does need one was the mistake.

The client now FETCHES link start with its token and navigates to the URL it
returns. The transaction cookie is still set by that response, because a
same-origin fetch stores Set-Cookie normally, so the callback is unchanged.
beginOidc grew an asJson flag rather than a second copy of the PKCE/state/nonce
setup, so login and link still cannot drift apart.

Both mutations fail the new test: navigating straight at the route, and having
the server redirect instead of answering with JSON.
2026-08-12 14:44:23 -05:00
ScreenTinker ffceaf2c1f chore(release): v1.9.34-alpha4
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
2026-08-12 11:53:35 -05:00
ScreenTinker 184ff71dee Let an existing account move to SSO, and ask who you are before how
Two halves of the same problem: an account created with a password could never
use single sign-on, and the login page offered a credential before it knew
which one applied.

LINKING. Signing in with a provider never adopts an account that already has a
password -- that is the takeover the login path exists to refuse. The README
promised the way out ("the owner signs in locally and links from Settings") but
nothing had ever been built, so the refusal was a dead end rather than a
redirection. Settings now has a Sign-in method block: an account with a password
can link an instance-wide provider, and one on a provider can unlink back to a
password.

The account being linked comes from the SIGNED TRANSACTION -- the session that
started it -- never from the email in the returned token. That distinction is
the whole feature: taking it from the token would be the same email-keyed
takeover under a friendlier name. The email must still match the account's own,
because login resolves accounts by the asserted address, and one provider
subject may not be linked to two accounts.

Linking DELETES the password rather than keeping it alongside. One credential at
a time, and the confirmation says so in those words, because a password left
behind is a second way in that the user believes they replaced. Unlink therefore
takes the new password up front and writes it in the SAME statement as the
unlink -- never unlink now and set a password after, which leaves an account
briefly, or on failure permanently, with no way in.

Instance-wide providers only. An organization's provider is chosen by a
customer; letting one attach itself to a platform account would hand that
customer whatever the account can do.

IDENTIFIER-FIRST. The password box now appears only after an address has been
submitted, which is what lets the organization lookup happen before a credential
is offered: someone whose company requires its own provider is shown that,
rather than a password box that will be refused. Editing the address returns to
the identifier step so a corrected domain gets a fresh answer.

The per-keystroke lookup is gone with it. It answered for half-typed domains,
changed the form under someone mid-address, and spent a 10/min per-IP budget on
people who had not finished typing -- an office behind one address could exhaust
it without a single sign-in attempt.

Instance-wide providers stay visible at all times now, by decision: the server
refuses them for an SSO-only organization anyway, and hiding them made the page
change shape while typing.

Verified in a real browser, not only by rendering: password hidden -> submit ->
visible and focused -> edit the address -> hidden again, with no page errors.
Four mutations of the linking rules fail the tests (account from the email
instead of the session, keeping the password, allowing org providers, dropping
requireAuth).
2026-08-12 11:48:11 -05:00
ScreenTinker 6cd697c3fc chore(release): v1.9.34-alpha3
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
2026-08-11 23:23:31 -05:00
ScreenTinker e5e5b75b85 Customer Entra tenants: verify the domain, then be believed
The previous fix let the instance-wide Microsoft button work and left the
customer-facing path broken, which is the worst way round. An organization that
brings its own Entra tenant would publish the TXT record, watch its domain go
green, and still be refused at login with `email_unverified` -- because Entra
sends no such claim and rowToProvider pinned the assumption off for every org
provider.

Requiring a claim Microsoft does not emit is not a security control, it is an
outage. What makes it safe to stop requiring it is the proof that already gates
these providers: the callback confines an org provider to its DNS-verified
domains, and an address only reaches the check after passing that. Whoever
controls a domain's DNS controls its mail, which is the same trust that makes a
verification link meaningful.

So the assumption is DERIVED from proof -- `verified.length > 0` -- rather than
pinned off. A provider that has verified nothing still assumes nothing, which is
belt and braces: emailAllowedForProvider already refuses it, since an empty
allow-list matches no domain, but deriving it here means a future reordering of
those checks cannot silently widen it.

It is never a column, and there is no column for it to be read from. An
organization must not be able to switch this on for itself; it is a consequence
of DNS proof, not a setting. A test asserts both -- that the value is derived
next to `source: 'org'`, and that no `assume_email_verified` exists in the
schema.

Domain confinement is untouched. An explicit `email_verified: false` is still
refused from anyone.

Mutations all fail the tests: assuming unconditionally, never assuming, and
reading it from the row.
2026-08-11 23:05:25 -05:00
ScreenTinker 77d41ae73e chore(release): v1.9.34-alpha2 2026-08-11 22:34:53 -05:00
ScreenTinker b1a58144bb Microsoft sign-in could never complete: Entra omits email_verified
The OIDC callback required `claims.email_verified === true`. Entra ID v2 does
not send that claim at all, so every Microsoft login authenticated correctly
against the tenant and was then refused with `email_unverified` on the way back.
Nothing caught it: the SSO tests assert how the Microsoft issuer string is built
but never put a Microsoft-shaped token through the policy.

The strict check was itself a fix -- `=== false` had been accepting an omitted
claim -- and it is right for a provider a CUSTOMER configured, since such a
provider is chosen by the party it vouches for and its bare assertion is worth
nothing. What was wrong is treating that as a question about the token when it
is a question about who we trusted. `users.email_verified` is our own state; the
claim is the IdP's. An instance-wide provider was chosen by the operator -- the
same trust that already exempts it from domain confinement -- and Microsoft is
additionally pinned to one tenant GUID, so only that directory can issue a token
whose `iss` matches.

So the policy now depends on the provider, in emailIsVerified(), next to the
flag it reads so the two cannot drift:

  - explicit true            -> believed, from anyone
  - claim absent, operator   -> believed (Microsoft; opt-in for other IdPs)
  - claim absent, org        -> refused
  - explicit false           -> refused, always

Org providers pin the flag false in rowToProvider and never read it from the
row, so the takeover path the strict check existed to close stays closed.
Google is left strict: it does send the claim.

Also documents MICROSOFT_CLIENT_SECRET (supported in code, missing from the
table), that the redirect URI must be registered under Web rather than SPA, and
the email optional claim -- the other two ways an Entra setup fails.

All three mutations of this policy fail the new tests: reinstating the strict
check (3 failures), letting an org provider assume (1), and accepting an
explicit false (2).
2026-08-11 22:29:53 -05:00
ScreenTinker 6830fe58ea chore(release): v1.9.34-alpha1
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
2026-08-11 21:54:55 -05:00
ScreenTinker 226c96c17e Keep the widget editor's Preview isolated, whatever the org setting says
#254 lets an organization opt out of widget iframe isolation so that players
can embed origin-strict third-party sites. It applied that opt-out to the
widget editor's Preview as well.

Preview is framed by the dashboard, from the dashboard's own origin, and the
dashboard keeps its session JWT in localStorage. So with the setting on, anyone
who can author a widget -- workspace_editor and up; viewers are refused at the
create route -- could put script in a text widget and read the session of
whichever admin clicked Preview. That is an editor -> admin escalation, and it
is not the risk the confirmation modal asks the admin to accept: a player runs
on a kiosk with a device token, an admin's dashboard session is a different
thing entirely.

The org setting is what makes players able to embed those sites, so the
/render path keeps consulting it. Preview is pinned to allow-scripts in both
places that build it -- the dashboard iframe and the server-side render -- so
neither a frontend change nor a new server caller can re-grant it alone.

Also correct the modal copy, which claimed same-origin would expose the session
of anyone viewing "a display or preview". Preview is now excluded, and the
display case is really the device token, so say that instead.

widget-preview-stays-isolated.test.js fails if either half is reverted; both
mutations were checked to fail before committing.
2026-08-11 15:53:57 -05:00
screentinker 6aeb703efe
Merge pull request #254 from ChrisChrome/main
Add org-level widget sandbox toggle.
2026-08-11 15:45:39 -05:00
ScreenTinker fbf55f842c Close the third QA round: limiter bypass, stored XSS, break-glass, org placement
Four HIGH findings. Two were mine, and one was a composition of two of my own fixes.

ONE EXTRA SLASH DEFEATED EVERY /api/auth LIMITER

`/api/auth//login` still reaches the login handler — Express normalises the mount
boundary for the router — but `app.use('/api/auth/login', rateLimit(...))` does not
match it, so the limiter never runs. A review got a real session after 60 unthrottled
password attempts. Same for //totp/verify (unlimited 6-digit brute force),
//forgot-password (unlimited reset mail to any address) and //sso/discover (the
customer-enumeration cap, gone). Fixing the limiter KEY could never help, because the
middleware was never invoked: the path is now collapsed to one canonical form before
routing. Pre-existing, and it falsified this file's own warning about walking past the
login limiter.

STORED XSS: I ESCAPED ONE COPY OF THE TABLE

My earlier fix patched views/admin.js line 357 and missed line 372 in the same
function — and missed views/settings.js entirely, which renders a SECOND copy of the
platform users table from the same endpoint, including the email in a raw text node.
The write path was `POST /api/admin/users`, whose EMAIL_RE barred only whitespace, so
an org or workspace admin (not a platform admin) could choose an address that executed
in the operator's session. Both tables escaped, both regexes tightened to reject markup
characters, verified against 11 address shapes.

I KILLED THE BREAK-GLASS WHILE CLOSING AN ORACLE

Hoisting the domain check above the account lookup — my fix for the enumeration oracle
— made `user.role !== 'platform_admin'` unreachable for enforced domains. On a
self-host the operator IS the org owner, and my would_lock_out_actor guard GUARANTEES
their address is inside the enforced set, so the recovery loop closed on itself:
approving a removal request needs a signed-in platform admin. Both properties hold now
by letting the operator through on a CORRECT PASSWORD only — every wrong answer is the
identical 403 whether the address exists, does not exist, or is theirs. Verified: 200 /
403 / 403 / 403.

Also fixed: enabling SSO-only locked out every password-holding member including the
admin who pressed the button (password refused by policy, SSO refused by
account_exists_local). An org provider now adopts a password account at a domain it has
PROVED by DNS when the org requires SSO — which is what a verified domain means, and
what every hosted identity product does.

SSO USERS WERE LANDING IN A PERSONAL ORG

The membership write added organization_members but no workspace_members, and
ensureDefaultOrgForUser looks at workspaces — so it minted each SSO user a private
organization and made it their current one. The customer's Members page read
"Members (1)" while their staff signed in successfully and were invisible.

ALSO: bcrypt on a NULL password_hash 500'd with a stack (and was an oracle for accounts
a provider deletion had returned to local); stranded_members was returned by the server
and discarded by the UI; a provider with zero domains was the one useless state with no
warning; two limiter shapes were missing (removal-request shared the garbage bucket —
an unauthenticated flood could deny the SSO break-glass path); doubled mail subject
prefixes; a DELETE that toasted "Saved"; a decided request left in the DOM with live
listeners; and a confirm dialog promising "immediately" when sessions already open
survive.

1609 tests, three clean runs. Limiter, break-glass, oracle parity and null-password all
verified against a running server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
2026-08-11 11:25:11 -05:00
ScreenTinker 94e1273ecd Fix: per-organization SSO was blocked by our own CSP and had never worked in a browser
THE HEADLINE FEATURE COULD NOT RUN.

"Continue with single sign-on" was a <form method="POST"> that redirected on to the
customer's identity provider. Chrome applies `form-action` across the WHOLE redirect
chain, and the dashboard sets `form-action 'self'`, so the hop to the provider was
aborted — silently. The user clicked and nothing happened: no navigation, no toast, no
spinner, a byte-identical page. Combined with SSO-only it was a total lockout: password
login answers 403 "use the single sign-on button", pointing at a button that cannot
work.

Every test I ran on this feature checked the button RENDERED. None clicked it.

The provider origins cannot be allowlisted — customers supply them at runtime. So the
page now fetches the destination and navigates itself; a script-initiated navigation is
not governed by form-action. The redirect answer is kept for a caller without
JavaScript, where the chain stays same-origin until the provider takes over. The slug
in the JSON is not a disclosure: following the old redirect put it in the address bar
and history anyway.

Verified in Chrome: the provider start endpoint is reached, zero CSP violations, zero
aborted requests — where before it was ERR_ABORTED plus a console violation.

STORED XSS IN THE PLATFORM ADMIN'S SESSION

admin.js interpolated user name, email and auth_provider into innerHTML unescaped, and
/register accepted an address whose local part was an img tag with an onerror handler —
no spaces, so it slipped the asserted-email check too. A reviewer registered
anonymously and got script execution on #/admin: the page operators are now emailed to.
Escaped, and registration refuses addresses that are not addresses. (The render bug
predates this branch; the reachability and the significance of that screen do not.)

ALSO

  - the org SSO button is secondary while a password still works; two identical blue
    buttons stacked sent people to their IdP by muscle memory after typing a password.

1609 tests, three clean runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
2026-08-11 10:36:32 -05:00
ScreenTinker 85febe05c0 Fix a login-page dead end, an enumeration oracle, and three boot/limiter defects
From the regression sweep. The first is a genuine regression against main.

A RATE-LIMITED DISCOVERY PERMANENTLY DEAD-ENDED THE LOGIN PAGE

lookupOrgSso checked that a body PARSED, not that the request succeeded — and a 429
body is valid JSON. So `data.sso` came back undefined, the single sign-on button was
hidden, the password box restored, and the domain recorded as answered: permanently,
for the life of the page. On an SSO-only domain that is the worst outcome available —
the password box then returns 403 and the button the user is told to use is not on the
screen. Discover is 10/min per IP and one person filling in the form costs up to four
calls, so a few colleagues behind one office address is enough. The comment above that
code already claimed to prevent exactly this; it only ever covered the 5xx case.

THE SSO-ONLY REFUSAL WAS AN ACCOUNT-EXISTENCE ORACLE

403 for an address that exists, 401 for one that does not — from an endpoint whose own
lockout returns 401 specifically to avoid that. The DOMAIN check now runs BEFORE the
account lookup, so both answer identically; whether a domain uses single sign-on is
already public through /sso/discover, so it reveals nothing new. The membership-level
refusal is deliberately downgraded to the generic 401, because a distinct answer there
would put the oracle back for exactly the accounts worth enumerating.

Verified: existing and invented addresses at an SSO-only domain both 403; and on an
instance with NO SSO configured, register/login/wrong-password/unknown-address are
201/200/401/401 — the hoisted check does not touch them.

BOOT PREFLIGHT

  - a cold install ran `npm ci --omit=dev` unconditionally, so a first start on a
    developer machine left `npm test` broken: same class of surprise as the prune this
    file already warns about, through the other branch of the same if. Now production-
    only.
  - two servers starting together: the loser died with ENOTEMPTY even though the tree
    was complete by then. It re-checks before failing.
  - the opt-out accepted only '1', unlike every other boolean the server takes.

THE LIMITER FOLD, DONE PROPERLY

Unmatched paths under /api/organizations still minted a bucket each. My first fix was a
catch-all regex — which put every unknown path in ONE bucket WITH the real endpoints,
so flooding nonsense URLs exhausted the limit for /sso-only. That trades a bypass for a
denial of service. Folding is now by explicit shape: known endpoints keep their own
keys, everything else shares a bucket kept apart from all of them.

Verified: 120 unmatched paths give 60/60 (bypass closed), and after that flood
/sso-only, /sso and /sso/:id/test all still answer 401 rather than 429 (no starvation),
while 70 hits on one real endpoint do trip its own limit. The login trailing-slash
bypass stays closed.

1609 tests, three clean runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
2026-08-11 10:22:25 -05:00
ScreenTinker 37e22bb773 SSO-only: enforce per-domain, cover invited members, and stop the admin locking themselves out
A second attack round defeated three of the previous fixes and found a regression I
introduced. Each is reproduced-then-refused against a live server.

MEMBERSHIP: organization_members IS NOT HOW PEOPLE JOIN

Only three places write that table and nothing deletes from it — every INVITED user,
every admin-created account and every workspace assignment lands in workspace_members
and nowhere else. So keying enforcement on organization_members covered org owners and
people who had already used SSO: exactly the set the domain check already caught. A
reviewer invited an outside address into an SSO-only tenant, kept password login, read
the member list and content, and used it to invite more. Enforcement now asks whether
the user is in ANY workspace belonging to an SSO-only organization.

THE INTERLOCK ASKED THE WRONG QUESTION, TWICE

It fired only when a domain list became EMPTY, and it counted PROVIDERS. So:
  - replacing acme.test with decoy.test removed every proof and sailed through — two
    PUTs, and the customer's domain enforced nothing, with sso_only still reading true;
  - with two providers you could disable the one owning your staff's domain, because
    the other one, covering a domain nobody signs in at, still "enforced".
The question that matters is per-DOMAIN: after this change, is every domain that
enforces today still enforcing? Losing one needs the operator, whichever route gets you
there. The refusal now names the domain that would stop being covered.

REGRESSION I CAUSED: THE HAPPY PATH LOCKED THE OWNER OUT

Sign up with a personal address, create the org, verify the company domain, turn this
on — and enforcement covers you (you are a member) while your own address is outside
the verified domains, so passwords are refused AND your org's provider will not assert
for you either. No route removes a membership; reset succeeds but login still refuses.
Recovery meant a platform admin turning SSO off for the whole tenant. Enabling now
refuses when the actor's own address is not covered, naming it, and REPORTS everyone
else who will be stranded instead of letting them be discovered by support ticket.

ALSO

  - POST /api/admin/users gated only on the target workspace, so you could mint
    cfo@theircompany.test into your OWN workspace: login refused, but the row now has a
    password_hash and an SSO login will not adopt one — permanently locking a real
    person out of their own address. Now gated on the address's domain too.
  - `ceo@acme.test.` (trailing root dot) slipped the registration gate.
  - two rate-limited sub-paths were still unfolded because the generic org-id fold ate
    `sso-only` as an organization id; the specific shapes are matched first now.

1609 tests, three clean runs. Verified live: invited outsider 403, swap refused,
sibling-disable refused, squat 400, self-lockout refused with the address named, and an
on-domain admin gets `stranded_members` back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
2026-08-11 09:51:40 -05:00
ScreenTinker 355b7a2b86 SSO: build the operator approval screen, and close the last of the QA findings
The approval workflow had no front door. The notification email told the operator to
"review it in ScreenTinker under Admin" and that screen did not exist — the only way to
approve was curl, while the tenant sat locked out of their own product. Admin now leads
with a removal-request section: who asked, for which organization, the reason they
gave, what approving does, and Approve/Reject. It hides itself when the queue is empty.
Approving is confirmed; rejecting is not, because rejecting only leaves the safe state.

REGISTRATION BYPASSED SSO-ONLY AND SQUATTED ADDRESSES

/register had no domain awareness: it issued a working session at an SSO-only domain,
and the account then held that address forever, because an SSO login will not adopt a
row that has a password. Registering ceo@acme.test before the real CEO's first login
left the address dead in both directions with no self-service way out. Refused now, and
"Create Account" is hidden on the login page for those domains — it was the only action
left on the card, so the page was inviting the one thing that cannot work.

THE NEW RATE LIMIT WAS DECORATIVE

/api/organizations carries three caller-chosen segments, and only the OIDC slug was
folded — so every request minted its own bucket. Measured: 120 calls with unique org
ids produced ZERO 429s, unauthenticated, against the limit that exists to bound
outbound discovery and live DNS. Now 60/60. The general problem was named in the
previous commit's own comment and then not applied to the mount it added.

XSS IN THE TOAST

showToast built innerHTML from server strings, including ones that reflect input
verbatim — a reviewer typed `<img src=x onerror=alert(1)>` as an issuer and got script
execution in the admin's session. Escaped.

ALSO

  - the org SSO button sat BETWEEN the "Password" label and its input, so the label
    described the button and the field had none; moved below the input, with a for=
  - the OR divider survived when the providers under it were hidden
  - provider action buttons were clipped off-screen at 375px with no way to scroll to
    them — "Remove" was unreachable; the row wraps now

1609 tests. Verified in real Chrome: 13/13 on the approval loop and the login states,
including approving a request and watching password login re-open for that org.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
2026-08-11 08:48:53 -05:00
ScreenTinker 983bee31b7 SSO-only: close the backdoor, the unilateral disable, and the fresh-install fail-open
Three HIGH findings from the QA round. Each was demonstrated end to end against a
running server, and each is now refused there.

ENFORCEMENT PROTECTED A DOMAIN, NOT AN ORGANIZATION

ssoOnlyForEmail answers about an address's domain, so any account in the tenant at an
outside address kept password login — a contractor, an MSP, the one address nobody
remembered. And it could be manufactured: POST /api/admin/users accepts workspace_admin
and creates a LOCAL password account at any address bound to that workspace. A review
created backdoor@notacme.test, logged in with the password, landed in the SSO-only org,
and used it to create another. Enforcement is now keyed on MEMBERSHIP as well as domain
(ssoOnlyForUser), and that route refuses to mint password accounts into an SSO-only
organization at all. platform_admin keeps both, as the operator break-glass.

THE APPROVAL WORKFLOW WAS DECORATIVE

`sso_only` is honoured only while a provider is enabled and a domain is verified, so
`PUT {enabled:false}`, `PUT {email_domains:""}` and `DELETE` each switched enforcement
off — with sso_only still reading true, no request filed and the operator never told.
The delete variant additionally rewrites every federated account to `local`, after
which a password reset takes over accounts the identity provider was supposed to own.
Anyone who could file a request could simply turn the provider off instead. All three
now refuse with sso_only_locked when nothing else would still enforce, and say to ask
for approval.

FRESH INSTALLS FAILED THE MIGRATION AND FAILED OPEN

The ALTER adding organizations.sso_only sat in the column-migration array, which runs
BEFORE the multi-tenancy migration that creates the table: `[migrate] FAILED … no such
table: organizations`, one line among ~85. The instance then ran its whole first boot
with the SSO settings screen 500ing and ssoOnlyForEmail catching `no such column` and
answering "not required" — password login proceeding for an organization that had
switched it off. It self-healed on the second boot, which is what made it easy to miss.
The column is now added after the table exists, and the catch distinguishes "this
instance has no per-org SSO" (null, so single-tenant installs keep working) from drift
on a table that DOES exist (throw). Login treats an undeterminable answer as "required"
rather than letting a 500 escape or letting the login through.

Verified live, all four refused with enforcement intact and the operator still able to
sign in. 1609 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
2026-08-11 07:23:28 -05:00
ScreenTinker 901e664591 Fix: the router discarded every SSO return, so single sign-on could never complete
THE CRITICAL ONE. The server ends every SSO login by redirecting to `#/login?sso=1`
(claim the session) or `#/login?sso_error=<code>` (say what went wrong). The router
compared the hash EXACTLY against '#/login' in three places, so an unauthenticated
browser — the only kind that ever arrives there — had the hash rewritten to a bare
'#/login' and the query was gone before the login view ran.

  - a user who authenticated perfectly at their IdP landed back on a clean login page,
    still signed out, with no message: /api/auth/sso/claim was never called
  - all 16 error codes rendered SILENCE — not a raw key, not "undefined", nothing to
    report or search for
  - it took the pre-existing ?verified=1 email-verification toast with it

The comment above the reset-password exclusion describes this exact bug class and was
never extended to the login route. It is now, in all three places: the auth redirect,
the render dispatch, and the no-workspace guard.

Verified in real Chrome: 16/16 codes render a real sentence, and ?sso=1 now reaches
POST /api/auth/sso/claim.

Also, on a server with NO SSO configured, confirmed in the browser that the login page
is exactly what it was before any of this work: email, password, Sign In, Forgot
password, zero SSO buttons, no single sign-on wording, plain local login issues a
session, no page errors.

And fixes MY preflight, which pruned devDependencies as a side effect of BOOTING:
`npm install --omit=dev` reconciles the whole tree, so merely starting the server
deleted socket.io-client, puppeteer-core and js-yaml and broke `npm test`. A reviewer
watched it happen. It now installs only the named missing packages, with --no-save —
a boot-time repair that quietly removes packages is worse than the failure it fixes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
2026-08-10 22:51:27 -05:00
ScreenTinker 601b526264 Boot: install missing dependencies and rebuild the native module before starting
scripts/upgrade.sh already runs `npm ci`, so this is not for the normal path. It is
for the ways a box ends up with the wrong node_modules, both of which present as
"server will not start" with an error naming a file rather than the action needed:

  ROLLBACK      checking out an older tag to back out a bad release restores that
                tag's package.json but not its packages. This branch removes
                google-auth-library, so a rollback to main would not boot — and you
                are rolling back because something else already broke.
  NODE UPGRADE  better-sqlite3 is compiled against one ABI. Upgrading Node makes every
                boot fail with NODE_MODULE_VERSION, which reads like database
                corruption and is not.

Runs as the FIRST statement in server.js, before any dependency is required, and uses
only Node builtins — anything it imported could be the thing that is missing. Repairs
with `npm install --omit=dev` (never `ci` on a partly-populated tree, which would
delete a working node_modules to fix one package) or `npm rebuild better-sqlite3`, and
exits with the command to run if it cannot. ST_SKIP_DEP_PREFLIGHT=1 opts out.

⚠️ The first version of the native check was WRONG and I caught it only by running it
under a real version mismatch: better-sqlite3's entry point is plain JavaScript that
loads the compiled binding lazily, so `require()` succeeds under a Node the binary was
never built for. It reported a genuinely broken install as healthy. It now opens an
in-memory database, which is what actually pulls the binding in. A test pins that,
because the failure is invisible — the check keeps passing on every machine where
nothing is wrong.

Verified: a deleted dependency is detected, installed and the server boots (200); the
ABI mismatch is detected under Node 18 against a module built for Node 20 and reported
clean under Node 20; a healthy tree costs 8ms and touches no network.

1609 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
2026-08-10 22:44:26 -05:00
ScreenTinker 0e8ffa5444 SSO-only: an org may require its own identity provider, operator approves removal
Per-organization toggle. Enabling is the safe direction and an org admin does it
alone; turning it back off is a REQUEST that a platform admin has to approve, because
that is the direction that re-opens password sign-in — the direction a compromised
admin would take, and the one a customer will demand at their worst moment with the
IdP down.

  - requires at least one VERIFIED domain, so nobody can lock a company out of a
    domain they only typed, and an org cannot leave its own people with no way in
  - the login page HIDES the password field for those domains rather than letting
    someone type a password that will be refused and then go reset it
  - the refusal is `sso_required`, distinguishable from a wrong password
  - the approval email carries NO action link: a token that acts on its own turns
    every forwarded copy into a way to switch off a customer's SSO. The decision is
    made signed in as a platform admin.

INSTANCE PROVIDERS WERE A SIDE DOOR

Blocking passwords while leaving "Continue with Google" is not requiring single
sign-on, it is renaming the bypass — instance-wide providers are the operator's and
are NOT domain-confined, so one could assert an address at an SSO-only domain and walk
straight past the customer's MFA and deprovisioning. The callback now refuses any
provider other than that organization's own, and the page stops offering them.

Instance-wide stays the default everywhere else: an address whose domain has no org
SSO still gets local plus every configured instance provider. The org only overrides
for its own verified domains.

PLATFORM_ADMIN IS EXEMPT, DELIBERATELY

The operator approves turning this off. If the operator's own address sat at an
SSO-only domain and that IdP broke, nobody could sign in to approve anything and the
instance would be bricked. The exemption is the break-glass, and a test pins it as
source so it is not "tidied away" as a convenience.

BROWSER-FOUND

Hiding the password by hiding its .form-group also hid the organization SSO button,
which lives inside that same group — leaving a login page whose only action was
"Create Account". Only visible by looking at a screenshot. Hides the field now, not
the container.

Player untouched: this branch changes no device, WebSocket or provisioning file, and
the 358 device/player/socket/pairing tests pass.

1603 tests pass. Enforcement, the approval workflow and the login page verified in
real Chrome.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
2026-08-10 22:19:40 -05:00
ScreenTinker c91b96ab91 SSO: refuse delegated proof names, release lapsed and deleted claims
Third review pass. It confirmed the crash wrapper holds (~13,000 hostile requests,
no fourth crash), the SSRF rewrite holds (77 vectors, every CIDR boundary proven),
the rate-limiter rewrite closed the login brute-force bypass, and /sso/claim rejects
every wrong token kind. It also found that two things I built yesterday did not do
what they claimed.

THE 8-HOUR LIMIT DID NOT BOUND SQUATTING

Pressing Verify on an expired claim REISSUED it in place, renewing the clock — so one
request per window held a domain forever, through the endpoint meant to enforce the
limit. Worse, a renewal was not a new claim, so the operator was notified exactly once,
on day zero: a tenant could sit on a company's domain for a year off a single stale
alert. A lapsed claim is now RELEASED. Re-adding it is an ordinary new claim: new
token, and the operator is told again. Squatting is not impossible; it is loud.

A DELEGATED PROOF NAME COULD FORGE A DOMAIN

A TXT lookup follows CNAMEs, and RFC 4592 means a wildcard `*.victim.com` synthesizes
`_screentinker-verify.victim.com` too — so a wildcard CNAME let whoever controls its
target prove a domain they do not own, turning an ordinary subdomain takeover into
every `@victim.com` login. A reviewer did this against a real authoritative zone. The
proof name is now refused if it is a CNAME, which is stricter than ACME's dns-01, and
the comment that claimed wildcards "cannot be mistaken for a proof" — true only for
wildcard TXT — has been corrected.

MY VERIFY BUTTON REPORTED FAILURE ON SUCCESS

`await load()` — the loader is `loadSso()`. The ReferenceError went into a bare catch,
so a correct DNS proof showed "Could not verify that domain" and left the card stale.
On the expired branch the admin kept publishing a token the server had already rotated.

ALSO FIXED

  - deleting a provider stranded its verified domains (no FK, UNIQUE, never expires) so
    the domain was blocked for EVERY org forever with no in-product recovery, and its
    users could neither sign in nor reset. Delete now releases the domains and returns
    the accounts to local, in one transaction; a cascade FK backstops it.
  - isOrphanedFederated read absence-of-config as proof-of-deletion, so unsetting
    GOOGLE_CLIENT_ID made every Google account password-resettable instance-wide, and
    irreversibly. Restricted to org-provider slugs.
  - `email_domains: null` (not undefined) took the destructive branch and deleted every
    DNS proof an organization had.
  - unbounded domain lists: 400 domains sent 401 emails; now capped at 50, one digest
    per save, and /api/organizations is rate-limited at all for the first time.
  - login and register responses carried password_reset_hash and email_verify_hash —
    live account-takeover credentials handed to the browser. One sanitiser now.
  - trailing-dot hostname (`https://localhost./`) slipped the SSRF guard.
  - asyncRoute's own catch could throw and kill the process it exists to protect.
  - a legacy DB whose typed domains were never verified now says so LOUDLY at boot
    instead of silently locking every federated user out.

TESTS

Two of the previous round's tests passed against the code they were named after: one
asserted UNIQUE against the test harness's own CREATE TABLE rather than the shipped
schema, the other used two different domains so no ordering was exercised. Both
replaced and confirmed load-bearing. Seven mutations now turn the suite red, including
removing the CNAME refusal, the verified_at filter, and the expiry itself.

1598 tests pass. Delete-release, lapse-release and the leak fix verified against a
running server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
2026-08-10 21:01:26 -05:00
ScreenTinker 9155370ae8 SSO: TXT only for domain proof, drop the CNAME form
The CNAME alternative pointed at `<token>.verify.screentinker.com`. Making that work
means operating a wildcard DNS zone that answers for every token ever issued — which
this project does not have, so half the published instructions described a check that
could never pass. Documenting a verification path that cannot succeed is worse than
offering one form.

TXT needs nothing outside the customer's own zone, and the dedicated `_`-prefixed name
keeps it away from the apex where SPF and DMARC live. A wildcard `*.example.com` cannot
be mistaken for a proof either way: it answers with its own value, never the token, so
it lands in "exists but does not match".

Also simplifies check() — one lookup, no Promise.allSettled, and NXDOMAIN is reported
as "not published yet" rather than as an error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
2026-08-10 20:27:03 -05:00
copilot-swe-agent[bot] d0c7ba28b7
Fix RSS ticker so scroll speed is content-independent
Co-authored-by: ChrisChrome <28414320+ChrisChrome@users.noreply.github.com>
2026-08-11 00:44:27 +00:00
ScreenTinker d4b8d7dad4 SSO: prove domain ownership by DNS, and fix what the second review found
A second review pass, run against the previous commit, found four blockers — two of
them introduced by the fixes in that commit. It also confirmed the original account
takeover is closed: a hostile IdP with real TLS, discovery, JWKS and RS256 driving the
real routers now stops at domain_not_allowed, and all 16 bypass variants are refused.

DOMAIN OWNERSHIP (the root cause, not the symptom)

A claimed domain used to mean "nobody else claimed it". It now means the organization
published a record in that domain's own DNS — TXT or CNAME, at a dedicated
_screentinker-verify name rather than the apex, where an edit would sit beside SPF.

  - an unverified domain routes NOBODY and cannot be asserted; it reserves the name
  - an unverified claim LAPSES after 8 hours, so a domain cannot be held against its
    real owner, and lapsing rotates the token so a record left over from an abandoned
    attempt cannot satisfy a later claim
  - a verified domain never expires — re-proving on a timer would log a customer out
    over a DNS edit made months later
  - routing and confinement read the VERIFIED set only, never the typed column
  - configuring SSO now requires a verified email address
  - platform admins are emailed when a domain is claimed; nothing is ever sent to the
    claimed domain, which would let any tenant make this product email third parties

Instance-wide providers are exempt from all of it: they are the operator's own
configuration and keep the trust they have always had.

BLOCKERS FROM THE REVIEW

  - two unauthenticated remote crashes, both one request, both "async handler throws
    before its try": `Cookie: st_oidc_tx=%` (unguarded decodeURIComponent) and the
    fail-closed secret added last commit, which turned a JWT_SECRET rotation into a
    permanent crash loop. Fixed the CLASS with asyncRoute() rather than the instances.
  - the SSRF guard was bypassable via IPv4-mapped IPv6 ([::ffff:127.0.0.1]) and also
    refused every host beginning "fc"/"fd" (fcm.googleapis.com). Addresses are now
    parsed and compared by RANGE. 42 cases verified.
  - the takeover fix had NO test — the test named after it asserted two struct fields
    and passed with the guard deleted. The decision is now a pure function and four
    mutations were confirmed to turn the suite red.
  - the PUT path never received the TOCTOU fix, so two orgs could end up holding one
    domain and forEmail handed routing to the attacker's older row.

ALSO

  - linking compared slugs, so an org could never rotate its own IdP, and fell open on
    an empty auth_provider. It now asks which ORGANIZATION owns the slug.
  - an account stranded by a deleted provider can be reclaimed by password reset —
    proof of the mailbox, which is stronger than the IdP assertion that created it.
  - /sso/claim accepted a pre-TOTP mfa_pending token and returned the full user row;
    it now takes a purpose-built 120s claim token with a pinned algorithm and typ.
  - the rate limiter keyed on a caller-controlled path, so a trailing slash bought a
    fresh bucket — a real login brute-force bypass.
  - domain_not_allowed and account_exists_other_provider rendered as "please try
    again", advice that can never work.
  - malformed asserted addresses are refused rather than trimmed into shape.
  - dead config (microsoftTenantId defaulted to 'common', which the provider code now
    refuses) and the orphaned google-auth-library dependency removed.

1591 tests pass. Domain lifecycle verified end to end against a running server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
2026-08-10 19:23:46 -05:00
ScreenTinker d26aaebef6 SSO: fix an account takeover, a remote crash, and login CSRF found in review
Five reviewers went at the two SSO commits. Three of them independently
demonstrated a full account takeover, and it was the same defect each time.

TAKEOVER. An org admin supplies the issuer and client_id, so they control that
identity provider completely and can mint an id_token asserting ANY email with
email_verified:true — including a platform_admin's. Every cryptographic check
passed honestly, because the attacker IS the issuer. upsertFederatedUser then
re-pointed the existing account at whichever provider spoke last, because the
only guard was `password_hash IS NULL` — and every SSO-created account has a
null password. Sessions were issued as the victim, and the victim's own login
then failed forever with subject_mismatch.

The rule came from the old Google handler, where it was safe: only the operator
could add a provider. Making providers customer-configurable turned it into a
takeover primitive and the assumption was not re-examined. Now an org provider
may only assert emails inside the domains it registered, and may never adopt an
account another provider established.

REMOTE CRASH, unauthenticated. The state comparison guarded on UTF-16 character
length while Buffer.from produces UTF-8 bytes, so a state of 43 characters
containing one multi-byte character reached timingSafeEqual with mismatched
buffers and threw — inside an async handler, which Express does not catch, which
server.js turns into process.exit. One request per restart killed any instance
with SSO enabled. Compared as bytes now, and /api/auth/oidc gained a rate limit.

LOGIN CSRF. The callback returned the session token in the URL fragment, so a
crafted link installed an ATTACKER'S token and silently signed the victim into
their account. The token now goes in a one-shot httpOnly cookie exchanged at
POST /sso/claim, which a link cannot forge.

FRONTEND, dead on arrival twice over. login.js used `await` in a non-async
function — a SyntaxError that takes the WHOLE app down, since app.js imports it
statically and there is no bundler. And `esc` was never imported, so the org-SSO
button could never render; the ReferenceError was swallowed by the catch written
for network failures. Both slipped through because `node --check` parses these
files as CommonJS and exits 0 on a broken module. The correct check is
`node --input-type=module --check`, and all four frontend files now pass it.

PUBLIC EMAIL DOMAINS cannot be claimed. A tenant had claimed gmail.com in
review, after which every Gmail user typing their address was offered "sign in
with your organization" pointing at that tenant's infrastructure — phishing from
this product's own login page. server/lib/public-email-domains.js.

MICROSOFT multi-tenant is refused rather than silently broken. `common` metadata
advertises the literal template {tenantid}, so the issuer never matches and
every login already failed; and loosening that check is nOAuth. A tenant GUID is
now required, with a loud warning at boot.

SSRF: https only, loopback/RFC1918/link-local refused, redirects not followed,
and the test endpoint no longer echoes upstream status for a caller-supplied
jwks_uri (it was a readable internal port scanner).

Also: an omitted email_verified was accepted (the comment already said it should
not be); the domain-uniqueness check raced an 8s network call before its insert
and is now inside the transaction; same-org duplicate domains were allowed and
made routing depend on table-scan order; routing is now ordered; a client secret
that cannot be decrypted fails closed instead of silently downgrading to a public
client; SSO audit rows were writing the org id into the deviceId column; and
/sso/start was capped at 10/min per IP, which would 429 the 11th employee behind
a corporate NAT.

Adds per-provider editing in the org admin UI (replace-only secrets — never
returned, blank means keep, explicit clear) and a Test button that checks
discovery, endpoints and signing keys while stating plainly that it cannot
verify the client ID, the secret, or the redirect URI registration.

⚠️ STILL MISSING: domain-ownership verification. A claimed domain means "nobody
else had claimed it", not "they own it". DNS TXT proof is the remaining control.

1582 tests pass. New regression tests cover the takeover confinement, ordering,
fail-closed secrets, the Microsoft refusal and the public-domain blocklist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
2026-08-10 18:12:07 -05:00
ScreenTinker e97228a502 SSO: per-organization providers, configured by the customer
Instance-wide providers belong to whoever runs the server. These belong to a
CUSTOMER: an organization points ScreenTinker at its own identity provider from
Settings → Single sign-on, with no environment variable and no restart.

The login flow is unchanged. An org provider is resolved through the same
oidc-providers.get(slug) the env ones go through, so there is one authorization
request builder, one token exchange and one verifier — not a second, less
tested path for tenants. That seam is why Phase 1 put provider lookup behind a
single function.

⚠️ An org provider is NEVER published. It is not in /api/auth/providers, because
listing a customer's IdP would both offer it to people it does not belong to and
leak the customer list from the login page. It surfaces only when someone types
an address at one of that organization's domains; otherwise the instance-wide
buttons are what you get.

The discovery endpoint answers with a BOOLEAN and nothing else — no slug, no
display name. Returning "yes, Acme Corp SSO" would turn a guessed domain into
confirmation that Acme buys this product, and the slug would hand out a working
entry point to their tenant. POST /sso/start repeats the lookup server-side and
redirects, so the browser never learns which provider it is being sent to until
the provider says so, and the address travels in a body rather than in a URL
that lands in history, proxy logs and a Referer. Both endpoints rate limited to
10/min.

Other properties, each with a test:
  - slugs are RANDOM, not chosen, so two customers cannot collide on or guess
    each other's URL
  - a domain may be claimed by ONE organization; a second claim is refused, so a
    tenant cannot capture another company's logins
  - the issuer is verified by live discovery BEFORE the row is written, so a
    typo is caught at configuration rather than by a user staring at a failed
    login
  - client secrets are optional (PKCE), stored AES-256-GCM via lib/secretbox,
    never returned; an absent secret on update leaves the stored one alone,
    which is how a settings form that cannot show it avoids blanking it
  - cross-org access answers 404, not 403, so an outsider cannot confirm that an
    organization id exists
  - signing in through an org provider grants membership of that organization,
    but never changes an existing member's role

Verified live end to end: creation against a real issuer, domain normalisation
(`@Acme.CO.UK` → `acme.co.uk`), boolean-only discovery, a rejected domain
squat, a rejected bad issuer, and 404 for a foreign organization.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
2026-08-10 17:33:32 -05:00
ScreenTinker 252854d31e SSO: one OIDC flow for every provider, and verify the token properly
The OAuth support that was here could not work and would not have been safe if
it had.

It could not work: the login page called google.accounts.oauth2 and
new msal.PublicClientApplication, and NEITHER SDK WAS EVER LOADED by any page
in this app — no script tag, no dynamic import, nothing. Both buttons threw
ReferenceError on click. Even had they loaded, the CSP allows scripts only from
'self' and cloudflareinsights, and frames only from self and YouTube, so the
libraries and their popups were blocked too.

It would not have been safe: both endpoints authenticated with an ACCESS token
and neither checked who it was issued for. POST /auth/google fell back to
tokeninfo?access_token= and read the email out of the reply; POST
/auth/microsoft handed the bearer token to Graph /me and trusted that. Graph
and tokeninfo will both describe the user behind a token minted for SOMEBODY
ELSE'S application, so any site a user signed into that requested `email` or
`User.Read` could have replayed their token here and been issued a session as
them. Both endpoints are deleted; nothing is lost, because nothing could reach
them.

Replaced by ONE generic flow — Authorization Code + PKCE (S256), run
server-side, with the provider list resolved through a single function so
per-organization SSO can extend it later without a second login path. Google
and Microsoft become ordinary configured providers; Okta, Keycloak, Authentik,
Auth0 and anything else that speaks OIDC now work with three env vars.

Because the exchange happens server-side the browser never talks to the
provider, so there is no SDK to load, no client id in the page, and no
third-party origin needed in the CSP.

Identity comes from an ID token that must survive: signature against the
provider's JWKS (asymmetric algorithms only — alg:none and HMAC are refused
outright, the latter because the only key we hold is public), `iss` exactly as
discovered, `aud` and `azp` matching our client, `exp`, and a `nonce` this
server minted for that login. State is compared in constant time against a
value in an httpOnly SameSite=Lax cookie, so the callback is CSRF-protected and
survives a restart mid-login.

Account rules are the ones already in place: a verified email is required, an
SSO login never takes over an account that has a password, and a changed `sub`
for a known address is refused rather than handing the account to a recycled
mailbox.

18 new tests, every one describing something the old code would have accepted:
cross-audience tokens, azp mismatch, replayed nonces, alg:none, HMAC forgery,
wrong signing key, expired tokens, a discovery document lying about its issuer,
and a registry that never leaks a client id or secret to the browser.

Verified end to end against Google's real discovery document: the redirect
carries response_type=code, PKCE S256, state and nonce, and every callback
guard rejects as intended (no cookie, wrong state, no code, provider refusal,
unknown provider).

⚠️ TOTP is still not prompted on an SSO login, matching the documented
behaviour of the previous SSO and API-token paths. That is a product decision
and is left unchanged here rather than altered silently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
2026-08-10 17:19:28 -05:00
copilot-swe-agent[bot] f725186905
Add org-level widget sandbox isolation toggle with warnings
Co-authored-by: ChrisChrome <28414320+ChrisChrome@users.noreply.github.com>
2026-08-10 21:14:42 +00:00
ScreenTinker 28885d1a13 Merge branch 'feat/brightsign-ip-from-js'
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
# Conflicts:
#	server/test/device-controls-hidden.test.js
2026-08-10 15:40:31 -05:00
ScreenTinker 9e5d05aa33 Merge branch 'fix/brightsign-local-ip' 2026-08-10 15:38:56 -05:00
ScreenTinker 9b6f0856e0 Merge branch 'fix/pi-installer-245' 2026-08-10 15:38:56 -05:00
ScreenTinker 08b3d7d404 BrightSign: report IPv6, the attached display and the active video mode
Follow-on from the Node-stdlib work, guided by BrightSign's own dev-cookbook
rather than by guessing at module names.

IPv6 costs nothing extra — it comes from the same os.networkInterfaces() call
the v4 address does. The column, the API field and the dashboard card have all
existed since 1.9.29 and no player has ever filled them; the card is written to
appear ONLY when set, precisely so the overwhelmingly v4 fleet does not pay
screen space for an empty row. fe80:: is skipped for the same reason 169.254 is
— a link-local address is scoped to one interface and cannot be dialled from a
laptop across the office. A ULA is kept, because that one is reachable.

The attached display and video mode are new columns, and they answer the first
question anyone asks about a dark sign: which panel is that, and is the player
outputting at all. screen_width/height could not answer it — they are what the
PAGE believes it has, i.e. the widget's own geometry. Our XT245 drives a CX101
at 1920x1200@60 while the page reports its own canvas.

Per telemetry row rather than on `devices`, because a display can be swapped,
unplugged or renegotiated without the player re-registering.

MULTI-OUTPUT: the output is chosen by screen number, not hard-coded. A
dual-output player registers ONE DEVICE ROW PER OUTPUT (?screen=N →
output_index), so each row must report its own panel — otherwise a box driving
a lobby TV and a menu board shows the lobby TV twice. Both naming forms are
tried: probed on hardware, "hdmi" and "HDMI-1" both resolve to output 1, while
a second output that does not exist fails cleanly ("hdmi2" throws from the
constructor, "HDMI-2" rejects), so a single-output player reports nothing
rather than inventing a screen. That case has its own test.

Dashboard: two cards, shown only when the player reports them, like every other
card in that block.

Verified end to end on the real XT245 (FW 9.1.93.2) — attached_display=CX101,
video_mode=1920x1200@60, alongside local_ip 192.168.1.46, 119616 MB disk,
3656 MB RAM and live CPU.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
2026-08-10 15:30:40 -05:00
ScreenTinker def30e6d39 BrightSign: report the LAN address, real disk, memory and load
Every one of these fields existed in the schema, the API and the dashboard,
and every one was NULL or misleading on a BrightSign. The XT245 had 6000
consecutive telemetry rows with local_ip NULL while sitting at a perfectly
reachable 192.168.1.46, and reported "1026 MB" of storage for a 119 GB NVMe.

The host half (autorun.brs) does collect an address, but nothing the host
sends was arriving at all — proven by the storage figure, which was the
browser's cache quota rather than any disk. So the page has to read this
itself, which is also the half that can be delivered: st-bridge.js is served
per page load, while autorun.brs needs a release bump to reach a player.

It is Node's standard library, not a @brightsign module. The widget is created
with nodejs_enabled, so os and fs are simply there — this is what BrightSign's
own dev-cookbook does in html5-app-template (both the .ts and .js variants).

Looking for a platform module is the trap, and it cost most of a day:
@brightsign/networkconfiguration EXISTS but exposes only callback,
getNeighborInformation and enableLeds — no config reader. hostconfiguration
has getConfig()/applyConfig() but returns host settings (forwardingEnabled,
hostName, loginPassword, nameServers) with no address in them. Both enumerated
on the live player, because the JavaScript API doc pages 404 and BrightSign's
own roNetworkConfiguration page links to one of the dead URLs.
getCurrentConfig() is BrightScript-only.

  local_ip          os.networkInterfaces(), skipping internal and 169.254
  ram_total/free    os.totalmem() / os.freemem()
  cpu_usage         1-min load average / core count, as a clamped percentage
  uptime_seconds    os.uptime() — the MACHINE, overriding the page's own
                    performance.now(), so a widget rebuilt by the watchdog no
                    longer hides weeks of real uptime
  storage_*         fs.statfsSync over the mounts under /storage, largest wins
                    (ours boots from NVMe with a dead card slot; others from SD)

Dashboard: the RAM and CPU cards were gated on "is this Android?", which was
right when Android was the only family that could measure them. They now
render for any player that reports the value, so a BrightSign gets them and
Android is untouched — including keeping its "--" cards when no reading has
arrived, since an empty card is a known state and a missing one reads as
"cannot". The BrightSign storage card loses its "player storage" caveat,
because the number is now the disk it always claimed to be.

Verified on the real XT245 (FW 9.1.93.2): 116.8 GB free of 116.8 GB, 2.68 GB
of 3.57 GB RAM, 3% CPU, uptime tracking the machine, local_ip 192.168.1.46 —
matching the address found independently by MAC-vendor scan, and a disk figure
matching the kernel's own block count.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
2026-08-10 15:05:06 -05:00
ScreenTinker c2033b95fb BrightSign: find the LAN address on any interface, and say when there is none
The dashboard has a "Local IP" field, the server stores it, the bridge relays
it and autorun.brs collects it — the whole path has existed since 1.9.29. It
has never once produced a value for a BrightSign. Our XT245 has 6000 telemetry
rows with local_ip NULL while sitting on a healthy PoE network at
192.168.1.46, and every other field in the same payload arrives.

Confirmed against the device itself over its DWS: the installed autorun.brs is
ours (61055 bytes vs 61058 in tree) and contains this exact code, so it runs
and yields nothing. Interface 0 alone is not enough.

Now walks every interface the platform documents — 0/"eth0", "eth1",
1/"wlan0" — instead of assuming the first answers. The string forms are the
point: per the Object Reference an INTEGER interface "must currently exist on
the player; otherwise the object-creation function will return Invalid", while
the string names carry no such condition.

And when nothing answers it now says so on the host log. Silence is what made
this invisible for a whole fleet: the column stayed NULL and read as a
server-side gap rather than a player that never sent anything.

Not fixed blind — the first attempt at this used roDeviceInfo.GetIPAddrs(),
which is ROKU's API. BrightSign's roDeviceInfo has no network method of any
kind; the string does not occur once in the published Object Reference. It
would have raised "Member function not found" from inside SendHostTelemetry,
once a minute, forever — while ostensibly fixing telemetry. Caught by checking
the docs before shipping, and now added to the deny-list in
brightscript-api-surface.test.js so the next person cannot repeat it. Verified
the entry bites: injecting the call fails that suite.

⚠️ Untested on hardware. BrightScript has no interpreter outside a player, so
this is docs plus block-balance checking. The XT245 is reachable at
192.168.1.46 (DWS on 8080, not 80) to confirm once the package updates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
2026-08-10 11:47:23 -05:00