Files
SoulStream/src/lib/preview/redact.js
T
KeelrLeek 8ede6a8adf 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.
2026-07-22 18:29:03 -05:00

65 lines
2.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
}
}