Commit graph

47 commits

Author SHA1 Message Date
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 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 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 240f107f17 README: nest the SSO subsections under their parent headings
They were ### under a #### parent, so both rendered as siblings of the section they
belong to rather than inside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
2026-08-10 22:24:42 -05:00
ScreenTinker 751d5343da README: document SSO-only, and correct two claims that stopped being true
The account-linking paragraph still described the rule that caused the account
takeover — "an account with no password is re-pointed at whichever provider
authenticated it" — which has not been true since the confinement fix. And the
discovery endpoint no longer answers with a bare boolean; it also says whether SSO is
required, which is what lets the login page hide the password field.

Adds the "Requiring single sign-on" section: what it does, that instance-wide
providers are refused too (a side door, not a convenience), that removal needs a
platform admin, why the approval email carries no link, why platform_admin is exempt,
and that the approval queue becomes an availability dependency.

Also states the resolution order plainly — instance-wide is the default, an
organization overrides only its own verified domains — and removes a duplicated
paragraph about claim expiry left over from an earlier edit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
2026-08-10 22:24:31 -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
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
ScreenTinker 0efbc6040e Pi installer: ask the operator, not the pipe; and stop assuming X11
Five defects from #245, all found by a user on a Pi 5 because nothing in this
repo has ever executed either of these scripts.

THE MENU IGNORED THE OPERATOR.

The documented install is `curl … | sudo bash`, which makes stdin the SCRIPT.
bash has consumed it by the time any `read` runs, so every prompt got EOF
instantly: the mode menu "chose" All-in-One without anyone touching it, and
Player-Only could not be reached that way at all. Reported as the menu being
skipped, because it was. Prompts now read the controlling terminal, and when
there genuinely is no terminal the script SAYS which way it went instead of
letting an empty answer look like a decision.

X11 TOOLS ON A WAYLAND PI.

Pi 5 on Bookworm defaults to Wayland, where xset, unclutter and xrandr are
no-ops that log an error and do nothing. The Pi therefore got no blanking
suppression and no cursor hiding while looking configured. The launcher now
detects the session and branches: X11 keeps what it had, Wayland gets wlopm and
--ozone-platform=wayland, and the compositor-side alternatives are documented
rather than silently assumed.

THE KEYRING PROMPT.

"Choose password for keyring" on every boot is Chromium reaching for
gnome-keyring. A kiosk has nobody to answer it. --password-store=basic.

THE WHITE PAGE ON EVERY BOOT BUT THE FIRST.

Chromium restoring a session it believes crashed — a kiosk is killed by
shutdown and never exits cleanly, so it returns with a restore surface over the
player. That is why ALT+F4 "fixed" it: it closed the surface, not the player.
Rewriting exited_cleanly was never enough on its own because the previous window
set is replayed from Sessions/, so that goes too.

THE BANNER SPELLED THE PRODUCT WRONG.

The ASCII art read "Scree Tinker" — the n was missing, and it is the first thing
anyone sees over SSH.

Also answered the reporter's Overlay FS question in the README, including the
part that bites: an All-in-One Pi IS the server, so an overlay discards the
database, uploads and JWT secret at every reboot. Safe for Player-Only; needs
DATA_DIR moved off the overlay otherwise.

The new test generates the kiosk launcher exactly as the installer writes it and
runs bash -n over it, because `bash -n` on the outer script cannot see inside a
heredoc — a syntax error in there is just text until it reaches a screen.

Reported-by: carloblu74
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS
2026-08-07 09:08:17 -05:00
ScreenTinker b00efa4f14 Merge main into thumbnail-backfill 2026-08-07 08:38:56 -05:00
Rob K 7896cccf4d Install ffmpeg in the Docker image and document it as a requirement
The runtime image never had ffmpeg, so every Docker deployment silently
lost video thumbnails and durations; the README never mentioned it for
bare-metal installs either.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0131RYmVh8ePEhparD3mXBhU
2026-08-07 09:30:32 +01:00
Rob K 7d7be365f9 Warn against proxy-level security headers in the README
helmet already sets X-Frame-Options, HSTS, CSP, etc., and manages them
per route (widget/kiosk renders and the device preview remove or relax
X-Frame-Options so they can be framed). A proxy-level header block adds
a second copy, and browsers treat conflicting duplicate X-Frame-Options
values as deny - which blanks the same-origin /player iframe behind the
dashboard's Preview button. Seen in the wild behind a Caddy config that
added X-Frame-Options: DENY on top of the app's SAMEORIGIN.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0131RYmVh8ePEhparD3mXBhU
2026-08-07 08:52:26 +01:00
ScreenTinker 88f2c63229 Add scripts/force-update.js — operator CLI to force a check on one display
Some checks failed
CI / Unit tests (node --test) (push) Has been cancelled
CI / OpenAPI spec lint (push) Has been cancelled
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Has been cancelled
CI / Boot smoke + version check (push) Has been cancelled
A display whose periodic update checker stops firing never pulls an APK on its
own, and a beta-channel opt-in alone doesn't reach it. The dashboard's force
button is the only lever that does, because the client's "update" handler calls
checkForUpdate(forced = true), which ignores both the backoff cap and the MDM
stand-down and hands the attempt budget back.

That command only exists over the /dashboard socket.io namespace, so there was
no way to send it from the server. socket.io-client isn't a dependency here, so
this speaks engine.io v4 directly over ws (reached out of server/node_modules,
same convention as reset-admin.js).

Owner-only by construction like mint-billing-token.js: no network endpoint, the
access control is shell access to the host. Resolves a display by id or unique
prefix, mints a short-lived platform_admin token, and reports whether the
command was delivered or queued for an offline display. --dry-run stops after
the namespace handshake so a rehearsal never puts an install dialog on a live
screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-01 16:19:33 -05:00
Claude b44f9d4f03 Serve a beta APK alongside the stable one, and let a display move between them
1.9.26's opt-in was passive: it stopped a sideloaded build being reverted, but there was still one
APK slot and latest_version was the server's own VERSION, so a beta had to be installed by hand on
every display. This makes it a real channel.

- apk-cache tracks two slots. ScreenTinker-beta.apk is optional and reaches only displays with
  ota_beta = 1.
- A beta must DECLARE its version in a sidecar ScreenTinker-beta.apk.version. The server cannot
  infer it — stable's version is the server's own constant because the two ship together, and
  reading it from the APK means parsing binary AndroidManifest.xml on the request path. If the
  sidecar is missing or unparseable the channel does not activate at all and opted-in displays keep
  getting stable. Failing closed matters: advertising a version that does not match the bytes served
  is the OTA-loop condition this fleet has been bitten by before.
- The check and the download resolve the channel identically and fall back to stable identically, so
  apk_size always describes the bytes actually delivered. No APK change was needed — the client
  already fetches whatever download_url it is handed, so displays in the field can be moved between
  channels from the dashboard today.

Switching back needed care. Stable is semver-OLDER than the beta it replaces, so the ordinary
"never offer a downgrade" rule stranded the display and unticking the box would have been another
silent no-op. The first attempt returned any non-opted-in display running a pre-release — which
broke a #144 test, correctly: that would have dragged every existing pre-release tester back to
stable the moment their server upgraded, the exact harm the opt-in exists to prevent. So the return
now requires evidence we actually served that display the beta channel (devices.ota_channel_served,
written once on change, not per check). A tester ahead of the server on their own build is left
alone exactly as before.

Documented in the README, including the constraint that makes the switch-back physically possible:
beta builds must carry a versionCode no higher than the stable they branch from, because Android
refuses to install a lower one. Equal numbers install in both directions.

Verified end to end against a live server with two real signed APKs: stable serves 1.9.26, beta
serves 1.9.27-rc1, an unknown channel falls back to stable, removing the version file deactivates
the channel, and the full opt-in -> serve -> switch-back lifecycle produces offer / up-to-date /
channel-return in order. 859 server tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 19:12:46 -05:00
Claude 0b9d9aff76 docs(readme): catch up on displays, OTA behaviour, plans and the public API
The README had drifted behind several shipped features and, worse, behind a few behaviours that
surprise people in practice. Everything here was verified against the code rather than written from
memory — three claims were wrong on the first pass and are corrected below.

Added:

- **Public REST API.** Scoped tokens, the OpenAPI contract and the browsable reference at /docs were
  not mentioned anywhere in the README despite being a shipped, documented surface.
- **When a display will not update itself.** The three things to check in order, and the retry model
  spelled out because "nothing is happening" is indistinguishable from "it gave up" otherwise:
  flagged for attention after 3 failed installs, still retrying to 40 (cheap — the APK is cached, so
  later attempts pull no bytes), then about one a day indefinitely, cleared by a new version. Plus
  what Force update overrides (back-off, attempt count and the MDM stand-down) and what it cannot
  (invent install permissions).
- **Deleting and re-pairing a display.** Settings are keyed to the hardware, so a re-paired panel
  returns configured — which reads as a bug when the old playlist reappears. Also documents that a
  block deliberately survives re-pair, and that Unblock is the way out (and that before 1.9.25 it
  only cleared half, so a display can still be stuck).
- **Plans and comped accounts.** The platform-admin plan overview, and how an inactive plan runs a
  comped/beta/legacy tier without appearing on the pricing page.
- **Optional location permission** for reporting the Wi-Fi network name, and that permission rows
  stay visible as Manage so grants can be reviewed or revoked.
- **One playlist per display**, and that Scheduling is how you rotate several — the question a
  customer asked this week.
- LAN and WAN addresses in the telemetry feature bullet; BrightSign in Supported Platforms.

Corrected while verifying:

- The API reference is served at /docs, not /api-docs.
- Tizen does NOT self-update; only the Android APK does. The two were wrongly lumped together.
- The admin section is labelled "Subscription Plans".
- The retry description conflated the flag threshold (3) with the attempt cap (40) — different
  numbers doing different jobs.
- BrightSign is listed with the caveat that its HTML widget may not survive the player's reload on
  deploy, rather than as unqualified support.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-29 22:33:48 -05:00
ScreenTinker c779d62d63 Add an operator override for self-update on MDM-managed panels
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.
2026-07-28 23:07:30 -05:00
ScreenTinker 0df7f58b26 Parse MAX_FILE_SIZE, and document what else caps an upload
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
2026-07-28 20:40:14 -05:00
ScreenTinker 2d4af97f67 docs: fix double-escaped < in API reference, add README hero image
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
- openapi.yaml: use the numeric entity &#60; so Redoc renders
  'read < write < full' in the scope-ladder nav + section header,
  instead of the double-escaped 'read &lt; write &lt; full'
- README: add a centered dashboard hero image + quick-links row
  (Live demo / API reference / Self-hosting guide / Discord) at the top,
  and refresh the Support section

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

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

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

Closes #173

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 15:56:22 -05:00
screentinker 2dc1d1279a
feat(seo): IndexNow + landing-page optimization (schema, FAQ, CWV, content) (#177)
* feat(seo): enable IndexNow (key file + submission script)

Instant re-crawl pings to Bing/Yandex/Seznam/Naver on content changes (Google ignores IndexNow
but uses the same sitemap). Hosts the ownership key at frontend/<key>.txt (served at
https://screentinker.com/<key>.txt) and adds scripts/indexnow-submit.sh which POSTs the sitemap
URLs to api.indexnow.org (DRY_RUN=1 to preview). Run after a content deploy / from CI.

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

* feat(seo): landing-page optimization pass (schema fixes, FAQ, content depth, CWV)

From a 3-way SEO audit (technical / structured-data / content). Highest-value fixes:

Structured data (penalty risk + rich results):
- REMOVE the fabricated aggregateRating (4.8/50) from SoftwareApplication — no visible reviews
  on the page = a "spammy structured markup" risk. Replace loose Offers with a proper AggregateOffer
  + publisher + image/screenshot.
- Add a VISIBLE FAQ section (10 Q&As) so the FAQPage schema finally has on-page content (it shipped
  4 Q&As with no visible counterpart — a mismatch); expand the FAQPage JSON-LD to mirror all 10.
- Add a WebSite entity block; add YouTube to Organization sameAs + a description.
- Fix the guides/compare BreadcrumbList position-2 target (dead /#features -> /).

Content / keywords / IA:
- Hero + Features subtitle rewritten to surface "digital signage software" / "digital signage CMS"
  / "self-host" / "free" above the fold.
- New "How It Works" (3-step) and "Use Cases / Industries" (8 verticals) sections for snippet +
  long-tail intent. Platform tiles (Android TV / Fire TV / Raspberry Pi) now link to their guides.
- FAQ answers add keyword-rich internal links to the guides + compare pages.

Technical / Core Web Vitals:
- Lazy-load the YouTube iframe (loading=lazy + youtube-nocookie + explicit width/height) — the top
  LCP/TBT win on mobile.
- Title 84->~60 chars (keyword-front), meta description ~178->~156 + CTA.
- <div> footer -> <footer> landmark; favicon sizes (192+512).
- sitemap.xml: add <lastmod> to all 10 URLs.

All JSON-LD validated (4 blocks parse; single H1; no fabricated data).

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

* feat(seo): open-source/what-is pillars, Xibo+Anthias compares, integrations hub [#177]

Competitor-SEO gap-fill from the Yodeck/ScreenCloud/OptiSigns/Xibo teardowns.
11 new static pages + sitemap/landing/README wiring. All match the existing
seo-page.css template; BreadcrumbList on every page, FAQPage (visible-backed)
on guides + integration spokes. No fabricated ratings.

Pillar guides:
- guides/open-source-digital-signage.html  (head term "open source digital signage")
- guides/what-is-digital-signage.html       (TOFU definitional pillar + FAQ)

Comparisons (the open-source SERP Xibo/Anthias own):
- compare/xibo-alternative.html    (wedge: every ScreenTinker player free vs Xibo's paid Android/Tizen/webOS licences; no free plan)
- compare/anthias-alternative.html (wedge: fleet + video walls + multi-platform vs one-Pi-one-screen)

Integrations (OptiSigns' top tactic — one page per app):
- integrations/index.html hub
- google-slides / canva / power-bi  (honestly framed as the universal Webpage widget, with the X-Frame-Options / publish-vs-edit-URL caveat + Power BI public-data warning)
- youtube / rss / weather           (native widgets)

Wiring:
- sitemap.xml +11 URLs (lastmod 2026-07-13)
- landing.html Resources grid: 6 new cards (open-source, what-is, vs Xibo, vs Anthias, integrations hub)
- README.md: keyword-rich open-source/self-hosted intro + platform list + guide links (GitHub-SERP asset)
- docs/seo-directory-listings.md: off-repo G2/Capterra/AlternativeTo/awesome-selfhosted/fingoweb submission checklist + reusable kit

Validated: all JSON-LD parses, canonicals match paths, 0 broken internal links, sitemap well-formed (21 URLs).

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 11:22:37 -05:00
Christopher Cookman 01e5b10f53
feat(setup): Debian 13 player/server install script (#137)
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
Community contribution from @ChrisChrome (tested on Debian 13 headless). Adds scripts/debian-13-setup.sh — server/player/both modes, systemd units, kiosk autologin, and management scripts (status/update/logs) — modeled on the Raspberry Pi setup. Also fixes Chromium fullscreen by detecting screen resolution at runtime (replacing --start-fullscreen), applied to both the Debian and Pi scripts, plus a README entry.

Maintainer review fix: the kiosk wait-loop now polls /api/status (the server's real readiness endpoint) instead of the non-existent /api/health, which had been silently burning the ~120s timeout on every all-in-one boot (bug inherited from the Pi script, fixed in both).
2026-06-23 23:47:22 -05:00
ScreenTinker 674a34ba45 feat(config): HIDE_BILLING flag to hide the Subscription/billing UI (#116)
Opt-in, default-off UI gate (per strobe's spec; verified his file refs first).
When set, hides the Subscription sidebar item + billing view and bounces
#/billing to the dashboard. Billing shown by default -> existing deployments
unchanged. UI-only: /api/subscription/* untouched (internal usage reads stay).

- config.js: config.hideBilling from HIDE_BILLING (mirrors selfHosted).
- auth.js: surface hide_billing on GET /api/auth/me (client already fetches it
  at boot, stored on the user object).
- index.html: id="billingNavItem" on the Subscription <li> (mirrors adminNavItem).
- app.js: toggle billingNavItem in updateSidebarUser (next to the admin toggle);
  guard #/billing -> history.replaceState('#/') + render dashboard (replaceState
  so the back button doesn't loop into the guard).
- .env.example + README documented.

Spec assumptions verified against code: adminNavItem toggle pattern exists;
/me is fetched at boot and updateSidebarUser runs both at boot (cached user)
and post-/me, so no-flash holds on warm loads (one-time flash possible on the
first load after the flag flips — same as the admin nav, minor); route dispatch
is an if/else chain. Nav label is static (no data-i18n) so no i18n change.

Validated (headless Chrome, both states):
- flag unset -> Subscription tab present, #/billing renders (backward-compat).
- HIDE_BILLING=true -> tab hidden, #/billing redirects to #/.
- config maps HIDE_BILLING both ways; live /me default hide_billing=false.
- 149 server tests green. Default-off = zero change for existing deployments.

Known cosmetic (harmless): after the redirect the billing nav LINK keeps its
'active' class, but the nav item is display:none so it's never visible.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 09:19:24 -05:00
ScreenTinker 22376710ee fix(android): re-sign release APK with v1 (JAR) signature for MDM signage (#81)
minSdk 26 makes AGP default the v1 (JAR) signature off, so the release APK is
v2-only. Some MDM-managed commercial signage (MAXHUB via the Pivot MDM) silently
removes a v2-only app on the next reboot because its boot integrity check expects
a v1 signature — screens that power-cycle nightly lose the app and fall back to
the setup screen.

`enableV1Signing = true` has no effect at minSdk >= 24 (verified: still v2-only).
Instead, finalize assembleRelease with a `resignReleaseV1` task that re-signs via
apksigner with --v1-signing-enabled true and a low --min-sdk-version, emitting v1
alongside v2/v3. Verified: v1+v2+v3 at min-sdk 19, verifies at API 36, and the
re-signed APK installs and runs on a live API 36 emulator.

Closes #81

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 21:10:04 -05:00
ScreenTinker 3ddc209d19 docs(readme): add Wise donation link to Support section
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 20:37:07 -05:00
ScreenTinker 5530d6cfcd docs: tag-based upgrade flow + upgrade.sh, Tizen install paths
- scripts/upgrade.sh: upgrade a self-hosted instance to a tagged release
  (default latest). Backs up the db (.backup), checks out the tag, npm ci
  --omit=dev, restarts the service (SERVICE_NAME override), reports the version.
- README: replace the git-pull update flow with scripts/upgrade.sh (latest or a
  pinned tag); keep main as the bleeding-edge option. Add a Samsung Tizen entry
  to device setup (URL Launcher -> /player).
- tizen/README: point path A at the server's built-in /player, and explain why
  the released .wgt is unsigned (Samsung distributor certs are DUID-locked).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 14:12:29 -05:00
ScreenTinker 3ac81a4206 feat(ops): nightly backup script with point-in-time content history
Adds scripts/backup.sh — atomic SQLite .backup + hard-linked point-in-time
content snapshots, daily (7) + monthly (12) retention, and an error log.
Env-configurable (SCREENTINKER_DIR/BACKUP_DIR/DB/UPLOADS/*_KEEP*) so any
self-hoster can use it; defaults target a /opt/screentinker install.

Hardens two real failure modes found in production:
- Content snapshots EXCLUDE uploads/screenshots/ and use rsync --link-dest
  instead of cp -al. The per-device *_latest.jpg screenshots are rewritten
  24/7; cp -al aborts when a file mutates mid-copy and the prior script
  swallowed the error with 2>/dev/null, silently breaking content snapshots
  for ~8 weeks. rsync --link-dest hard-links unchanged files but tolerates
  in-flight changes; errors now go to backup.log.
- Retention sorts by NAME, not mtime: rsync -a / cp -al preserve the source
  dir's (frozen) mtime, so ls -dt treated fresh snapshots as oldest and pruned
  them. The timestamp is in the dir name, so name-sort is chronological.

README Backups section documents the cron setup + env knobs. Verified on prod.
2026-06-09 19:53:09 -05:00
ScreenTinker 1a4397ad24 docs: local AI setup guide for the Content Designer (#41)
How to run the AI design feature fully local + free: Ollama (OpenAI-compatible
LLM) for text/layout and stable-diffusion.cpp (Vulkan) for images, plus the
SELF_HOSTED requirement for localhost endpoints, an OpenAI fallback, and GPU
troubleshooting (incl. the Blackwell CUDA-fails/Vulkan-works note). Linked from
the README integrations section.
2026-06-09 13:57:02 -05:00
ScreenTinker 406f481a57 docs: surface DISABLE_REGISTRATION self-hosting flag (#11)
DISABLE_REGISTRATION already closes public self-service signup (first-user
setup on an empty DB still allowed) and the login page already hides its
"Create account" button when it's set - but the flag was easy to miss: it was
in the README env-var table yet absent from .env.example (the file
self-hosters actually copy) and from the README systemd unit example.

- .env.example: document DISABLE_REGISTRATION + DISABLE_HOMEPAGE under the
  Self-hosting section.
- README: add commented Environment= lines for both to the systemd example,
  noting the login UI hides the signup button to match.

Docs only - no code change. Backend gate (routes/auth.js canRegister +
/auth/config registration_enabled) and the login.js hiding already behave
correctly; verified registration_enabled flips to false under the flag.

Closes #11.
2026-06-08 10:29:08 -05:00
ScreenTinker 0fec335e75 docs: add Android player troubleshooting & recovery guide
Covers the "Connecting to server" / xhr-poll-error hang (stale server URL,
fixed via Clear data + re-provision), and adb-over-Wi-Fi setup including the
gotchas: must be on the same subnet, and never `adb root` over a wireless
connection (it wedges adbd until reboot). Linked from the README Device Setup
section.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 19:58:09 -05:00
ScreenTinker 159a36ed99 fix(workspaces): use APP_URL env var for invite-accept URL generation
Slice 1+3 (c4fbd2b) introduced PUBLIC_URL as the env var name for the
public-facing origin used to construct invite-accept URLs. The README
has long documented APP_URL as the canonical name for this concept
(used for Stripe callbacks in the existing codebase). The new code
should have read APP_URL from the start; PUBLIC_URL was unintentional
naming drift.

Caught during prod-deploy survey on 2026-05-17: APP_URL was set on the
production systemd unit and documented in the README, but read by no
code path on origin/main. PUBLIC_URL was read by slice-1 code but set
nowhere. The bug was masked in 99% of cases by the request-derived
fallback (${req.protocol}://${req.get('host')}) which produces the
correct URL when invites are triggered from browsers behind Cloudflare.
It would have manifested for any future non-browser-triggered invite
path.

README updated to note APP_URL covers both Stripe callbacks and
invite-accept URL generation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 15:26:07 -05:00
ScreenTinker 742d8c4b09 feat(socket): delivery queue for offline-device emits
Short-lived per-device queue covers the TV-flap window (issue #3):
when a device is mid-reconnect, prior code emitted to an empty room
and the event vanished. Now playlist-updates and commands targeting
an offline device are queued and flushed in order on the next
device:register for that device_id.

server/lib/command-queue.js (new):
- pendingPlaylistUpdate: per-device marker (rebuild via builder on
  flush -> always fresh DB state, no stale snapshots)
- pendingCommands: per-device Map<type, payload> with last-of-type
  dedup (most recent screen_off wins)
- TTL via COMMAND_QUEUE_TTL_MS env (default 30000)
- Active sweep every 30s prunes expired entries

Memory bounds: ~6 entries per device worst case (1 playlist marker
+ 5 command types), unref'd sweep timer.

Wired emit sites (8 total; the four direct socket.emit calls in
deviceSocket register handlers are intentionally NOT queued because
the socket is alive by definition at those points):
- server/routes/video-walls.js   (pushWallPayloadToDevice)
- server/routes/device-groups.js (pushPlaylistToDevice)
- server/routes/content.js       (content-delete fan-out)
- server/routes/playlists.js     (pushToDevices + assign)
- server/services/scheduler.js   (scheduled rotations)
- server/ws/deviceSocket.js x2   (wall leader reclaim/reassign)

server/ws/deviceSocket.js register paths now call flushQueue after
heartbeat.registerConnection + socket.join. Existing
socket.emit('device:playlist-update', ...) lines kept - they send
the initial state on register; the flush replays any queued events.
Player's handlePlaylistUpdate fingerprint check dedupes the
overlap.

Refs #3

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:06:43 -05:00
ScreenTinker 3da49ec79c chore(config): env-configurable heartbeat timing
Make HEARTBEAT_INTERVAL and HEARTBEAT_TIMEOUT env-tunable so
self-hosters with slow/jittery networks don't have to edit
config.js (issue #3 reporter did exactly this to confirm the
diagnosis). Defaults unchanged at 10000ms / 45000ms so existing
deployments keep current behavior.

Same parseInt(env) || default pattern as PORT/HTTPS_PORT/PING_*.
README env table extended.

Refs #3

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:03:02 -05:00
ScreenTinker 1aee4f2d5b fix(socket): raise Engine.IO ping/pong + prefer WebSocket transport
Connection-stability layer for issue #3. LG webOS WebKit (and other
TV-grade clients) miss Engine.IO pongs under decode load with the
Socket.IO defaults of 25s ping / 20s timeout, causing spurious
transport drops and a connect/reconnect/evict/disconnect loop on
the device. Default polling-first transport adds another fragility
layer via the polling->WebSocket upgrade dance.

- pingInterval / pingTimeout default to 30000 / 30000 (worst-case
  dead-socket detection 60s, up from ~45s). Both env-configurable
  via PING_INTERVAL / PING_TIMEOUT.
- Player Socket.IO client: transports: ['websocket', 'polling'].
  Tries WebSocket first; falls back to polling on the same connect
  attempt if WebSocket fails. Polling fallback preserved for
  firewall-restricted networks.

App-level heartbeat checker is unchanged and remains the safety net
for clients that miss the transport-level ping/pong window.

Tradeoffs documented in inline comments. README env table extended
with PING_INTERVAL and PING_TIMEOUT rows.

Refs #3

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:02:34 -05:00
ScreenTinker c4ac81c7a6 chore(discord): update Discord invite link
Old invite replaced with current permanent invite across README,
landing page, and anywhere else it appeared.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:26:20 -05:00
ScreenTinker 3dfec5d2f9 feat(config): DISABLE_HOMEPAGE env var to redirect / to the app
Self-hosters running internal-only deployments don't need the
marketing homepage. With DISABLE_HOMEPAGE=true, requests to /
302-redirect to /app instead of serving the landing page.
Unset/false preserves current behavior.

Requested via discord feedback.
2026-05-14 12:03:29 -05:00
ScreenTinker 4b2a5c51ea docs(readme): comprehensive review - multi-tenancy, current features, tech stack, deployment, contribution
The repo has been shipping multiple features ahead of the README (12+
commits today alone). This is a catch-up pass to bring the docs current.

Key additions / updates:
- Multi-tenancy architecture (orgs > workspaces > members + roles)
- Auto-migration on boot
- Teams currently consolidated into workspace_members
- Tech stack reference (Node 20.6+, msal-node, etc.)
- Deployment env vars (full reference table)
- Local dev setup with .env approach
- Contribution/Discord/issue reporting

No code changes - docs only.
2026-05-12 18:57:41 -05:00
ScreenTinker dddc48440b docs(readme): document Microsoft Graph email setup + dev restrict + spam protections
Replaces the stub EMAIL_WEBHOOK_URL row with the real 5-variable
GRAPH_* config table, Azure AD app registration steps (single-tenant
+ Mail.Send application permission + admin consent), the local-dev
stdout-fallback behavior when unconfigured, the optional
GRAPH_DEV_RESTRICT_TO allow-list for safe development against fresh
prod DB clones, and a brief enumeration of the alert spam protections
(2h dedup, 24h long-offline cutoff, sequential send pattern, per-user
email_alerts opt-out).

Pairs with c71c401 which shipped the implementation.
2026-05-12 18:34:08 -05:00
ScreenTinker 846d61a1b0 Add Discord link and refresh feature copy
- README + landing page footer now link to the community Discord
- Landing page feature grid gains Playlists, Directory Board,
  Offline Resilience, and Mobile Dashboard cards; Scheduling and
  Self-Hosted copy updated to mention group-level schedules and
  the DISABLE_REGISTRATION env var
- Structured data featureList expanded to match; Organization
  sameAs now includes Discord
- README feature list clarifies scheduling precedence, mobile
  responsiveness scope, and the auth/IDOR/XSS audit work

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 17:47:00 -05:00
ScreenTinker 4392bb460a Add DISABLE_REGISTRATION env var to block public sign-ups
When DISABLE_REGISTRATION=true (or 1), POST /api/auth/register returns
403 with a clear error. OAuth endpoints (/google, /microsoft) also
refuse to auto-create new accounts — existing OAuth users can still
sign in. First-user setup (empty users table) is always allowed so a
fresh install can still be initialized.

GET /api/auth/config now returns registration_enabled so the login
view can hide the "Create Account" button and the trial banner when
registration is off. Absence of the flag is treated as enabled for
back-compat with older servers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 19:35:32 -05:00
ScreenTinker ea86d70475 README: update feature list to reflect current capabilities
- Playlists: draft/publish workflow with revert
- Device groups: group playlist assignment and group scheduling
- Scheduling: priority-based conflict resolution, group-level schedules,
  device-level overrides
- Widgets: replace "Content designer" line; list all widget types
  including Directory Board
- Offline resilience: Android ContentCache + web player service worker
- Mobile-responsive dashboard
- Account management: password change, profile, email reset
- Security: JWT, rate limiting, ownership checks, XSS/IDOR audits

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 22:39:07 -05:00
ScreenTinker 1cf6b93512 Update README with Phase 2 features and recent additions
- Playlists as first-class objects (create, share, reorder, per-item duration)
- Device groups with bulk actions (content assign, playlist assign, commands)
- Scheduling now supports playlist overrides, priorities, timezones
- Export/import v2 format with playlists and backward-compatible v1 migration
- Device token authentication for WebSocket connections
- Device telemetry (battery, storage, RAM, CPU, WiFi, uptime)
- Activity log audit trail
- Content management details (folders, remote URLs, ffprobe, thumbnails)
- HTTPS_PORT env var and SSL auto-detection
- Updated project structure section
- Plan description now matches schema.sql defaults (not hosted instance)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 23:20:55 -05:00
ScreenTinker 4ae7533b85 Hide unclaimed devices from dashboard, add unassigned API, add upgrade docs
- Filter out devices with no user_id from the main device listing
- Add GET /api/devices/unassigned endpoint (admin only) for unclaimed devices
- Add "Updating" section to README with upgrade instructions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 13:13:46 -05:00
ScreenTinker d67dd41056 Add YouTube and Export/Import to README features list
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 12:21:05 -05:00
ScreenTinker 1594a9d4a4 Initial open source release
ScreenTinker - open source digital signage management software.
MIT License, all features included, no license gates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 12:14:53 -05:00