screentinker/server/test/brightsign-package.test.js
screentinker 9a1a82a100
Run the ScreenTinker server on the player it serves (#288)
* Make a BrightSign say what it is running, and what it is plugged into

A panel on a wall could not answer three questions an operator asks first:
which version am I, which page am I running, and which screen is that. All
three had answers already travelling over the socket; nothing was reading them.

VERSION. device_info.app_version was the literal '1.1.0-web' for every web
player, BrightSign included — the same string as PLAYER_VERSION, which already
travels separately as client_version. So the column carried no information at
all: a panel provisioned this morning and one running a year-old host reported
identically. app_version is now the ON-DEVICE host package, the artifact OTA
replaces and the only one here that can be stale, and PLAYER_VERSION is stamped
at serve time from VERSION rather than being a constant nobody bumped for the
whole 1.x line. No '-web' suffix: client_version is only compared for equality
today, but X.Y.Z-web is a semver PRERELEASE that sorts BELOW X.Y.Z, and this
project has been bitten by exactly that before.

The host version arrives asynchronously and can land after the page registers,
so register sends what it has and the heartbeat corrects the record — which also
catches the version changing under a live page, which is what a self-update is.

THE CARD SHOWED FOR NOBODY. The Info tab's version card sat inside the block
gated on android_version && !startsWith('Web/'). A BrightSign registers as
"Web/<ua>", so the panel that most needed a version never displayed one.

THE PAD THAT COULD NOT BE CLICKED. System View was gated on tier === 2. tier is
an Android device-owner concept, NOT NULL DEFAULT 0, written only by the APK —
so a BrightSign or Tizen panel sat at 0 forever and rendered HOME, BACK, POWER,
the D-pad and OK permanently pointer-events:none, for keys those players
genuinely handle. Greying an Android gate over a working control is the "button
that cannot work" the capability system exists to prevent, inverted. Only
Recents (KEYCODE_APP_SWITCH) and Settings are truly Android-only; those are now
the only things hidden.

THE PACKAGE POINTED AT THE WRONG SERVER. autorun.zip carried the committed
default, so a player self-updating from alpha or a self-hosted box was handed a
config pointing at screentinker.com — which surfaces as a pairing bug, miles
from the packaging code that caused it. It is now stamped with the URL it was
fetched from. The bytes therefore vary per origin, so the cache is keyed by
origin and BOTH routes derive it identically: the manifest checksum and the
served bytes must come from one buffer or every player downloads, fails
verification and retries forever.

EDID. getEdidIdentity() answers seven questions and cannot answer any others —
manufacturer, EDID version, physical size, gamma and the mode lists exist only
in the raw block, which getEdid() returns as 2048 bytes. The player ships those
on the register (identity, not a reading: it changes when someone swaps the
screen) and the SERVER parses them. That split is the point: a new field becomes
a server deploy instead of a bridge update behind a 4h CDN plus an OTA for the
host. Verified against real hardware — an XT245 with a CX101 decodes to RTK /
0x1010 / serial 1 / 2020w26 / 22x13cm, preferred 1920x1200@62, matching the
player's own DWS field for field. The odd-looking 62 is right: 168.5MHz over
2200 x 1245 is 61.5Hz, and rounding it to a nicer 60 would contradict the panel.

Also corrects two comments that had outgrown their reasoning: the BrightSign
capability baseline still explained its exclusions with "a canvas cannot read
the video plane", which native capture made obsolete, and player-parity.md
claimed the bridge is "always current" when a zone-wide Cloudflare Browser Cache
TTL had been rewriting its no-cache to max-age=14400 for months.

Every new guard is mutation-tested — the fix was reverted in the source and each
test confirmed to fail. 1676 -> 1714 tests, all green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014kfhrUPit5MCqxeTQyqr56

* Run the ScreenTinker server on the player it serves

A BrightSign XT245 now downloads, installs and runs the server itself, with
the display showing what it is doing until it is up.

WHY IT NEEDED A NEW SHAPE

BrightSignOS cannot open a large autorun.zip. The 73MB build failed at boot
with "ZipArchive error at line 91", and the OS renamed it autorun.zip_invalid
- which is how a device that had already unpacked once came back up with no
autorun at all. The identical package cut to 32KB and five files boots fine;
paths (182 chars) and depth (8) are unremarkable, so the limit is in the
boot-time reader, not the archive. BrightSign's own notes acknowledge package
size as a problem and point at webpack; that route needs the dynamic requires
in scripts/ removed first, so instead autorun.zip carries only what starts the
process and the payload arrives over HTTP into a Node that has no such limit.
The payload can also be updated without re-provisioning the device.

WHY roNodeJs AND NOT THE WIDGET

The first version ran the server inside an roHtmlWidget with nodejs_enabled.
That is a Node context inside an Electron renderer, and it is not Node. Four
separate boot failures came out of it, each invisible to a local test because
a local test runs on real Node:

  - shebangs are not stripped, so any `#!/usr/bin/env node` file dies with
    "Failed to construct 'ContextifyScript': Invalid or unexpected token".
    Note it names no token - "#" is not one. An ESM file compiled as CJS says
    "Unexpected token 'export'" instead, which is how the two are told apart.
  - require() of an ESM-only package is unsupported, which plain Node 24
    handles. uuid 14 is ESM-only and 21 files import it.
  - setInterval is the DOM's and returns a NUMBER, so setInterval(...).unref()
    throws. Two call sites were unguarded; sixteen more were written
    defensively and had been silently not unreffing.
  - worker_threads cannot create a thread at all.

BrightSign's dev-cookbook is explicit: roNodeJs "for long running processes
like ... running a web server", roHtmlWidget "for browser-based apps". Their
cra-template examples do exactly this - server in roNodeJs, widget pointed at
localhost. It also fixes the lifecycle problem that was the original argument
against a server on this hardware: in a widget the server dies with the page,
taking an open SQLite WAL with it.

The shims for the first three are kept in the packager for now rather than
removed in the same change that moves the container, so that if something
breaks it is the move and not four simultaneous removals.

CHANGES THAT ARE NOT BRIGHTSIGN-SPECIFIC

  db/database.js, routes/status.js  fs.copyFileSync does not merely copy
    bytes: it fchmods the destination to match the source. exFAT has no
    permission bits, so the pre-migration snapshot failed with EPERM and the
    failure path called process.exit(1) - which inside a widget also killed
    the page, leaving a black screen and no diagnostic. The guard was right;
    the copy was wrong. lib/fsutil.js copies without touching mode.

  db/wal-checkpointer.js  the module already degraded correctly when its
    worker died or could not be respawned, but the FIRST spawn was not
    wrapped, so a host that cannot make threads lost the whole server rather
    than falling back to inline autocheckpoint.

  db/sqlite-compat.js  a better-sqlite3 facade over node:sqlite. With it the
    bundle contains no native code at all, which is what lets an x86_64
    laptop build a package for an aarch64 player. 1719/1719 tests pass on
    Node 24 through this shim.

The packager refuses to build if a source file is untracked (git ls-files
decides what ships, and lib/fsutil.js reached a player without shipping
alongside the code that required it), if any .node binary is present, if a
shebang survives, or if a database, upload, cert or .env is staged - the first
build of this package swept up a real 33MB database and 105MB of uploads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014kfhrUPit5MCqxeTQyqr56

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Dan Walters <dan.walters@bytetinker.net>
2026-08-18 15:16:09 -05:00

229 lines
12 KiB
JavaScript

'use strict';
// The manifest and the download must describe the SAME bytes.
//
// Advertising a version whose checksum does not match the file actually served is the classic
// OTA-loop condition: the player downloads, fails verification, retries, forever. It is also the
// easiest mistake to make, because the natural implementation computes the manifest from one source
// (a VERSION file, a build record) and serves the file from another (a path on disk that some
// deploy replaced). These tests pin the invariant that makes that impossible here: one buffer,
// hashed once, read by both routes.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const crypto = require('node:crypto');
const pkgLib = require('../lib/brightsign-package');
test('THE OTA LOOP: the advertised checksum is the hash of the bytes that are served', async () => {
const pkg = await pkgLib.getPackage();
assert.ok(pkg, 'package should build from brightsign/');
// sha256 because that is what BrightScript's roMessageDigest can compute — a checksum the player
// cannot verify is an unverifiable package.
const actual = crypto.createHash('sha256').update(pkg.buffer).digest('hex');
assert.equal(pkg.sha256, actual, 'a mismatch here loops every player in the fleet');
assert.equal(pkg.size, pkg.buffer.length);
});
test('the package is byte-identical on rebuild — otherwise every deploy re-flashes the fleet', async () => {
// Zip entries carry timestamps. Left at "now" the archive changes on every server restart, the
// checksum changes with it, and every player decides it has an update waiting.
const first = await pkgLib.getPackage();
pkgLib._reset();
const second = await pkgLib.getPackage();
assert.equal(second.sha1, first.sha1);
});
test('the archive contains exactly the payload, at its ROOT with no wrapper directory', async () => {
// A player extracts to the storage root. A wrapper folder puts autorun.brs where the player never
// looks and the card silently does nothing — the failure mode is "blank screen", not an error.
const pkg = await pkgLib.getPackage();
const names = [];
// Minimal central-directory walk: entry names follow the 0x02014b50 signature at offset +46.
const buf = pkg.buffer;
for (let i = 0; i < buf.length - 4; i++) {
if (buf.readUInt32LE(i) === 0x02014b50) {
const nameLen = buf.readUInt16LE(i + 28);
names.push(buf.slice(i + 46, i + 46 + nameLen).toString('utf8'));
}
}
assert.deepEqual(names.sort(), pkgLib.PACKAGE_FILES.slice().sort());
for (const n of names) {
assert.ok(!n.includes('/'), `${n} must be at the archive root, not nested`);
}
});
test('autorun.brs and autozip.brs are both present — either missing is a dead panel', async () => {
// autorun.brs missing: nothing to run after extraction.
// autozip.brs missing: nothing extracts the archive in the first place.
assert.ok(pkgLib.PACKAGE_FILES.includes('autorun.brs'));
assert.ok(pkgLib.PACKAGE_FILES.includes('autozip.brs'));
});
test('THE BACK-DOOR LOOP: the shipped autorun.brs reports the version the manifest advertises', async () => {
// Ship it unstamped and the player applies the update, still reports the old version, and is
// offered the same package on every check — forever. The loop arrives even though the checksum
// was correct and the download was clean.
const unzipper = require('unzipper');
const pkg = await pkgLib.getPackage();
const dir = await unzipper.Open.buffer(pkg.buffer);
const entry = dir.files.find((f) => f.path === 'autorun.brs');
assert.ok(entry, 'autorun.brs must be in the package');
const text = (await entry.buffer()).toString('utf8');
const m = text.match(/return "([^"]*)"\s*' ST_PACKAGE_VERSION/);
assert.ok(m, 'the ST_PACKAGE_VERSION marker must survive — it is what the stamp anchors on');
assert.equal(m[1], pkg.version, 'stamped version must equal the advertised version');
});
test('the version comes from VERSION, so the manifest matches the release it shipped with', async () => {
const fs = require('node:fs');
const path = require('node:path');
const expected = fs.readFileSync(path.join(__dirname, '..', '..', 'VERSION'), 'utf8').trim();
const pkg = await pkgLib.getPackage();
assert.equal(pkg.version, expected);
});
// A BrightSign consultant's automated deployment copied our first autorun.zip onto a player and
// then reported it invalid. Two causes: the archive was DEFLATED, and we opened it with roUnzip
// rather than roBrightPackage. The player bootstrap extracts autozip.brs by itself before any
// script runs, and roBrightPackage supports a specific set of methods — "no compression" is the
// universally safe one.
//
// This is the failure mode that hurts: a compressed package uploads, downloads and deploys
// perfectly, then fails to open on the player. It reads as a broken deployment, not a broken zip,
// so it gets debugged everywhere except where the bug is.
test('THE DEPLOYMENT BUG: every member of the package is STORED, never deflated', async () => {
const pkg = await pkgLib.getPackage();
const buf = Buffer.isBuffer(pkg) ? pkg : (pkg && (pkg.buffer || pkg.bytes || pkg.zip));
assert.ok(Buffer.isBuffer(buf), 'getPackage must yield the archive bytes');
// Walk the local file headers: signature PK\x03\x04, compression method at offset +8.
let found = 0;
for (let i = 0; i + 30 <= buf.length; i++) {
if (buf.readUInt32LE(i) !== 0x04034b50) continue;
const method = buf.readUInt16LE(i + 8);
const nameLen = buf.readUInt16LE(i + 26);
const name = buf.slice(i + 30, i + 30 + nameLen).toString();
assert.equal(method, 0, `${name} is compressed (method ${method}); the player cannot open it`);
found++;
}
assert.ok(found > 0, 'no entries found — the walk itself is wrong, not the archive');
});
// ---------------------------------------------------------------------------------------------
// The package points at the server it was FETCHED FROM
//
// A zip pulled from alpha must provision against alpha. Before this, every package carried the
// committed default (prod) regardless of origin, so provisioning a self-hosted or alpha player
// from its own server silently pointed it at screentinker.com — which surfaces as a pairing bug,
// miles from the packaging code that caused it.
//
// The invariant at the top of this file becomes PER ORIGIN: different URL, different bytes,
// different checksum — and the manifest and download routes must derive the same one.
// ---------------------------------------------------------------------------------------------
// Entries are STORED (no compression), so screentinker.json sits verbatim in the archive and can be
// read without a zip library. Matched on the "key": "value" form specifically: autorun.brs also
// mentions server_url, but only as reg.Exists("server_url") / SaveRegistry("server_url", …), which
// this pattern cannot match.
const packagedServerUrl = (buffer) => {
const m = buffer.toString('latin1').match(/"server_url"\s*:\s*"([^"]*)"/);
assert.ok(m, 'screentinker.json should be readable in the stored archive');
return m[1];
};
test('a package fetched from alpha points at alpha, not the committed default', async () => {
pkgLib._reset();
const alpha = await pkgLib.getPackage('https://alpha.screentinker.com');
assert.equal(packagedServerUrl(alpha.buffer), 'https://alpha.screentinker.com');
pkgLib._reset();
const plain = await pkgLib.getPackage();
assert.equal(packagedServerUrl(plain.buffer), 'https://screentinker.com',
'with no URL to stamp, the committed default ships unchanged');
});
test('THE OTA LOOP, per origin: each package hashes its OWN bytes', async () => {
pkgLib._reset();
const a = await pkgLib.getPackage('https://alpha.screentinker.com');
const b = await pkgLib.getPackage('https://screentinker.com');
assert.notEqual(a.sha256, b.sha256, 'different URLs must produce different bytes');
for (const p of [a, b]) {
assert.equal(p.sha256, crypto.createHash('sha256').update(p.buffer).digest('hex'));
assert.equal(p.size, p.buffer.length, 'Content-Length must match the body');
}
});
test('reproducibility survives: the same URL yields byte-identical packages', async () => {
// The whole reason entry timestamps are fixed. Per-origin caching must not reintroduce the
// churn — a player that sees a new checksum every poll re-downloads forever.
pkgLib._reset();
const first = await pkgLib.getPackage('https://alpha.screentinker.com');
pkgLib._reset();
const second = await pkgLib.getPackage('https://alpha.screentinker.com');
assert.equal(first.sha256, second.sha256);
});
test('the URL is not taken on trust — APP_URL wins, and a hostile Host is sanitised', () => {
const req = (host, protocol = 'https') => ({ protocol, get: (h) => (h === 'host' ? host : null) });
const saved = process.env.APP_URL;
try {
process.env.APP_URL = 'https://configured.example/';
assert.equal(pkgLib.packageServerUrl(req('evil.example')), 'https://configured.example',
'a configured APP_URL must win over the request header, and lose its trailing slash');
delete process.env.APP_URL;
assert.equal(pkgLib.packageServerUrl(req('alpha.screentinker.com')),
'https://alpha.screentinker.com', 'otherwise fall back to the host, so self-hosting needs no config');
for (const bad of ['a.example"; rm -rf /', 'a.example/../x', 'a b.example', 'x'.repeat(200),
'http://a.example', 'a.example:notaport']) {
assert.equal(pkgLib.packageServerUrl(req(bad)), null,
`a host that is not a clean hostname[:port] must ship the default, got it from ${bad}`);
}
assert.equal(pkgLib.packageServerUrl(req('a.example:3001', 'http')), 'http://a.example:3001',
'a port and plain http are legitimate for a self-hosted box');
assert.equal(pkgLib.packageServerUrl(req('')), null, 'no host, no stamp — ship the default');
assert.equal(pkgLib.packageServerUrl(null), null);
} finally {
if (saved === undefined) delete process.env.APP_URL; else process.env.APP_URL = saved;
}
});
test('a corrupt config ships as-is rather than shipping corrupt', async () => {
// autorun.brs reads screentinker.json at boot. A package that cannot be parsed there is a player
// that never starts — strictly worse than one pointing at the wrong server.
const pkgSrc = require('node:fs').readFileSync(
require('node:path').join(__dirname, '..', 'lib', 'brightsign-package.js'), 'utf8');
assert.match(pkgSrc, /catch \(e\) \{\s*return source;/,
'stampServerUrl must fall back to the original text on a parse failure');
});
test('the per-origin cache is bounded — the key comes from a request header', async () => {
pkgLib._reset();
for (let i = 0; i < 40; i++) await pkgLib.getPackage(`https://h${i}.example`);
const src = require('node:fs').readFileSync(
require('node:path').join(__dirname, '..', 'lib', 'brightsign-package.js'), 'utf8');
const m = src.match(/MAX_CACHED_PACKAGES\s*=\s*(\d+)/);
assert.ok(m, 'the bound must be a named constant, not a magic number');
assert.ok(Number(m[1]) <= 32, 'a ~73KB buffer per entry keyed on a header needs a small bound');
});
test('BOTH routes derive the stamped URL the same way, or the manifest lies about the bytes', () => {
// The lib tests above prove one buffer hashes to one checksum PER URL. That guarantee is only
// useful if the manifest route and the download route ask for the SAME url — if one stamps and
// the other does not, the player verifies a checksum against bytes it was never sent and retries
// forever. Asserted on the source because both routes live in server.js behind an Express app
// this file does not boot.
const src = require('node:fs').readFileSync(
require('node:path').join(__dirname, '..', 'server.js'), 'utf8');
const total = (src.match(/bsPackage\.getPackage\(/g) || []).length;
const viaHelper = (src.match(/bsPackage\.getPackage\(bsPackage\.packageServerUrl\(/g) || []).length;
assert.equal(viaHelper, total,
`every getPackage call must route through packageServerUrl; ${total - viaHelper} do not`);
// The two REQUEST-driven routes must both use (req). The boot warm-up legitimately passes null —
// there is no request at boot — and it shares the cache key with APP_URL-configured deployments,
// so it warms the very entry those requests will hit.
const fromRequest = (src.match(/bsPackage\.getPackage\(bsPackage\.packageServerUrl\(req\)\)/g) || []).length;
assert.ok(fromRequest >= 2,
`the manifest and download routes must both derive from the request; found ${fromRequest}`);
});