Phase 1: preview contracts and pure session core, 61 tests

src/lib/preview/ — framework-free ESM modules, per design §12–14:
- contracts.js: versioned manifest/item/source-ref factories; signed
  URLs structurally excluded from manifests; Apple identity first-class.
- lease.js: discriminated MediaLease union; computeSafeForMs derives
  lifetimes provider-clock-first with fallback/contradiction handling
  (margins are ADRs backed by Phase 0 soak evidence).
- redact.js: URL redaction (host+hash+exp only) and the HTTPS + exact
  host allowlist gate applied to initial URLs and every redirect.
- manifest.js: artist (rank order, exact-id dedupe), album (disc/track
  sort, context inheritance) and fixture builders — pure, no fetching.
- session.js: reducer with session/generation guards on every async
  action; keyed active item with derived playable indices/cursor;
  auto-advance under fresh generations; close is terminal.
- resolver.js: PreviewResolver contract + scripted fixture resolver
  with classified failures (stale_lease/not_found/network/…).
- transport.js: transport seam + deterministic fake (manual ticks).
- focus.js: revision gateway (all main-player entry points; focus-
  internal tag) and the coordinator — acquisition only via a
  generation-bound silence ack, restore only with revisions unchanged.

tests/preview/*.test.mjs run via `npm test` (node --test): 61 passing,
covering the full Phase 1 list in IMPLEMENTATION-PLAN §6 including the
exit gate: stale lease/play events are inert, no restoration after any
simulated main command, and no component renders anywhere.
This commit is contained in:
KeelrLeek committed 2026-07-22 18:29:03 -05:00
1 parent 6dcc62a2fd
commit 8ede6a8adf
14 files changed
+1722 -2

No files matched your search

+116
View File
@@ -0,0 +1,116 @@
// Ephemeral media leases — the discriminated union from design §12.
//
// A lease is the resolver's answer to "how do I play this item RIGHT NOW":
// either a transport-private URL (webview) or an opaque native handle, plus
// a conservative `safeForMs` after which the transport must re-resolve.
// Leases are never persisted, never used as cache identity, and never logged
// in full (see redact.js).
/**
* Safety margin subtracted from every derived lifetime. ADR (Phase 0): leases
* hard-403 after their signed exp with NO grace observed (206 at +14 min
* against a +14.97 exp, 403 by +17.5). 20 s covers request latency, decoder
* spin-up and one settle-window retry without meaningfully shortening a
* 13-minute lease.
*/
export const LEASE_SAFETY_MARGIN_MS = 20_000;
/**
* Fallback lifetime when provider time data is missing, malformed or
* contradictory. Deliberately short: Phase 0 never observed a lease younger
* than ~13 min, so 60 s is far inside any real lifetime while still letting
* a clip play out (clips are 30 s) before a forced refresh.
*/
export const LEASE_FALLBACK_SAFE_MS = 60_000;
/** Longest lifetime we will ever trust, however healthy the inputs look —
* a parse artifact claiming hours must not disable refresh. */
export const LEASE_MAX_SAFE_MS = 30 * 60_000;
/**
* Derive `safeForMs` from provider time data, per design §12:
* conservative, provider-clock based, never trusting any single input.
*
* @param {object} t
* @param {number|null} t.expEpochMs parsed signed expiry (provider clock)
* @param {number|null} t.dateHeaderMs provider `Date` header (provider clock)
* @param {number} t.localNowMs local wall clock at receipt
* @returns {number} milliseconds the lease may be used, >= 0
*/
export function computeSafeForMs({ expEpochMs, dateHeaderMs, localNowMs }) {
const bad =
!Number.isFinite(expEpochMs) ||
!Number.isFinite(localNowMs) ||
expEpochMs <= 0;
if (bad) return LEASE_FALLBACK_SAFE_MS;
// Remaining lifetime on the provider's own clock when both ends of it
// are known — immune to local clock skew entirely.
let remaining = null;
if (Number.isFinite(dateHeaderMs) && dateHeaderMs > 0) {
remaining = expEpochMs - dateHeaderMs;
// Contradictory provider data (exp at/before Date, or a lifetime far
// beyond anything the provider actually issues) → fallback.
if (remaining <= 0 || remaining > 2 * LEASE_MAX_SAFE_MS) {
return LEASE_FALLBACK_SAFE_MS;
}
}
// Local-clock view. If the local clock is skewed AHEAD of the provider,
// this is smaller than `remaining` and correctly wins (we refresh early).
// If skewed BEHIND, `remaining` wins and the local view can't extend it.
const localView = expEpochMs - localNowMs;
if (localView <= 0 && remaining === null) return LEASE_FALLBACK_SAFE_MS;
const usable =
remaining === null ? localView : Math.min(remaining, localView > 0 ? localView : remaining);
const safe = Math.min(usable, LEASE_MAX_SAFE_MS) - LEASE_SAFETY_MARGIN_MS;
return Math.max(0, safe);
}
/** Build a native-transport lease (the ratified transport). */
export function makeNativeLease({ source, opaqueLeaseId, safeForMs, mimeType, clipDurationMs, byteLength, supportsRanges }) {
if (typeof opaqueLeaseId !== "string" || !opaqueLeaseId) {
throw new Error("native lease requires opaqueLeaseId");
}
return Object.freeze({
transport: "native",
source,
opaqueLeaseId,
safeForMs,
mimeType,
clipDurationMs,
byteLength,
supportsRanges,
});
}
/** Build a webview-transport lease (documented fallback transport). */
export function makeWebviewLease({ source, url, safeForMs, mimeType, clipDurationMs, byteLength, supportsRanges }) {
if (typeof url !== "string" || !url) {
throw new Error("webview lease requires url");
}
return Object.freeze({
transport: "webview",
source,
url,
safeForMs,
mimeType,
clipDurationMs,
byteLength,
supportsRanges,
});
}
/** Union validity: exactly one locator, matching the transport tag. */
export function isValidLease(lease) {
if (!lease || typeof lease !== "object") return false;
if (!Number.isFinite(lease.safeForMs) || lease.safeForMs < 0) return false;
if (lease.transport === "native") {
return typeof lease.opaqueLeaseId === "string" && lease.opaqueLeaseId !== "" && lease.url === undefined;
}
if (lease.transport === "webview") {
return typeof lease.url === "string" && lease.url !== "" && lease.opaqueLeaseId === undefined;
}
return false;
}