The changelog stopped at 1.9.2-patch2, so 23 shipped releases had no entry — including the whole
transition engine, group sync, the device-owner foundation, the hardening pass and every #234 fix.
Anyone deciding whether to upgrade, or working out which release changed a behaviour, had nothing to
read between 1.9.2 and now.
Written from the actual commit ranges between tags rather than from memory, and pitched at the
question a reader has ("do I need this, and what will change") rather than as a commit dump. Detail
scales with the release: 1.9.5 (group sync, device-owner foundation, agency folders) and 1.9.25 get
real explanation; 1.9.9 and 1.9.19 get two lines, because that is what they were.
The 1.9.16 hardening entry describes each fix in the same neutral terms as its commit — the
invariant restored, not the weakness. This is a public repository, some findings from that review
are still open, and exploitation detail helps nobody deciding whether to upgrade. The advice there
is just "upgrade".
Also adds a CHANGELOG check to bump-version.sh: it warns if the release being cut has no entry.
Deliberately a warning and not generation — a generated changelog is worse than none, since it reads
like documentation while saying nothing. This only stops a release being cut silently without one,
which is how the file fell 23 versions behind.
scripts/reset-admin.js mints a JWT carrying `recovery: true`, and middleware/auth.js
accepted that claim on its own with no database involvement. Three consequences:
- NOT REVOCABLE. The only way to invalidate an outstanding recovery token was to rotate
JWT_SECRET, which logs out every user on the instance.
- NOT ENUMERABLE. Nobody could answer "is a recovery token outstanding right now?"
- NOT AUDITED. The synthetic id ('recovery-<nonce>') is not a users row, so every
activity_log insert for it failed the user_id foreign key and was swallowed by a catch —
a break-glass session left no trace at all.
A `recovery_grants` row per minted token turns all three around: DELETE revokes, SELECT
enumerates, expires_at bounds, and used_at + source_ip record when and from where it was
first exercised. The migration is additive and idempotent, so re-running is a no-op and a
code-only rollback just leaves an unused table.
The grant is session-scoped, NOT single-use-per-request. Recovery means many requests —
load the dashboard, list users, reset a password — so consuming the grant on the first
would make break-glass unusable, a worse outcome than the narrow replay window it closes.
Revocation and expiry are the controls; used_at is the audit stamp.
Also fixed, because it is the mechanism that hid this: logActivity now rewrites a
'recovery-*' id to a NULL user_id with the identity in `details`, so break-glass actions
are actually recorded instead of failing the FK; and a dropped audit row now logs a loud
[AUDIT-DROP] line naming the action and increments a counter, rather than vanishing into
console.error.
The token is written to a 0600 file instead of stdout — under systemd or Docker, printing
it meant journald captured a live admin credential well past its lifetime. Added --list
and --revoke-all.
In-flight recovery tokens minted before this change stop working; they live one hour and
were unrevocable, which is the problem being fixed. Minting already required a working DB,
so redeeming against one is not a new dependency.
test/session-token-resolution.test.js now mints a real grant for its recovery token, so
its assertions keep testing that break-glass is refused on those surfaces for lack of a
users row — not for the unrelated new reason that the token is invalid.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous commit's `--exclude='.env.*'` also matched `.env.example`, silently
dropping the config template self-hosters need from the release tarball.
Exclude `.env*` wholesale, then explicitly re-add `.env.example` (tar cf / tar rf /
gzip), so a new secret file is excluded by DEFAULT and the one legitimate file is a
named exception rather than something a glob has to be written carefully enough to
spare. The credential audit now subtracts `.env.example` from its matches, and a
second check fails the upload outright if the template is absent - the regression
this commit fixes would have been caught by that check.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The complete-tarball step archives `server/` wholesale, and tar includes dotfiles,
so any gitignored local config living under it (.env, .mcp.json, key material) is
packaged unless excluded by name - gitignore has no effect on tar.
Two changes:
- Add excludes for .env / .env.* / .mcp.json and jks|keystore|pem|key|p12|pfx.
- Follow the tar with an audit that inspects what is actually IN the archive and
refuses to upload when anything credential-shaped is present.
The exclude list fails OPEN (a new file added under server/ ships unless someone
remembers to exclude it); the audit fails CLOSED, which is the property that
matters. .env.example is deliberately shipped and is not matched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
raspberry-pi-setup.sh bundled `chromium-browser` into the X11 apt-get install.
That package name only exists on Raspberry Pi OS / Ubuntu; on Debian it's
`chromium`, so the combined install hard-failed and `set -e` aborted the whole
installer on any Debian-based player.
Split Chromium out of the bundle into an install_chromium() helper that tries
`chromium-browser` then falls back to `chromium` (no-op if already present).
CHROMIUM_BIN detection already resolved either binary. Found by running the
installer end-to-end on a Debian arm64 VM (QEMU) standing in for a Pi.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* 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>
Since #168 android versionName/versionCode became env-overridable, the values in
build.gradle.kts live as fallback literals inside `?: "…"` at the end of each line, not
as `versionName = "X"` / `versionCode = N`. The old sed/grep found no versionCode digits,
so $((CODE + 1)) errored under set -e and the whole bump aborted after touching VERSION +
package.json. Retarget the trailing `?: "literal"`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The billing:read scope + dual-path gate were built but there was no way to MINT a token
(and it must NOT go in the workspace-scoped, self-service API-Tokens UI). Adds a server-side,
owner-only CLI — no new UI, no network endpoint. Owner-only BY CONSTRUCTION: it's a
host-side script, so filesystem/shell access = the platform owner.
- server/lib/billing-token.js (testable): mintBillingToken/revokeBillingToken/
listBillingTokens. Reuses the EXACT existing token path — same secret (st_ + 32 bytes
base64url), same SHA-256 hashing (hashToken), same api_tokens columns — no second format.
Resolves the platform OWNER (oldest platform_admin/superadmin; #14 collapsed superadmin ->
platform_admin so that's the top tier) and binds to their workspace. api_tokens.user_id +
workspace_id are BOTH NOT NULL (no platform-level token exists); the workspace binding is
VESTIGIAL for billing (billing:read is off-ladder -> can't reach any workspace router;
billing is platform-global), documented in-file rather than loosening NOT NULL pre-release.
- scripts/mint-billing-token.js: thin CLI wrapper. --name mints and prints the secret ONCE
(+ id, + "run as owner on host" warning), --list, --revoke <id> (soft revoke, mirrors the
dashboard DELETE).
Tests (4, test/billing-token-mint.test.js): minted row is scope EXACTLY billing:read with a
matching SHA-256 hash and no read/write/full/agency scope; the token reads GET
/api/billing/usage (200) but is refused on /api/devices (403) and /api/admin (401) — scope
isolation; revocation -> 401; mint requires a name; revoke refuses a non-billing id. CLI
smoked live (mint/list/revoke). Suite 310/310.
SPEC-vs-REALITY (again): spec said bcrypt + JSON `scopes`; this codebase uses SHA-256 + a
single `scope` TEXT column. Built to the real system.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a pre-push fast-forward check: fetch origin/main and abort if it has commits not in local HEAD, BEFORE the annotated tag is created. Prevents the beta9 incident where origin/main had advanced by one commit so 'git push origin main' was rejected, but the tag pushed anyway and fired release.yml from a commit not on main. Best-effort fetch — warns and proceeds when offline (the push stays the backstop).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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).
Two independent multi-zone bugs, plus operator-facing warnings, i18n, and
regression tests guarding the data contracts.
Bug 1 — per-item mute was a no-op end to end:
- GET /api/devices/:id dropped the `muted` column from its assignments SELECT,
so the dashboard toggle never reflected state (the muted=false case in
particular). Column restored to the device payload.
- Android player now honours the per-item mute flag for YouTube (initial state
+ live via the IFrame JS API).
Bug 2 — items whose zone_id belongs to a different layout were silently dropped:
- Player fallback (web + Android): an orphaned zone_id is recovered into the
largest zone instead of vanishing, with telemetry.
- server/lib/zone-validate.js is the single source of truth for the orphan rule
(zone not in the device's active layout); used by the device payload
(per-item `orphan` flag + `active_layout_zones`) and the device list
(`orphan_count`).
- Assign-time hardening: a stale zone_id (not in the device's active layout) is
cleared to null on POST/PUT rather than persisted as a new orphan.
- scripts/find-orphan-zone-items.js: read-only sweep for existing orphans.
Dashboard warnings (operator-facing, never on the live player):
- Per-item badge + reassign affordance, device-list glance, preview banner.
- Graceful degradation: the zone selector falls back to /api/layouts/:id so it
can't vanish on a stale payload.
i18n: orphan-zone strings added to en/es/fr/de/pt/it (hi falls back by design;
count strings interpolate through tn()).
Tests: server/test/device-zone-contract.test.js adds 5 regression tests for the
data contracts above (muted true/false round-trip, active_layout_zones, orphan
flag + count, orphan-clears-on-reassign, assign-time clearing). 172/172 pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The package.json + android versionName seds matched only a clean X.Y.Z, so bumping FROM a
pre-release (1.9.1-beta1 -> 1.9.1-beta2) left those two files stale while VERSION advanced -
an inconsistent release. Allow a [^"]* suffix so the current value's -beta1 is matched and
replaced. Still matches clean versions (regression-checked).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tagging a pre-release (e.g. v1.9.0-rc1) was unsafe. Four fixes:
1. bump-version.sh writes a numeric-only x.y.z to tizen/config.xml (strips a
-rc1/-beta.N suffix) so the .wgt still signs/installs; the full VERSION
(with the suffix) still drives server/Android/package.json.
2. release.yml flags the GitHub Release --prerelease for a -suffix version
(keeps it off "Latest" and out of the /releases/latest API).
3. release.yml moves docker :latest only for final releases - a pre-release no
longer repoints :latest onto untested code.
4. upgrade.sh excludes pre-release tags from its default selection - GNU
`sort -V` ranks 1.9.0-rc1 above the final 1.9.0, so the unfiltered default
would silently pick an RC (which then auto-OTAs to field kiosks). An explicit
`upgrade.sh v1.9.0-rc1` still works.
Verified the strip, tag selection, prerelease/tags logic, and YAML validity in
isolation.
Closes#80
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
bump-version.sh matched `<?xml version="1.0" ...?>` - the XML format version,
which has a leading space before version= just like the widget attribute - and
rewrote it to the app version, producing invalid XML that breaks the Tizen .wgt
build: 'XML version "X.Y.Z" is not supported, only XML 1.0 is supported'. (CI did
not catch it because the no-Tizen-CLI build path just zips the files without
validating the XML.)
- bump-version.sh: skip the `<?xml` declaration line in the tizen version sed.
- tizen/config.xml: restore the declaration to version="1.0" (prior bumps had
corrupted it to 1.8.2).
The widget version and tizen:application required_version are still updated /
left alone correctly (verified with a dummy bump + an XML parse).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- 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>
- release.yml: build the Tizen .wgt before the source tarball and bundle it in
(ScreenTinker.wgt at the tarball root). The signed Android APK is added by the
local finalize step (the keystore stays off CI).
- scripts/finalize-release.sh: after the release workflow publishes a tag, build
the signed APK locally, pull the CI-built unsigned .wgt from the release,
assemble a complete tarball (source + apk + wgt at the root, where /download/apk
resolves the apk after extraction), and upload the apk + complete tarball.
- .gitignore: ignore *.wgt and *.tar.gz so finalize temp files cannot be committed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- server/version.js: shared version helper that reads the root VERSION file once
(fallback 0.0.0). Replaces the stale hardcoded 1.2.0 / 1.5.1 / 1.0.0 fallbacks
in /api/version, /api/update/check, and /api/status.
- config.js: DATA_DIR / DB_PATH / UPLOADS_DIR / CERTS_DIR env overrides for the
db, uploads, and certs/jwt-secret locations. Unset resolves to exactly the
legacy in-repo paths, so existing installs (including production) are
byte-for-byte unchanged. Guarded by test/config-paths.test.js.
- package.json: rename remote-display-server -> screentinker (+ lockfile name).
- scripts/bump-version.sh: one-shot bump across VERSION, package.json (+lock),
android (versionName and versionCode + 1), and the tizen widget version; makes
one commit plus an annotated tag; prints the push command, never pushes.
- .gitignore: global *.db / *.db-wal / *.db-shm / *.db.* so no database file
(including .db.devbak backups, at any path) can be committed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.
Curl-pipe URLs, --help output, clone-and-run path, and the root-check
error message all referenced pi-setup.sh / setup.sh / screentinker/pi,
none of which exist. Point them all at the actual filename and path:
scripts/raspberry-pi-setup.sh.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Turns the Raspberry Pi script from a basic Chromium kiosk launcher
into a full installer with two modes:
- All-in-One: installs Node.js, clones the repo, runs the server
on port 3001, and launches the kiosk pointing at localhost. One
Pi does everything.
- Player-Only: connects to an existing server; same kiosk behavior
as before but with better Chromium flags and crash-flag cleanup.
Other changes:
- Detects Pi OS Lite vs Desktop and adjusts strategy (startx + vt1
for Lite, plain kiosk launcher for Desktop)
- Auto-login on tty1 for Lite installs
- GPU memory, overscan, console-blanking, and watchdog tweaks
- screentinker-{status,update,logs} management commands
- MOTD with command hints
- Cleans up the legacy remotedisplay.service / kiosk script on
upgrade so old installs migrate cleanly
- set -euo pipefail, root check, architecture check, tee'd log at
/var/log/screentinker-setup.log
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
scripts/reset-admin.js signed a JWT with a synthetic id ("recovery-XXX")
and instructed the operator to paste it into localStorage. But the
requireAuth middleware always SELECTs the user row by id, so every
authed API call under the recovery token returned 401 "User not found"
and the recovery flow was effectively dead.
Fix:
- reset-admin.js now sets a `recovery: true` claim on the JWT.
- requireAuth / optionalAuth short-circuit the DB lookup when
decoded.recovery === true and synthesize a req.user record in
memory (role: admin, plan_id: enterprise). The synthetic user is
never persisted, so FK-constrained writes that expect a real
user (creating devices, etc.) will still fail — which is fine,
recovery is only meant to let the operator reset a password or
create a fresh admin via the Settings UI.
Security: a recovery token still requires the jwtSecret to sign,
so only someone with filesystem access to the server can mint one.
Token TTL remains 1h.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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>