mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
Found by driving the real server and a real browser, not by reading. Each fix has a
test that fails without it.
1. A missing upload answered 200 with the DASHBOARD. express.static falls through on a
miss and the SPA catch-all caught it, so GET /uploads/content/<gone>.mp4 returned
15KB of index.html as text/html — under the `immutable, max-age=30d` header the mount
sets before it knows the file exists. Every player downloader treats 200 as success,
so a panel stores the HTML page AS the video and caches it for a month, rendering a
black frame with nothing in any log. Reachable exactly when it hurts: a content
replace writes a new random filename and unlinks the old one. The mount now
terminates a miss with a 404 and drops the cache header.
2. Four dashboard->device socket handlers had no capability gate. dashboard:device-command
has always refused a command the panel cannot honour, and the comment above it is right
about why ("hiding the button is not enforcement — this socket is reachable directly").
Every word applied to the four handlers immediately above it, which had none: a display
declaring [] still received screenshot-request, remote-touch, remote-key and
remote-start. Measured, not inferred. They now refuse on remote.screenshot /
remote.input / remote.stream and name the capability in the ack; remote-stop stays
ungated for the same reason set_debug does. The undeclared fleet is unaffected — an
absent declaration still resolves to its platform baseline and keeps everything.
The wall panel list (#235) made this visible: it offered a Screenshot button for every
panel, including a BrightSign, which has no screenshot capability at all, and popped a
toast promising an image that was never coming. GET /api/devices now ships the RESOLVED
capability array rather than the raw column ('[]' as a STRING, which Array.isArray reads
as "pre-capability server, show everything" — wrong in the one case that matters), so
the wall list and the fleet cards can hide what a panel cannot do. The remote pad's
Scrn Off / Scrn On were gated on remote.input while the Info tab gated the same two
commands on display.power; both now agree.
3. A register with no `platform` ERASED the stored one. captureIdentity coerces a missing
field to the literal 'unknown' and persistIdentity wrote it straight over. That column
is load-bearing: platformFamily() reads it, so one reconnect from an older build turned
a Tizen panel into a browser tab and handed it a volume slider the .wgt has no handler
for — the exact control BASELINE.tizen exists to hide — while a BrightSign lost screen
power and reboot and gained screenshots it cannot take. platform and client_type are
now preserved (physical facts); client_version and contract_version still decay, because
there "we no longer know" is the truthful answer. client_type 'wgt' is also read as a
second signal for a Tizen TV.
4. PUT /api/content/:id/replace carried its own shorter copy of the ingest logic. Replacing
a video left duration_sec at the OLD clip's length and nulled width/height, so #237's
brand-new "default an item to the clip's own length" then handed out the wrong number
for every later add — 32s scheduled for a 5s video is 27s of frozen frame. Replacing an
image measured it with raw sharp metadata and thumbnailed without .rotate(),
re-introducing the EXIF-orientation bug #172 had just fixed at ingest. Both paths now
share lib/content-ingest.deriveMediaMetadata.
Verified working and NOT changed: all six item-duration insert paths (a 31.7s clip stores
32 everywhere, an explicit value always wins, and no path can store a 0); the content
revision bump + filepath refresh reaching a real device socket; a landscape wall producing
byte-identical geometry to the pre-#236 expression; a portrait wall reaching the player as
side-by-side halves; cross-workspace isolation across 29 probes.
Full suite green (1319).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
118 lines
7.2 KiB
JavaScript
118 lines
7.2 KiB
JavaScript
'use strict';
|
||
|
||
// v4 CORE-PASS liveness helpers — pure, VERSION-AGNOSTIC, mixed-fleet-safe. Dependency-free so they
|
||
// are unit-testable and the imperative shells (deviceSocket heartbeat/register handlers, the
|
||
// heartbeat offline sweep) stay thin. The server talks to a MIX simultaneously — v4 clients (have a
|
||
// watchdog, consume the ack, send an identity block), OLD pre-v4 clients (none of that), and
|
||
// genuinely-disconnected devices — and none of these may break the server or each other.
|
||
|
||
// ── Uniform ack (PRIMARY + FIX 1: reconnect-window gap) ────────────────────────────────────────
|
||
// Should THIS device:heartbeat be acked with device:heartbeat-ack? The ack keeps a v4 client's
|
||
// watchdog armed; it is emitted from the SHARED heartbeat handler (uniform by construction across
|
||
// APK / .wgt / /player) and is HARMLESS to old clients (they don't consume it). We ack a KNOWN
|
||
// device — identity-agnostic:
|
||
// - an already-authenticated socket (authedDeviceId set), OR
|
||
// - a heartbeat carrying a device_id that RESOLVES to a real device (a real device mid-reconnect,
|
||
// BEFORE this socket finished re-registering — the deferred ack-gap fix).
|
||
// We do NOT ack anonymous / never-authenticated sockets (no device_id, or an unknown id): those are
|
||
// covered by degrade-safe — an un-acked client's watchdog simply never arms, so there is no
|
||
// false-fire and no storm.
|
||
function ackableHeartbeat(authedDeviceId, heartbeatDeviceId, deviceExists) {
|
||
if (authedDeviceId) return true; // authenticated socket -> known
|
||
if (!heartbeatDeviceId) return false; // anonymous heartbeat -> not acked
|
||
return !!deviceExists(heartbeatDeviceId); // real device mid-reconnect -> ack (window fix)
|
||
}
|
||
|
||
// ── Dashboard liveness (FIX 2: server-derived, VERSION-AGNOSTIC 3-state) ────────────────────────
|
||
// Derived ONLY from signals EVERY client sends — socket presence, last-heartbeat age, reconnect
|
||
// frequency — never from v4-only signals. Correct for v4 clients, OLD clients (connected +
|
||
// heartbeating -> healthy), and disconnected clients (-> offline, a normal state, NOT an error).
|
||
// offline : no live socket.
|
||
// degraded : connected but reconnecting frequently (churn), OR connected but silent past the window.
|
||
// healthy : connected + a recent heartbeat + not churning.
|
||
const HEALTHY_HEARTBEAT_MS = 35000; // 2× the 15s client heartbeat + margin
|
||
const DEGRADED_RECONNECTS = 3; // >=3 (re)registers within the reconnect window => churn
|
||
|
||
function deriveLiveness({ connected, lastHeartbeatAgeMs, recentReconnects } = {}, opts = {}) {
|
||
const hbMax = opts.healthyHeartbeatMs != null ? opts.healthyHeartbeatMs : HEALTHY_HEARTBEAT_MS;
|
||
const churn = opts.degradedReconnects != null ? opts.degradedReconnects : DEGRADED_RECONNECTS;
|
||
if (!connected) return 'offline';
|
||
if ((recentReconnects || 0) >= churn) return 'degraded';
|
||
if ((lastHeartbeatAgeMs || 0) > hbMax) return 'degraded';
|
||
return 'healthy';
|
||
}
|
||
|
||
// ── Identity capture (FIX 3: capture-don't-act, DEGRADES on missing) ────────────────────────────
|
||
// Capture the v4 identity block when present; when absent/partial (an OLD client), fill
|
||
// "legacy"/"unknown" — NEVER fail on a missing field. No logic is built on this yet.
|
||
function captureIdentity(data) {
|
||
const d = data || {};
|
||
return {
|
||
client_type: d.client_type || 'legacy',
|
||
client_version: d.client_version || 'unknown',
|
||
platform: d.platform || 'unknown',
|
||
contract_version: d.contract_version || 'legacy',
|
||
};
|
||
}
|
||
|
||
/*
|
||
* Absent is not a statement — the same rule applyCapabilities() enforces for the capability column.
|
||
*
|
||
* captureIdentity above coerces a MISSING platform to the literal 'unknown', and persistIdentity
|
||
* used to write that straight over the stored value. One register from a client that doesn't send
|
||
* the field — an older build after an OTA, a downgrade, anything pre-v4 — permanently erased the
|
||
* panel's platform.
|
||
*
|
||
* That column is load-bearing, not decorative: player-capabilities.platformFamily() reads it to
|
||
* pick a baseline. An erased Tizen panel falls through to the WEB baseline and is offered a volume
|
||
* slider the .wgt has no handler for — the exact control BASELINE.tizen exists to hide — while an
|
||
* erased BrightSign loses screen power and reboot and gains screenshots it cannot take.
|
||
*
|
||
* platform and client_type are preserved; client_version and contract_version are NOT. The split is
|
||
* "physical fact" vs "property of the build currently installed": a panel does not stop being a
|
||
* Tizen TV or a .wgt player, but its version and protocol level change with every OTA, and there
|
||
* "we no longer know" is the truthful answer rather than a stale number.
|
||
*
|
||
* client_type earns its place because it is the SECOND signal platformFamily() reads ('wgt' => a
|
||
* Tizen TV): preserving platform while letting client_type decay to 'legacy' would leave a panel
|
||
* with no identifying signal at all.
|
||
*
|
||
* @param {object|null} stored the identity row currently in the DB
|
||
* @param {object} incoming the freshly captured identity (mutated in place and returned)
|
||
*/
|
||
const IDENTITY_PLACEHOLDER = { platform: 'unknown', client_type: 'legacy' };
|
||
function preserveKnownIdentity(stored, incoming) {
|
||
if (!incoming || !stored) return incoming;
|
||
for (const [field, placeholder] of Object.entries(IDENTITY_PLACEHOLDER)) {
|
||
if (incoming[field] === placeholder && stored[field] && stored[field] !== placeholder) {
|
||
incoming[field] = stored[field];
|
||
}
|
||
}
|
||
return incoming;
|
||
}
|
||
|
||
// A1 change-detection: has the (already-captured) identity changed vs what's stored? A genuine
|
||
// reconnect with an unchanged identity (the common case) then does NO write. A never-stored device
|
||
// (current null / all-NULL columns) or a real change (e.g. new client_version after an OTA) writes.
|
||
function identityChanged(current, incoming) {
|
||
if (!current) return true;
|
||
return current.client_type !== incoming.client_type
|
||
|| current.client_version !== incoming.client_version
|
||
|| current.platform !== incoming.platform
|
||
|| current.contract_version !== incoming.contract_version;
|
||
}
|
||
|
||
// Exit-signal contract v1 — manner-of-death. A client may ONLY announce 'crashed' (its uncaught-
|
||
// exception handler fired) or 'clean_exit' (a confident lifecycle-end). 'silent' is server-inferred by
|
||
// ABSENCE and is NEVER accepted from a client. Honesty by construction: an unknown/uncertain value is
|
||
// rejected (-> null), so the device falls to server-inferred 'silent' rather than being coerced into a
|
||
// wrong category. detail is optional (crash message / lifecycle-hook name), sanitized + length-capped.
|
||
const CLIENT_EXIT_REASONS = ['crashed', 'clean_exit'];
|
||
function sanitizeExitReason(reason, detail) {
|
||
if (!CLIENT_EXIT_REASONS.includes(reason)) return null;
|
||
const d = (typeof detail === 'string' && detail.trim()) ? detail.trim().slice(0, 200) : null;
|
||
return { reason, detail: d };
|
||
}
|
||
|
||
module.exports = { ackableHeartbeat, deriveLiveness, captureIdentity, identityChanged, preserveKnownIdentity, sanitizeExitReason, CLIENT_EXIT_REASONS, HEALTHY_HEARTBEAT_MS, DEGRADED_RECONNECTS };
|