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.
65 lines
2.5 KiB
JavaScript
65 lines
2.5 KiB
JavaScript
// URL redaction and provider host policy for the preview feature.
|
||
//
|
||
// Signed preview URLs are ephemeral secrets: they carry an hmac and expire in
|
||
// minutes (Phase 0: 13–15 min, then 403). They must never appear in logs,
|
||
// session persistence, or diagnostics. Everything that wants to talk about a
|
||
// URL goes through redactUrl() and gets host + a stable hash + the parsed
|
||
// expiry — enough to correlate, nothing to replay.
|
||
|
||
/** Providers the preview resolver may fetch media from. HTTPS only; exact
|
||
* host match — no subdomain wildcards, because the CDN host is a single
|
||
* stable name (Phase 0: 71/71 samples on cdnt-preview.dzcdn.net). */
|
||
export const MEDIA_HOST_ALLOWLIST = Object.freeze([
|
||
"cdnt-preview.dzcdn.net",
|
||
// Apple preview hosts land here in Phase 2 when Apple identity is
|
||
// plumbed through the backend; adding a host is a reviewed change.
|
||
]);
|
||
|
||
/** Parse the signed expiry out of a preview URL. Deezer uses an Akamai
|
||
* `hdnea=exp=<epoch-seconds>~…` token; a bare `exp=` query also matches.
|
||
* Returns epoch MILLISECONDS or null — never throws. */
|
||
export function parseSignedExpiryMs(url) {
|
||
if (typeof url !== "string") return null;
|
||
const m = /(?:^|[?&~])(?:hdnea=)?exp=(\d{9,11})(?:[~&]|$)/.exec(url);
|
||
if (!m) return null;
|
||
return Number(m[1]) * 1000;
|
||
}
|
||
|
||
/** FNV-1a — tiny, stable, dependency-free. Not cryptographic and does not
|
||
* need to be: the hash only correlates log lines about the same URL. */
|
||
function fnv1a(str) {
|
||
let h = 0x811c9dc5;
|
||
for (let i = 0; i < str.length; i++) {
|
||
h ^= str.charCodeAt(i);
|
||
h = Math.imul(h, 0x01000193);
|
||
}
|
||
return (h >>> 0).toString(16).padStart(8, "0");
|
||
}
|
||
|
||
/** The only representation of a media URL allowed outside the transport:
|
||
* `{ host, hash, expMs }`. */
|
||
export function redactUrl(url) {
|
||
if (typeof url !== "string" || url.length === 0) {
|
||
return { host: null, hash: null, expMs: null };
|
||
}
|
||
let host = null;
|
||
try {
|
||
host = new URL(url).host;
|
||
} catch {
|
||
/* malformed: host stays null, hash still correlates */
|
||
}
|
||
return { host, hash: fnv1a(url), expMs: parseSignedExpiryMs(url) };
|
||
}
|
||
|
||
/** True only for an https URL whose host is on the media allowlist. Used for
|
||
* the initial URL and re-checked for every redirect target — a redirect off
|
||
* the allowlist is a hard failure, not a follow. */
|
||
export function isAllowedMediaUrl(url) {
|
||
try {
|
||
const u = new URL(url);
|
||
return u.protocol === "https:" && MEDIA_HOST_ALLOWLIST.includes(u.host);
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|