@nitida/asset-client 0.18.1 → 0.19.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +20 -2
- package/LICENSE +21 -0
- package/README.md +10 -0
- package/SECURITY.md +56 -0
- package/dist/index.cjs +32 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +54 -2
- package/dist/index.d.ts +54 -2
- package/dist/index.js +28 -3
- package/dist/index.js.map +1 -1
- package/package.json +4 -2
- package/src/access.ts +22 -2
- package/src/index.ts +61 -0
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/access.ts","../src/palette.ts","../src/transform.ts","../src/slots.ts","../src/index.ts"],"sourcesContent":["/**\n * Signed URLs for PRIVATE assets — the `/a/{tenant}/…?exp&sig` tree.\n *\n * ## Who calls this, and who must not\n *\n * The tenant's BACKEND, which knows who the viewer is and holds the signing\n * key. Never a browser: shipping the signing key to the client would let any\n * visitor mint URLs for any private asset of that tenant, which is the whole\n * property the tree exists to provide. This module is deliberately importable\n * from anywhere — it runs on WebCrypto, so browsers, Node, Bun and Workers all\n * work — and that convenience is exactly why the warning is here rather than\n * in a doc nobody reads at the call site.\n *\n * ## Not the same signature as `signTransformUrl`\n *\n * `signTransformUrl` vouches for a WIDTH; this vouches for a VIEWER, until\n * `exp`. They come from the same `signing_key` but not the same key material:\n * the access key is derived (`HMAC(signing_key, \"nitida/access/v1\")`) so that\n * no crafted transform path can be replayed as an access signature. The full\n * argument lives beside the server implementation in `access-signing.ts`; the\n * short version is that a shared payload with a prefix separator IS\n * collidable, because both fields of the transform message are\n * attacker-influenced path segments.\n *\n * ## `exp` is mandatory\n *\n * A signed URL that never expires is a public URL as soon as someone forwards\n * it. There is no \"no expiry\" option here, and there will not be one.\n */\n\nconst ACCESS_KEY_INFO = \"nitida/access/v1\";\n\nasync function hmac(\n key: ArrayBuffer | Uint8Array,\n message: string,\n): Promise<Uint8Array> {\n const cryptoKey = await crypto.subtle.importKey(\n \"raw\",\n key as unknown as ArrayBuffer,\n { name: \"HMAC\", hash: \"SHA-256\" },\n false,\n [\"sign\"],\n );\n return new Uint8Array(\n await crypto.subtle.sign(\n \"HMAC\",\n cryptoKey,\n new TextEncoder().encode(message),\n ),\n );\n}\n\nconst toHex = (b: Uint8Array) =>\n [...b].map((x) => x.toString(16).padStart(2, \"0\")).join(\"\");\n\n/**\n * The per-tenant access key, derived from `signing_key`. Same value the origin\n * computes and hands the edge — nothing needs to be stored or synchronised.\n */\nexport async function deriveAccessKey(signingKey: string): Promise<Uint8Array> {\n return hmac(new TextEncoder().encode(signingKey), ACCESS_KEY_INFO);\n}\n\n/**\n * The signed message. Byte-identical to the server's `accessMessage` and the\n * worker's — three implementations of one string, which is why all three pin\n * the exact bytes in a test.\n *\n * `tenantPrefix` is the base36 segment as it appears in the URL, not the\n * decimal id: tenant 10 lives at `/a/a/`, and the thing being vouched for is a\n * path.\n */\nexport function accessMessage(\n tenantPrefix: string,\n exp: number,\n resourcePath: string,\n): string {\n return `${tenantPrefix}\\n${exp}\\n${resourcePath.replace(/^\\/+/, \"\")}`;\n}\n\nexport type SignAccessOptions = {\n /** Lifetime in seconds. Required — see the header. */\n expiresInSeconds: number;\n /** Injectable clock, for tests that need a URL already dead on arrival. */\n nowSeconds?: number;\n};\n\n/**\n * Turn a PUBLIC-tree URL into a signed PRIVATE-tree URL.\n *\n * https://8ok.uk/5/v/<sha16>-lg.webp\n * → https://8ok.uk/a/5/v/<sha16>-lg.webp?exp=…&sig=…\n *\n * Accepts a URL that is already under `/a/` and re-signs it, so calling twice\n * is not an error and does not produce `/a/a/`.\n */\nexport async function signAccessUrl(\n publicUrl: string,\n signingKey: string,\n opts: SignAccessOptions,\n): Promise<string> {\n if (!Number.isFinite(opts.expiresInSeconds) || opts.expiresInSeconds <= 0) {\n throw new Error(\n \"signAccessUrl: `expiresInSeconds` must be a positive number — a signed URL without an expiry is a public URL the moment it is forwarded.\",\n );\n }\n const u = new URL(publicUrl);\n const segments = u.pathname.split(\"/\").filter(Boolean);\n // ⚠️ `a` is BOTH the private tree's prefix and tenant 10 in base36, so\n // \"starts with /a/ ⇒ already signed\" would eat tenant 10's own segment and\n // sign `/a/v/…` as if `v` were the tenant. What tells them apart is the\n // resource kind, always `v` or `r` directly after the tenant: the private\n // tree is `/a/<tenant>/<v|r>/…`, and tenant 10's public `/a/v/<sha>-lg.webp`\n // is not. Same base36 trap that makes `/10/` the platform's favourite 404.\n // `[vrt]`, not `[vr]`: `t` joined the private tree when transforms did, and\n // this line was left behind — so re-signing `/a/5/t/<dsl>/<sha>.webp` read\n // `a` as the tenant and `5` as the resource kind, and threw. The check two\n // dozen lines below already said `[vrt]`; a regex that disagrees with its own\n // file is the shape this bug always takes.\n if (segments[0] === \"a\" && segments[2] && /^[vrt]$/.test(segments[2])) {\n segments.shift();\n }\n const tenantPrefix = segments.shift();\n if (!tenantPrefix || segments.length === 0) {\n throw new Error(\n `signAccessUrl: expected a tenant-prefixed CDN path like /<tenant>/v/<sha>-<preset>.<ext>, got ${u.pathname}`,\n );\n }\n // ⭐ The segment after the tenant is ALWAYS the resource kind. Checking it is\n // not pedantry — it catches the one mistake this signature shape invites.\n //\n // `/t/<dsl>/<sha>.webp` is a real, valid public transform URL, and it is the\n // obvious thing to hand this function. Without this check `t` is read as the\n // TENANT (29 in base36) and the result is `/a/t/<dsl>/…`: a URL that is\n // perfectly signed, structurally plausible, and 404s for a reason nobody can\n // see. Found by using it, 2026-08-22, on the very first private transform.\n //\n // A transform needs its tenant prepended first — which is exactly what\n // `getPrivateTransformUrl` does, so the fix is almost always to call that.\n if (!/^[vrt]$/.test(segments[0]!)) {\n throw new Error(\n `signAccessUrl: expected /<tenant>/<v|r|t>/… but the segment after the tenant is \"${segments[0]}\". ` +\n (segments[0]?.includes(\"=\")\n ? `That looks like a transform DSL, so the path is probably /t/<dsl>/<sha>.<ext> — which has no tenant in it (\\`t\\` here was read as tenant ${Number.parseInt(tenantPrefix, 36)}). Use getPrivateTransformUrl(asset, opts, signingKey, { expiresInSeconds }) instead.`\n : `Got ${u.pathname}.`),\n );\n }\n const resourcePath = segments.join(\"/\");\n const now = opts.nowSeconds ?? Math.floor(Date.now() / 1000);\n const exp = now + Math.floor(opts.expiresInSeconds);\n\n const sig = toHex(\n await hmac(\n await deriveAccessKey(signingKey),\n accessMessage(tenantPrefix, exp, resourcePath),\n ),\n );\n\n u.pathname = `/a/${tenantPrefix}/${resourcePath}`;\n u.searchParams.set(\"exp\", String(exp));\n u.searchParams.set(\"sig\", sig);\n return u.toString();\n}\n\n/** What every URL builder accepts so it can refuse a doomed URL. */\nexport type VisibilityHint = { visibility?: \"public\" | \"private\" };\n\n/**\n * Refuse to build a public URL for a private asset.\n *\n * Doctrine of the house: **a silence reads as \"you can't\"**. Returning\n * `https://8ok.uk/5/v/<sha>-lg.webp` for a private asset is not a smaller\n * failure than throwing — it is a URL that answers 404, in a platform where a\n * 404 has always meant \"that file does not exist\". The caller then debugs the\n * wrong thing.\n *\n * Only refuses when it was actually TOLD. A caller passing `{ sha }` carries no\n * visibility, and guessing would break every existing call site to protect\n * assets that are not there.\n */\nexport function assertPublic(\n asset: VisibilityHint,\n fn: string,\n /**\n * The call to make instead — declared per call site, not guessed.\n *\n * It matters which one: `getPrivateAssetUrl` signs a STORED preset, and\n * pointing a transform caller at it sends them to a function that cannot do\n * what they asked for. The first version of this message named\n * `getPrivateAssetUrl` for all seven builders; a test caught it.\n */\n escape: string,\n): void {\n if (asset.visibility !== \"private\") return;\n throw new Error(\n `${fn}: this asset is private, so a public CDN URL for it will answer 404 — that is the feature, not a missing file. ` +\n `Mint a signed URL on your BACKEND instead: await ${escape}. ` +\n \"Never ship the signing key to a browser.\",\n );\n}\n\n/**\n * Refuse a value whose `sha` is missing or malformed, instead of interpolating\n * it into a URL.\n *\n * ## Found by a Haiku agent, 2026-08-23\n *\n * It did the most natural thing there is — passed the result of `upload()`\n * straight to `transform()` — and got:\n *\n * https://8ok.uk/t/width=1280/undefined.webp\n *\n * `UploadResult` carries `sha256`; every URL builder wants `sha`. TypeScript\n * catches the mismatch, but an agent running through `bun` (or anyone in plain\n * JS) sees no error at all: just a 200-shaped URL with the word `undefined` in\n * it, which 404s later and somewhere else.\n *\n * The house rule applies exactly as it does to private assets: **a silence\n * reads as \"you can't\"**. A builder that cannot name the asset must say so at\n * the call site, not hand back a string that will fail far from here.\n */\nexport function assertSha(asset: { sha?: unknown }, fn: string): void {\n const sha = asset?.sha;\n if (typeof sha === \"string\" && /^[0-9a-f]{16,64}$/i.test(sha)) return;\n const hint =\n asset && typeof asset === \"object\" && \"sha256\" in asset\n ? \" The value you passed has `sha256` but not `sha` — that is the shape `upload()` returns. Use `{ sha: result.sha256.slice(0, 16) }`, or fetch the DTO with `assets.get(id)`.\"\n : ` Got ${JSON.stringify(sha)}.`;\n throw new Error(\n `${fn}: no usable \\`sha\\` on the value you passed, so the URL would contain \"undefined\" and 404 somewhere else.${hint}`,\n );\n}\n","/**\n * Color palette helpers — render harmonious ambient backgrounds behind\n * product images, inspired by Spotify Now Playing / Apple Music / Pico.\n *\n * Wire format is intentionally compact: only the hex per swatch, only the\n * swatches the source actually had. The full names map to 1-2 letter aliases\n * (`d` dominant, `v` vibrant, `m` muted, `dv` darkVibrant, `lv` lightVibrant,\n * `dm` darkMuted, `lm` lightMuted) to shave bytes for catalog-sized payloads\n * (palette was 62% of asset DTO before this).\n *\n * Population + RGB array + textColor are derivable client-side; we don't\n * ship them. textColor is computed via WCAG relative luminance on demand.\n */\n\n/** Compact wire shape for an asset's palette. All swatches optional except dominant. */\nexport type AssetPalette = {\n /** dominant hex (always present when palette exists) */\n d: string;\n /** vibrant */ v?: string;\n /** muted */ m?: string;\n /** darkVibrant */ dv?: string;\n /** lightVibrant */ lv?: string;\n /** darkMuted */ dm?: string;\n /** lightMuted */ lm?: string;\n};\n\n/** Backwards-compat alias for older callers that referenced PaletteSwatch. */\nexport type PaletteSwatch = { hex: string; textColor: \"#000000\" | \"#FFFFFF\" };\n\n/**\n * Resolve a palette key to its hex value if present.\n */\nfunction resolveSwatch(\n palette: AssetPalette | null | undefined,\n ...keys: (keyof AssetPalette)[]\n): string | null {\n if (!palette) return null;\n for (const k of keys) {\n const v = palette[k];\n if (v) return v;\n }\n return null;\n}\n\n/**\n * Pick the swatch best suited for an ambient surface behind the image.\n * Prefers muted/light tones — too vibrant a background fights the image.\n *\n * Order: lightMuted → muted → lightVibrant → dominant.\n */\nexport function pickAmbientBackground(\n palette: AssetPalette | null | undefined,\n): PaletteSwatch | null {\n const hex = resolveSwatch(palette, \"lm\", \"m\", \"lv\", \"d\");\n if (!hex) return null;\n return { hex, textColor: textColorForHex(hex) };\n}\n\n/**\n * Build a CSS linear-gradient from the palette. Useful for hero / detail\n * backgrounds.\n */\nexport function getAmbientGradient(\n palette: AssetPalette | null | undefined,\n opts: {\n angle?: string;\n from?: keyof AssetPalette;\n to?: keyof AssetPalette;\n } = {},\n): string | undefined {\n if (!palette) return undefined;\n const fromHex = palette[opts.from ?? \"lm\"] ?? palette.m ?? palette.d;\n const toHex = palette[opts.to ?? \"m\"] ?? palette.dm ?? palette.d;\n if (!fromHex || !toHex) return undefined;\n return `linear-gradient(${opts.angle ?? \"135deg\"}, ${fromHex}, ${toHex})`;\n}\n\n/**\n * Recommended text color (#000 or #FFF) for any background hex,\n * computed via WCAG relative luminance.\n */\nexport function getTextColorForBackground(\n swatch: PaletteSwatch | string | null | undefined,\n): string {\n if (!swatch) return \"#000000\";\n const hex = typeof swatch === \"string\" ? swatch : swatch.hex;\n return textColorForHex(hex);\n}\n\n/** Luminancia relativa WCAG de un hex. 0 = negro, 1 = blanco. */\nexport function relativeLuminance(hex: string): number {\n const h = hex.replace(\"#\", \"\");\n const r = Number.parseInt(h.slice(0, 2), 16) / 255;\n const g = Number.parseInt(h.slice(2, 4), 16) / 255;\n const b = Number.parseInt(h.slice(4, 6), 16) / 255;\n // sRGB → linear\n const lin = (c: number) =>\n c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;\n return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);\n}\n\n/**\n * WCAG contrast ratio between two luminances: `(L1 + 0.05) / (L2 + 0.05)`,\n * lighter on top. 1 = identical, 21 = black against white.\n */\nexport function contrastRatio(l1: number, l2: number): number {\n const [hi, lo] = l1 >= l2 ? [l1, l2] : [l2, l1];\n return (hi + 0.05) / (lo + 0.05);\n}\n\n/**\n * Black or white — whichever **actually** contrasts more against this\n * background.\n *\n * The tie point is NOT `L > 0.5`, which is the threshold most implementations\n * reach for. Equating the two contrast ratios gives it exactly:\n *\n * (1 + 0.05) / (L + 0.05) = (L + 0.05) / 0.05\n * (L + 0.05)² = 0.0525\n * L = 0.1791…\n *\n * Between **0.179 and 0.5** a `L > 0.5` test picks WHITE while black contrasts\n * more — a wide band, and exactly where saturated brand colours land. A\n * mid-luminance gold in that band can be handed white at **2.68:1** when black\n * would give **7.84:1**; 2.68 does not pass AA even for large text.\n *\n * So this does not swap one threshold for another: it **computes both ratios\n * and returns the winner**. A threshold is a constant somebody has to keep\n * correct; the comparison is correct by construction.\n *\n * ⚠️ This picks the BETTER of two — it does not guarantee the result is\n * enough. Over a mid-luminance background the best pair can still land under\n * 4.5:1. Use {@link bestTextContrast} when you need to know: it returns the\n * ratio it achieved.\n */\nfunction textColorForHex(hex: string): \"#000000\" | \"#FFFFFF\" {\n return bestTextContrast(hex).color;\n}\n\n/**\n * Same choice as the recommended text colour, but it also returns **the ratio\n * it achieved** and whether that passes AA — so a caller can decide with the\n * number in front of them instead of assuming it was enough.\n */\nexport function bestTextContrast(hex: string): {\n color: \"#000000\" | \"#FFFFFF\";\n ratio: number;\n passesAA: boolean;\n passesAALarge: boolean;\n} {\n const L = relativeLuminance(hex);\n const onBlack = contrastRatio(L, 0);\n const onWhite = contrastRatio(L, 1);\n const useBlack = onBlack >= onWhite;\n const ratio = useBlack ? onBlack : onWhite;\n return {\n color: useBlack ? \"#000000\" : \"#FFFFFF\",\n ratio,\n passesAA: ratio >= 4.5,\n passesAALarge: ratio >= 3,\n };\n}\n\n/**\n * CSS variables for a wrapper so a subtree can read --asset-bg / --asset-fg /\n * --asset-dominant / --asset-vibrant / etc.\n */\nexport function getPaletteCssVars(\n palette: AssetPalette | null | undefined,\n): Record<string, string> {\n if (!palette) return {};\n const bg = pickAmbientBackground(palette);\n return {\n \"--asset-bg\": bg?.hex ?? \"transparent\",\n \"--asset-fg\": bg ? bg.textColor : \"#000000\",\n \"--asset-dominant\": palette.d,\n ...(palette.v && { \"--asset-vibrant\": palette.v }),\n ...(palette.m && { \"--asset-muted\": palette.m }),\n ...(palette.lv && { \"--asset-light-vibrant\": palette.lv }),\n ...(palette.dv && { \"--asset-dark-vibrant\": palette.dv }),\n ...(palette.lm && { \"--asset-light-muted\": palette.lm }),\n ...(palette.dm && { \"--asset-dark-muted\": palette.dm }),\n };\n}\n\n/**\n * Iterate the palette in display order (dominant first, then vibrant +\n * muted families). Useful for rendering a swatch strip in admin UIs.\n */\nexport function iteratePaletteSwatches(\n palette: AssetPalette | null | undefined,\n): Array<{ key: keyof AssetPalette; label: string; hex: string }> {\n if (!palette) return [];\n const order: Array<{ key: keyof AssetPalette; label: string }> = [\n { key: \"d\", label: \"dominant\" },\n { key: \"v\", label: \"vibrant\" },\n { key: \"lv\", label: \"lightVibrant\" },\n { key: \"dv\", label: \"darkVibrant\" },\n { key: \"m\", label: \"muted\" },\n { key: \"lm\", label: \"lightMuted\" },\n { key: \"dm\", label: \"darkMuted\" },\n ];\n return order\n .map(({ key, label }) => {\n const hex = palette[key];\n return hex ? { key, label, hex } : null;\n })\n .filter(\n (s): s is { key: keyof AssetPalette; label: string; hex: string } =>\n s != null,\n );\n}\n\n/**\n * Build a multi-radial-gradient CSS `background` string from the palette\n * swatches. Acts as a zero-extra-bytes alternative to the WebP LQIP: the\n * palette is already in the DTO, so this placeholder costs nothing extra\n * to ship. Renders as a smooth abstract \"color cloud\" reminiscent of the\n * source image's vibe.\n *\n * Strategy: anchor 4 radial gradients at fixed corners using vibrant/muted\n * pairs, layered over the dominant fill. Skips missing swatches gracefully.\n */\nexport function getPaletteBlurBackground(\n palette: AssetPalette | null | undefined,\n): string | undefined {\n if (!palette) return undefined;\n const corners: Array<{ pos: string; key: keyof AssetPalette }> = [\n { pos: \"20% 20%\", key: \"lv\" },\n { pos: \"80% 25%\", key: \"v\" },\n { pos: \"25% 80%\", key: \"lm\" },\n { pos: \"80% 80%\", key: \"dv\" },\n ];\n const layers = corners\n .map(({ pos, key }) => {\n const hex = palette[key];\n if (!hex) return null;\n return `radial-gradient(circle at ${pos}, ${hex} 0%, transparent 55%)`;\n })\n .filter(Boolean) as string[];\n // Fallback fill = dominant (or muted if dominant is missing — shouldn't happen)\n const base = palette.d ?? palette.m ?? \"#888\";\n return layers.length > 0 ? `${layers.join(\", \")}, ${base}` : base;\n}\n","/**\n * On-the-fly transform URL builder.\n *\n * Mirrors the server's DSL canonicalizer byte-for-byte so a URL generated\n * here hashes to the same cache key as the server's canonical form.\n *\n * Canonicalization rules (kept in sync with the server):\n * - Drop entries whose value is `undefined`\n * - Sort keys alphabetically\n * - Numbers rendered without leading zeros or trailing dots\n * - String values lowercased\n *\n * URL shape:\n * <cdnBase>/t/<dsl>/<sha>.<ext>\n *\n * The `.ext` is informational (browser content-sniff hint); the server\n * decides the actual output format from the DSL `format` param + the\n * request's Accept header.\n */\n\nimport { assertPublic, assertSha, type VisibilityHint } from \"./access\";\nimport type { AssetDTO } from \"./index\";\nimport { getCdnBase } from \"./index\";\n\n/**\n * Widths the CDN edge whitelists (DoS guard).\n * Requesting any OTHER width returns HTTP 400 at the edge (unsigned URLs). These are the\n * 1× base ladder values; DPR ×2/×3 multiples are applied + whitelisted server-side. Import\n * this instead of hardcoding magic widths so an unsupported size is caught in review/IDE.\n */\nexport const TRANSFORM_WIDTHS = [\n 96, 128, 160, 240, 256, 320, 400, 480, 600, 640, 800, 960, 1080, 1200, 1280,\n 1440, 1600, 1920, 2560, 3840,\n] as const;\n/**\n * A CDN-whitelisted transform width — the only widths `TransformOptions.width`\n * accepts. Off-ladder widths are a compile error; for signed URLs that need a\n * custom width, use {@link SignedTransformOptions} (number) via\n * {@link getSignedTransformUrl} / `aq.transform(asset, opts, { sign: true })`.\n */\nexport type TransformWidth = (typeof TRANSFORM_WIDTHS)[number];\n\nexport type TransformFit = \"cover\" | \"contain\" | \"fill\" | \"inside\" | \"outside\";\nexport type TransformGravity =\n | \"auto\"\n | \"face\"\n | \"center\"\n | \"north\"\n | \"south\"\n | \"east\"\n | \"west\";\nexport type TransformFormat =\n | \"auto\"\n | \"avif\"\n | \"webp\"\n | \"jpeg\"\n | \"png\"\n // Video-only formats (Phase 4). The image path ignores them.\n | \"mp4\"\n | \"webm\"\n // HLS adaptive ladder (Phase 5). Video-only. Output is a directory\n // of m3u8 + .ts segments fronted by master.m3u8; the SDK returns\n // the master URL via `getHlsStreamingUrl`.\n | \"hls\";\nexport type TransformEffect = \"removebg\" | \"genfill\";\n\nexport type TransformOptions = {\n /**\n * Target max-side width in CSS pixels (multiplied by `dpr` server-side).\n * MUST be a {@link TRANSFORM_WIDTHS} value — off-ladder widths are rejected\n * (HTTP 400) by the edge whitelist for unsigned URLs, so the type forbids\n * them at compile time. For SIGNED URLs with a custom width, use\n * {@link SignedTransformOptions} (which widens this to `number`).\n */\n width?: TransformWidth;\n /** Target max-side height. Multiplied by `dpr` server-side. */\n height?: number;\n /** Resize fit mode. Default `cover` server-side. */\n fit?: TransformFit;\n /** Crop gravity. `auto` picks the region with the most visual salience. */\n gravity?: TransformGravity;\n /** Output format. `auto` → the platform's policy decides. */\n format?: TransformFormat;\n /** Output quality. `auto` → format-specific default. */\n quality?: \"auto\" | number;\n /** Device pixel ratio. Width/height are multiplied by this before resize. */\n dpr?: 1 | 2 | 3;\n /**\n * AI effect applied before resize/encode.\n *\n * - `removebg`: remove the background; output is a transparent PNG\n * of the foreground subject. Forces `format=png` regardless of\n * other format hints. A single cache miss per (sha, dsl) tuple;\n * subsequent identical DSLs serve from cache — no inference, no\n * per-image cost.\n *\n * - `genfill`: aspect-extension outpaint. Requires BOTH `width`\n * and `height` — the server\n * fits the source centered into the target canvas and outpaints\n * the gutters. Output is PNG (forced) at exactly target dims.\n * ~$0.05/image first time; same cache as removebg after.\n * Primary use case: building OG cards (1200×630) from portrait\n * listing photos without awkward edge mirroring.\n */\n effect?: TransformEffect;\n /**\n * Video-only: clip start in seconds. Image transforms ignore.\n * Accepts decimals (e.g. 1.5 for sub-second seek).\n */\n start?: number;\n /**\n * Video-only: clip duration in seconds (1..300). Image transforms\n * ignore. With `start`, lets a single request grab a sub-clip.\n */\n duration?: number;\n};\n\n/**\n * Like {@link TransformOptions} but with `width` widened to any `number` —\n * the escape hatch for SIGNED URLs that need an off-ladder custom width.\n *\n * The edge whitelist only rejects off-ladder widths on UNSIGNED URLs; a valid\n * `?sig=` earns the whitelist bypass at the edge (the server still\n * does the real HMAC check). So a custom width is ONLY safe when the URL is\n * signed — hence this type is accepted exclusively by the signing helpers\n * ({@link getSignedTransformUrl} / `aq.transform(asset, opts, { sign: true })`),\n * never by the plain unsigned {@link getTransformUrl}.\n */\nexport type SignedTransformOptions = Omit<TransformOptions, \"width\"> & {\n /** Off-ladder width — valid ONLY on signed URLs (edge whitelist bypass). */\n width?: number;\n};\n\n/**\n * Serialize transform options into the canonical DSL path segment.\n * Empty options return an empty string (caller should fall back to a\n * variant URL instead of a transform URL in that case).\n *\n * Accepts {@link SignedTransformOptions} (the wider type) so it also covers\n * custom-width signed URLs; {@link TransformOptions} is assignable to it.\n */\n/**\n * Extract the 16-hex short sha from an aquienpz CDN URL, regardless of\n * shape:\n * - tenant-prefixed variant: `https://8ok.uk/4/v/<sha16>-<preset>.<ext>`\n * - legacy variant: `https://8ok.uk/<sha16>-<preset>.<ext>`\n * - on-the-fly transform: `https://8ok.uk/t/<dsl>/<sha16>.<ext>`\n * - streaming HLS: `https://8ok.uk/t/format=hls/<sha16>.m3u8`\n *\n * Returns `null` for non-aquienpz URLs (pexels, googleusercontent, raw\n * uploaded URLs to other CDNs) so call sites can fall back to the URL\n * with a plain `<img>` instead of generating a broken transform URL.\n *\n * Useful when a value reaches the component as a pre-built URL string\n * (legacy data, site-config JSON, third-party feeds) but you want to\n * drop in `getTransformSrcSet` for the responsive ladder if it happens\n * to be an aquienpz asset.\n */\nexport function extractAssetSha(url: string | null | undefined): string | null {\n if (!url) return null;\n // Match the canonical aquienpz sha pattern: 16 lowercase hex chars\n // appearing as a path segment, optionally followed by `-<preset>`\n // (variant URL) or `.<ext>` (transform URL).\n const m = url.match(/\\/([0-9a-f]{16})(?:[-.]|$)/);\n return m ? m[1]! : null;\n}\n\nexport function serializeTransform(opts: SignedTransformOptions): string {\n const entries: Array<[string, string]> = [];\n const keys = Object.keys(opts).sort() as Array<keyof SignedTransformOptions>;\n for (const k of keys) {\n const v = opts[k];\n if (v == null) continue;\n const serialized = typeof v === \"string\" ? v.toLowerCase() : String(v);\n entries.push([k, serialized]);\n }\n return entries.map(([k, v]) => `${k}=${v}`).join(\",\");\n}\n\nfunction extForOptions(opts: SignedTransformOptions): string {\n // effect=removebg forces PNG output server-side (needs alpha).\n if (opts.effect === \"removebg\") return \"png\";\n // effect=genfill defaults to WebP (12× lighter than the raw generated\n // PNG output with no visible loss at q=85). Explicit `format=png`\n // opts back into lossless for print / marketing fold-outs. The server\n // re-encodes the generated PNG → target format before caching.\n if (opts.effect === \"genfill\") {\n switch (opts.format) {\n case \"png\":\n return \"png\";\n case \"avif\":\n return \"avif\";\n case \"jpeg\":\n return \"jpg\";\n default:\n return \"webp\";\n }\n }\n switch (opts.format) {\n case \"avif\":\n return \"avif\";\n case \"jpeg\":\n return \"jpg\";\n case \"png\":\n return \"png\";\n case \"mp4\":\n return \"mp4\";\n case \"webm\":\n return \"webm\";\n case \"hls\":\n // HLS uses `.m3u8` as the URL extension; the route resolves the\n // master playlist under the cache key prefix.\n return \"m3u8\";\n default:\n // \"webp\" / \"auto\" / undefined / anything new\n return \"webp\";\n }\n}\n\n/**\n * Build a transform URL for a VIDEO asset. Same DSL shape as image\n * transforms; the server branches on the asset's `kind` column. Video\n * URLs use `.mp4` (default) or `.webm` extension and on cache miss the\n * server returns 202 Accepted while a background job encodes the clip;\n * subsequent GETs return 302 to the cached object.\n *\n * <video src={aq.transformVideo(asset, { width: 1080, height: 1920 })}\n * autoPlay muted loop playsInline />\n */\nexport function getVideoTransformUrl(\n asset: Pick<AssetDTO, \"sha\"> & VisibilityHint,\n opts: TransformOptions,\n): string | null {\n assertSha(asset, \"getVideoTransformUrl\");\n assertPublic(\n asset,\n \"getVideoTransformUrl\",\n \"getPrivateTransformUrl(asset, opts, signingKey, { expiresInSeconds: 300 })\",\n );\n const dsl = serializeTransform(opts);\n if (!dsl) return null;\n const ext = opts.format === \"webm\" ? \"webm\" : \"mp4\";\n return `${getCdnBase()}/t/${dsl}/${asset.sha}.${ext}`;\n}\n\n/**\n * Build an HLS streaming URL for a VIDEO asset (Phase 5). Returns the\n * master.m3u8 entry point — HLS-aware players (Video.js's\n * @videojs/http-streaming, hls.js, native iOS Safari) follow it to\n * fetch the variant playlist + segments at the appropriate bitrate\n * for the connection.\n *\n * ⚠️ A master.m3u8 is NOT a video file. Assigning it to `<video src>`\n * works only where the engine has native HLS; everywhere else it needs\n * an MSE player. And the classic feature test is now WRONG: Chrome 147\n * (April 2026) added native HLS, so `canPlayType(\"application/vnd.apple.mpegurl\")`\n * answers \"maybe\" there and routes Chrome to the native branch, where it\n * opened a measured 17 s hero at 426x240 for ~8 s. Branch on the ENGINE:\n *\n * @example\n * ```ts\n * function prefersNativeHls(video: HTMLVideoElement): boolean {\n * if (video.canPlayType(\"application/vnd.apple.mpegurl\") === \"\") return false;\n * // Apple's engine, or an engine with no MSE to fall back on (iOS < 17.1).\n * return \"ManagedMediaSource\" in globalThis || !(\"MediaSource\" in globalThis);\n * }\n *\n * const src = getHlsStreamingUrl(asset);\n * if (prefersNativeHls(video)) {\n * video.src = src;\n * } else {\n * const hls = new Hls({ capLevelToPlayerSize: false, abrEwmaDefaultEstimate: 5_000_000 });\n * hls.loadSource(src);\n * hls.attachMedia(video);\n * }\n * ```\n *\n * ⚠️ **Do NOT pass `startLevel: -1` with `testBandwidth: true`.** That pair is\n * documented by hls.js as *\"forces the player to download a fragment from the\n * lowest level to establish a bandwidth estimate\"* — on a clip short enough to\n * be one segment, the probe IS the whole video, and it plays at the bottom\n * rung from first frame to last. (This doc-comment recommended exactly that\n * until 2026-08-18; a 5.042 s 4K asset was measured being delivered at\n * 426x240 because of it.) Leave `startLevel` unset: hls.js then opens on the\n * FIRST level in the manifest, and the server puts the right one there —\n * a mid rung for long video, the top rung for a clip under 18 s, which is the\n * same rung native HLS opens on per RFC 8216 §6.3.4. The ladder decides; the\n * player should not second-guess it.\n *\n * `capLevelToPlayerSize` is worth disabling explicitly: `@videojs/core`\n * defaults it to `true`, which caps quality to the player's rendered pixel box,\n * so a small inline player is pinned to 240p/360p on any connection.\n *\n * On first request the server returns 202 Accepted while a background\n * job transcodes the ladder (typically 1-3 min for a 90 s source);\n * subsequent requests get 302 to the cached master.m3u8. Keep the\n * progressive MP4 as a fallback source for that window.\n *\n * The ladder's ceiling is the source the job probes: built at ingest it\n * reads the RAW upload and a 4K master yields 1440p/2160p rungs; rebuilt\n * on demand after the raw is unavailable it reads the `-v.mp4`, which is\n * capped at 1920 wide. `getAssetUrl(sha, \"video\")` is always <= 1080p.\n */\nexport function getHlsStreamingUrl(\n asset: Pick<AssetDTO, \"sha\"> & VisibilityHint,\n opts: Omit<TransformOptions, \"format\"> = {},\n): string {\n assertSha(asset, \"getHlsStreamingUrl\");\n assertPublic(\n asset,\n \"getHlsStreamingUrl\",\n 'getPrivateAssetUrl(asset, \"hls\", signingKey, { expiresInSeconds: 300 }) — the worker re-signs the playlist children',\n );\n // Always serialize with format=hls so the server routes correctly.\n const merged: TransformOptions = { ...opts, format: \"hls\" };\n const dsl = serializeTransform(merged);\n return `${getCdnBase()}/t/${dsl}/${asset.sha}.m3u8`;\n}\n\n/**\n * Build a transform URL. Returns null when the caller passed no options —\n * callers should prefer the existing variant URL builder in that case so\n * the request hits a pre-generated variant instead of an on-the-fly encode.\n */\nexport { buildTransformUrl as getTransformUrlUnchecked };\n\nfunction buildTransformUrl(\n asset: Pick<AssetDTO, \"sha\">,\n opts: SignedTransformOptions,\n): string | null {\n const dsl = serializeTransform(opts);\n if (!dsl) return null;\n const ext = extForOptions(opts);\n return `${getCdnBase()}/t/${dsl}/${asset.sha}.${ext}`;\n}\n\nexport function getTransformUrl(\n asset: Pick<AssetDTO, \"sha\"> & VisibilityHint,\n opts: TransformOptions,\n): string | null {\n assertSha(asset, \"getTransformUrl\");\n assertPublic(\n asset,\n \"getTransformUrl\",\n \"getPrivateTransformUrl(asset, opts, signingKey, { expiresInSeconds: 300 })\",\n );\n return buildTransformUrl(asset, opts);\n}\n\n/**\n * Build AND sign a transform URL, allowing an off-ladder custom `width`.\n *\n * This is the escape hatch for {@link SignedTransformOptions}: off-ladder\n * widths only pass the edge whitelist when the URL is signed, so building one\n * and signing it must happen together. For on-ladder widths prefer the plain\n * {@link getTransformUrl} (+ {@link signTransformUrl} if you need a signature).\n *\n * Returns `null` only when `opts` serialize to an empty DSL (no transform\n * requested) — same contract as {@link getTransformUrl}.\n */\nexport function getSignedTransformUrl(\n asset: Pick<AssetDTO, \"sha\"> & VisibilityHint,\n opts: SignedTransformOptions,\n signingKey: string,\n): Promise<string> | null {\n // ⭐ The guard belongs here MOST of all, and it was the one place it was\n // missing. A `?sig=` on `/t/` is a WIDTH permit, not access: on a private\n // asset the URL it produces is a perfectly signed 404. And this is exactly\n // where a backend developer holding a signing key and a private asset ends\n // up — so without this, the same call site throws when unsigned and returns\n // a doomed URL when signed, which is the worst of both.\n assertSha(asset, \"getSignedTransformUrl\");\n assertPublic(\n asset,\n \"getSignedTransformUrl\",\n \"getPrivateTransformUrl(asset, opts, signingKey, { expiresInSeconds: 300 })\",\n );\n const url = buildTransformUrl(asset, opts);\n if (!url) return null;\n return signTransformUrl(url, signingKey);\n}\n\n/**\n * Sign a transform URL with the tenant's HMAC signing key. Appends\n * `?sig=<hex>` where hex = HMAC-SHA256(signingKey, `<canonical-DSL>/<filename>`).\n *\n * Must agree byte-for-byte with the server's `verifyTransformSignature`.\n * Uses WebCrypto, so works in browsers, Node ≥ 16, Bun, and Workers.\n *\n * The canonical DSL is the one already produced by `serializeTransform`\n * (sort keys + lowercase strings), so signing a URL built by `getTransformUrl`\n * is automatic — the same canonical form is in the URL path.\n */\nexport async function signTransformUrl(\n unsignedUrl: string,\n signingKey: string,\n): Promise<string> {\n const u = new URL(unsignedUrl);\n // Path shape: /t/<dsl>/<filename>\n const parts = u.pathname.split(\"/\").filter(Boolean);\n // First segment must be \"t\"; the rest is dsl groups + filename. With\n // Phase 1 we ship a single DSL group; chained groups stay flat for\n // signing purposes (server canonicalizer flattens them too).\n if (parts[0] !== \"t\" || parts.length < 3) {\n throw new Error(`signTransformUrl: unexpected URL shape ${unsignedUrl}`);\n }\n const filename = parts[parts.length - 1]!;\n const dsl = parts.slice(1, -1).join(\"/\");\n const message = `${dsl}/${filename}`;\n const sig = await hmacSha256Hex(signingKey, message);\n u.searchParams.set(\"sig\", sig);\n return u.toString();\n}\n\nasync function hmacSha256Hex(key: string, message: string): Promise<string> {\n const enc = new TextEncoder();\n const cryptoKey = await crypto.subtle.importKey(\n \"raw\",\n enc.encode(key),\n { name: \"HMAC\", hash: \"SHA-256\" },\n false,\n [\"sign\"],\n );\n const buf = await crypto.subtle.sign(\"HMAC\", cryptoKey, enc.encode(message));\n return [...new Uint8Array(buf)]\n .map((b) => b.toString(16).padStart(2, \"0\"))\n .join(\"\");\n}\n\n/**\n * Build a responsive `srcSet` string by generating one transform URL per\n * width. All other options apply to every URL.\n *\n * <img\n * src={aq.transform(asset, { width: 800 })!}\n * srcSet={aq.transformSrcSet(asset, [320, 640, 960, 1280])}\n * sizes=\"(max-width: 768px) 100vw, 50vw\"\n * />\n */\nexport function getTransformSrcSet(\n asset: Pick<AssetDTO, \"sha\"> & VisibilityHint,\n widths: number[],\n extraOpts: Omit<TransformOptions, \"width\"> = {},\n): string {\n assertSha(asset, \"getTransformSrcSet\");\n assertPublic(\n asset,\n \"getTransformSrcSet\",\n \"getPrivateTransformUrl(asset, opts, signingKey, { expiresInSeconds: 300 }) per width\",\n );\n return widths\n .map((w) => {\n // Build via the internal (number-width) builder: `widths` is an explicit\n // responsive ladder the caller chose, so it stays `number[]`. Off-ladder\n // unsigned widths 400 at the edge — the caller's responsibility, exactly\n // as before the width type was tightened.\n const url = buildTransformUrl(asset, { ...extraOpts, width: w });\n return url ? `${url} ${w}w` : null;\n })\n .filter((s): s is string => s != null)\n .join(\", \");\n}\n","/**\n * @nitida/asset-client/slots — slot resolver for tenant-named assets.\n *\n * Slots give tenants a way to attach stable, human-readable names\n * (\"webapp.wizard.pool-type.icon-1\", \"storefront.cr.hero-video.landscape_hd_16x9.mp4\")\n * to assets they uploaded. Consumers resolve names → AssetDTOs at\n * build / runtime so their source never hardcodes a CDN URL; an\n * admin rebinds a slot in the platform console and every consumer\n * picks up the swap on cache refresh.\n *\n * Two layers in this package:\n * - `resolveSlot` / `resolveSlots` — universal (server, edge,\n * workers) fetch helpers. Cache 60s by default.\n * - React hooks live in `@nitida/asset-client/react/use-slot`\n * (kept out of this module so the SSR-safe core stays\n * dependency-free of react).\n */\n\nimport type { AssetDTO, VariantPreset } from \"./index\";\nimport { getAssetUrl, hasPreset } from \"./index\";\n\n// ---------------------------------------------------------------------------\n// Wire shape — matches the server's slots route.\n// ---------------------------------------------------------------------------\n\nexport type SlotDTO = {\n slotKey: string;\n /** Preset hint set when the slot was bound (e.g. `thumb` for icon slots). */\n preset: VariantPreset | null;\n description: string | null;\n updatedAt: string;\n asset: AssetDTO;\n};\n\nexport type SlotResolution = {\n /** The resolved DTO (`null` when the slot is unbound or asset missing). */\n slot: SlotDTO | null;\n /**\n * Effective preset — what `url` below was built with. Resolution order:\n * 1. caller's `preset` override\n * 2. slot's `preset` hint\n * 3. `lg` for images, `video` for video kind\n */\n preset: VariantPreset;\n /** The CDN URL the consumer should use. */\n url: string | null;\n};\n\n// ---------------------------------------------------------------------------\n// Config\n// ---------------------------------------------------------------------------\n\nconst DEFAULT_TTL_MS = 60_000;\nconst cache = new Map<string, { fetchedAt: number; value: SlotDTO | null }>();\n\n// The published API host — the same address the docs give out, so a caller\n// that configured nothing still talks to the documented endpoint. Override it\n// with `configureSlotResolver({ endpoint })` to point at a different\n// deployment.\nlet endpoint = \"https://api.nitida.gofuture.space\";\nlet apiKey: string | null = null;\nlet tenantCode: string | null = null;\n\n/**\n * Configure the resolver process-wide. Call once at boot from your\n * storefront layout / server entry / worker init.\n *\n * configureSlotResolver({\n * endpoint: process.env.AQUIENPZ_URL,\n * apiKey: process.env.AQUIENPZ_API_KEY, // amk_rt_* — server-only\n * tenantCode: \"acme-co\",\n * });\n */\nexport function configureSlotResolver(opts: {\n endpoint?: string;\n apiKey?: string;\n tenantCode?: string;\n}): void {\n if (opts.endpoint) endpoint = opts.endpoint.replace(/\\/+$/, \"\");\n if (opts.apiKey !== undefined) apiKey = opts.apiKey;\n if (opts.tenantCode !== undefined) tenantCode = opts.tenantCode;\n}\n\n/** Wipe the in-process cache (test helper or forced refresh). */\nexport function invalidateSlotCache(slotKey?: string): void {\n if (slotKey === undefined) cache.clear();\n else\n for (const k of cache.keys())\n if (k.endsWith(`:${slotKey}`)) cache.delete(k);\n}\n\n// ---------------------------------------------------------------------------\n// Internal fetch helper\n// ---------------------------------------------------------------------------\n\nconst baseHeaders = (): Record<string, string> => {\n const h: Record<string, string> = {};\n if (apiKey) h.Authorization = `Bearer ${apiKey}`;\n if (tenantCode) h[\"X-Tenant-Code\"] = tenantCode;\n return h;\n};\n\nasync function fetchSlot(slotKey: string): Promise<SlotDTO | null> {\n const r = await fetch(`${endpoint}/slots/${encodeURIComponent(slotKey)}`, {\n headers: baseHeaders(),\n });\n if (r.status === 404) return null;\n if (!r.ok) throw new Error(`slot fetch ${r.status}: ${await r.text()}`);\n return (await r.json()) as SlotDTO;\n}\n\nasync function fetchSlotsBulk(\n slotKeys: string[],\n): Promise<Record<string, SlotDTO | null>> {\n if (slotKeys.length === 0) return {};\n const r = await fetch(`${endpoint}/slots/resolve`, {\n method: \"POST\",\n headers: { ...baseHeaders(), \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ keys: slotKeys }),\n });\n if (!r.ok) throw new Error(`slots resolve ${r.status}: ${await r.text()}`);\n const body = (await r.json()) as { resolved: Record<string, SlotDTO | null> };\n return body.resolved;\n}\n\n// ---------------------------------------------------------------------------\n// Public API\n// ---------------------------------------------------------------------------\n\nexport type ResolveSlotOptions = {\n /** Override preset (caller knows the use case better than the slot binding). */\n preset?: VariantPreset;\n /** TTL for the in-process cache. Default 60s. Set 0 to bypass. */\n ttlMs?: number;\n};\n\n/**\n * Resolve a single slot to a CDN URL. Returns `{slot: null, url: null}`\n * when the slot is unbound — callers fall back to a placeholder.\n *\n * Cached for `ttlMs` (default 60s). Slot rebindings propagate within the\n * TTL window without an app restart.\n */\nexport async function resolveSlot(\n slotKey: string,\n opts: ResolveSlotOptions = {},\n): Promise<SlotResolution> {\n const ttl = opts.ttlMs ?? DEFAULT_TTL_MS;\n const cacheKey = `${tenantCode ?? \"_\"}:${slotKey}`;\n const now = Date.now();\n let dto: SlotDTO | null;\n const hit = cache.get(cacheKey);\n if (hit && now - hit.fetchedAt < ttl) {\n dto = hit.value;\n } else {\n dto = await fetchSlot(slotKey);\n cache.set(cacheKey, { fetchedAt: now, value: dto });\n }\n return materializeResolution(dto, opts.preset);\n}\n\n/**\n * Bulk-resolve N slot keys in one round-trip. The SDK's `useSlots`\n * React hook calls this so every storefront header (logo + tagline +\n * nav cover + …) loads as one request.\n */\nexport async function resolveSlots(\n slotKeys: string[],\n opts: ResolveSlotOptions = {},\n): Promise<Record<string, SlotResolution>> {\n if (slotKeys.length === 0) return {};\n const ttl = opts.ttlMs ?? DEFAULT_TTL_MS;\n const now = Date.now();\n const missing: string[] = [];\n const out: Record<string, SlotResolution> = {};\n for (const k of slotKeys) {\n const cacheKey = `${tenantCode ?? \"_\"}:${k}`;\n const hit = cache.get(cacheKey);\n if (hit && now - hit.fetchedAt < ttl) {\n out[k] = materializeResolution(hit.value, opts.preset);\n } else {\n missing.push(k);\n }\n }\n if (missing.length > 0) {\n const resolved = await fetchSlotsBulk(missing);\n for (const k of missing) {\n const dto = resolved[k] ?? null;\n cache.set(`${tenantCode ?? \"_\"}:${k}`, { fetchedAt: now, value: dto });\n out[k] = materializeResolution(dto, opts.preset);\n }\n }\n return out;\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\nfunction defaultPresetFor(asset: AssetDTO | undefined): VariantPreset {\n if (!asset) return \"lg\";\n return asset.kind === \"video\" ? \"video\" : \"lg\";\n}\n\nfunction materializeResolution(\n dto: SlotDTO | null,\n overridePreset?: VariantPreset,\n): SlotResolution {\n if (!dto) return { slot: null, preset: overridePreset ?? \"lg\", url: null };\n const effective = overridePreset ?? dto.preset ?? defaultPresetFor(dto.asset);\n // Fall back to \"lg\" when the bound preset doesn't exist on the asset\n // (e.g. slot was bound to a video but caller asked for a thumb).\n const finalPreset = hasPreset(dto.asset, effective)\n ? effective\n : defaultPresetFor(dto.asset);\n return {\n slot: dto,\n preset: finalPreset,\n url: getAssetUrl(dto.asset, finalPreset),\n };\n}\n","/**\n * @nitida/asset-client — read helpers for asset URLs.\n *\n * Universal CDN model (May 2026): the API ships a compact AssetDTO with a\n * 16-char SHA prefix + a `presets` string of 1-char codes; client helpers\n * construct CDN URLs deterministically from `(cdnBase, sha, preset, ext)`.\n *\n * Why: catalog sync over Electric SSE shipped 4 nearly-identical full URLs\n * per asset × hundreds of thousands of assets per snapshot. Sending only\n * what differs — sha + presets bitmap — collapses ~600 bytes per asset to\n * ~70 (88% reduction).\n *\n * No runtime dependencies — pure types + pure functions. Safe everywhere.\n * @module @nitida/asset-client\n */\n\n/**\n * Image presets are size-based (Vercel `next/image` style):\n * - `thumb` is the only square crop — semantic icon use case\n * - `sm/md/lg` are max-side bounding boxes that preserve aspect ratio\n *\n * Naming over the legacy `thumbnail/cover/web/hero` because the new names\n * say what the variant IS (a size class) rather than what it might be\n * USED for, removing the implicit landscape-only assumption that bit us\n * with vertical product photos.\n */\nexport type VariantPreset =\n // image presets\n | \"thumb\" // 256x256 square smart-crop (icon)\n | \"sm\" // 640 max-side\n | \"md\" // 1280 max-side\n | \"lg\" // 1920 max-side\n | \"xl\" // 3840 max-side (4K) — OPT-IN; not generated by default\n // passthrough for non-image kinds (PDF etc.)\n | \"original\"\n // video presets — semantic (not size classes)\n | \"poster\"\n | \"video\"\n | \"aiproxy\"\n // The adaptive HLS ladder. Unlike every other preset this is NOT one file:\n // it is a `master.m3u8` plus one media playlist + segment set per rung. It is\n // a preset because the question callers ask about it is the same one they ask\n // about the others — *does this asset have one?* — and `hasPreset` is the\n // place that question is answered. What it HAS, rung by rung, is in the\n // `hls` entry of `variants` ({@link AssetVariant.rungs}).\n | \"hls\"\n // audio preset — the cross-browser mp3 transcode of a voice note\n // (libmp3lame) emitted alongside the original so chat audio plays on\n // both Chrome/Android (webm/opus) and iOS Safari (which can't decode opus).\n | \"mp3\";\n\n/**\n * What you may ASK the server to produce.\n *\n * NOT the same set as {@link VariantPreset}, and conflating the two is the\n * single most expensive type error this package has shipped. Three unknown\n * agents evaluating the SDK all hit it, independently, in the same afternoon:\n *\n * regenerate(id, { presets: [\"hls\"] }) // compiled → HTTP 400\n * upload(file, { presets: [\"mp3\"] }) // compiled → HTTP 400\n *\n * Both symbols are perfectly real — they are things a variant CAN BE. Neither\n * is something you can ASK FOR. `hls` is built by the video pipeline when a\n * video is transcoded; `mp3` is emitted automatically alongside any audio\n * original so iOS Safari can play it. You do not order either one.\n *\n * And it was wrong in the other direction too, which nobody had noticed:\n * **`probe` is requestable and was not on `VariantPreset` at all**, so the type\n * forbade a request the server has always accepted.\n *\n * Verified 2026-08-21 against the Elysia schemas of all four write routes —\n * `/assets/process`, the presign route, `/assets/:id/regenerate` and both\n * multipart routes. All four accept exactly this list and nothing else, with\n * no drift between them.\n */\nexport type RequestablePreset =\n | Exclude<VariantPreset, \"hls\" | \"mp3\">\n // Still frames at evenly spaced offsets, stored under indexed keys\n // (`-pr0.jpg`, `-pr1.jpg`, …). Requestable, and deliberately absent from the\n // compact `presets` wire string — so it is here and not on VariantPreset.\n | \"probe\";\n\n/** 1-char alias used in storage keys / wire `presets` string. */\nexport const PRESET_SHORT: Record<VariantPreset, string> = {\n thumb: \"q\",\n sm: \"s\",\n md: \"m\",\n lg: \"l\",\n xl: \"x\",\n original: \"o\",\n poster: \"p\",\n video: \"v\",\n aiproxy: \"a\",\n // `h` — free in both directions: no other preset claims it, and no\n // multi-character token that `stripMultiCharTokens` removes donates one\n // (`transform-<hex>` → t/r/a/n/s/f/o/m + a–f; `pr`; `mp3`). So a server that\n // starts emitting `h` cannot make an OLDER client answer `true` to anything.\n hls: \"h\",\n // 3 chars, NOT a 1-char alias: the server has no short-form for\n // audio so its `shortPreset(\"mp3\")` falls through to the literal token,\n // and the deployed server already writes the `-mp3.mp3` variant + emits\n // the bare `mp3` token in the wire `presets` string. Must stay in lockstep.\n mp3: \"mp3\",\n};\nexport const PRESET_LONG: Record<string, VariantPreset> = Object.fromEntries(\n Object.entries(PRESET_SHORT).map(([k, v]) => [v, k as VariantPreset]),\n);\n\n/** Variant extension by preset. Image variants are always WebP, video MP4. */\nexport const PRESET_EXT: Record<VariantPreset, string> = {\n thumb: \"webp\",\n sm: \"webp\",\n md: \"webp\",\n lg: \"webp\",\n xl: \"webp\",\n original: \"bin\", // overridden per-asset via mime when needed\n poster: \"webp\",\n video: \"mp4\",\n aiproxy: \"mp4\",\n // The ladder's ENTRY file. Never used to build a key — see `getAssetUrl`,\n // which refuses to derive `<sha>-h.m3u8` because no such object exists.\n hls: \"m3u8\",\n mp3: \"mp3\",\n};\n\n/** Max-side dimension by preset; null for video / passthrough. */\nexport const PRESET_MAX_DIM: Record<VariantPreset, number | null> = {\n thumb: 256,\n sm: 640,\n md: 1280,\n lg: 1920,\n xl: 3840,\n original: null,\n poster: null,\n video: null,\n aiproxy: null,\n // A ladder has no single max side — it has a rung per size. `null` keeps it\n // out of `getAssetSrcSet`, where offering an `.m3u8` as an `<img>` candidate\n // would be nonsense. Its ceiling is `variants.find(v => v.preset === \"hls\").height`.\n hls: null,\n // audio has no pixel dimensions; `null` keeps mp3 out of the\n // dimension-based `getAssetSrcSet` / `computeVariantDimensions` logic.\n mp3: null,\n};\n\n/**\n * One generated variant of an asset. Returned by the admin endpoints\n * (`GET /assets/:id`, `POST /assets/:id/regenerate`).\n */\n/**\n * What `AssetVariant.preset` can actually hold.\n *\n * ⚠️ NOT `VariantPreset`, and the difference is a real bug the type used to\n * hide. Measured against the live API on 2026-08-21, one asset came back with\n * **29 variants, 25 of them `transform-<hash>`** — 86 % of the array — while the\n * type said every entry was one of eleven known presets. So this compiles:\n *\n * ```ts\n * for (const v of asset.variants ?? []) getAssetUrl(asset, v.preset);\n * ```\n *\n * `tsc` exits 0, and at runtime 25 of those 29 URLs come out as\n * `<sha>-undefined.undefined` and answer 404, because `PRESET_SHORT[preset]`\n * and `PRESET_EXT[preset]` are `undefined` for a hash that is not a preset.\n *\n * The `transform-*` entries are NOT junk and are not being removed: they are\n * the materialised cache of past on-demand requests — still ready, still free\n * to fetch — and the inventory model treats them as a first-class\n * `transform-cache` family, which is exactly the question a composer asks\n * (\"what can I fetch cheaply right now?\"). Deleting them would destroy that.\n *\n * So the type tells the truth instead. `(string & {})` keeps autocomplete on\n * the known presets while admitting the rest, and a caller that wants to build\n * a URL now has to narrow first — which is the whole point.\n */\nexport type VariantEntryPreset =\n | VariantPreset\n /** Indexed stills (`-pr0.jpg`, …). Requestable, never on the compact string. */\n | \"probe\"\n /** `transform-<dslHash>` and `upscale_*` — materialised cache, not a rung. */\n | (string & {});\n\nexport type AssetVariant = {\n /**\n * What this entry IS. Usually a named preset; can also be a\n * `transform-<hash>` cache artifact — see {@link VariantEntryPreset} before\n * passing it to {@link getAssetUrl}.\n */\n preset: VariantEntryPreset;\n /** Public CDN URL of this variant. */\n url: string;\n /** Pixel width. Absent for `original`-only assets where image processing was skipped, or for video presets. */\n width?: number;\n /** Pixel height. Same caveat as `width`. */\n height?: number;\n /** Byte size of the stored variant file. */\n bytes: number;\n /**\n * Where the bytes for this variant came from. Useful for quality\n * traceability — a `thumb` with `sourceFrom: \"original\"` is the\n * canonical case, while `sourceFrom: \"lg\"` means it was derived\n * from an already-encoded WebP (slight quality compounding).\n *\n * - `\"upload\"` → first-write at `/assets/process`. The bytes came\n * straight from the client's PUT.\n * - `VariantPreset` → regenerated from that preset's variant.\n *\n * Absent on variants written before the trace field existed.\n */\n sourceFrom?:\n | VariantPreset\n | \"upload\"\n // Written by the on-demand `/t/` route (`transform-<hash>` entries).\n | \"transform-route\"\n // The two ways an `hls` entry comes to exist: written by the transcode that\n // produced the ladder, or read back off the CDN by the backfill/self-heal.\n | \"hls-transcode\"\n | \"cdn-probe\";\n /** ISO timestamp this variant was written. Absent on pre-trace variants. */\n createdAt?: string;\n /**\n * **`preset: \"hls\"` only** — the ladder's rungs, in MASTER ORDER.\n *\n * The reason this exists: an entry that only says *there is a ladder* leaves\n * a consumer that plans a composition exactly as blind as no entry at all,\n * because the ceiling of a composition is its weakest ingredient and there is\n * no upscale. Before this field the only way to learn a clip's real rungs was\n * to fetch the master playlist — or worse, download the asset.\n *\n * `rungs[0]` is the rung every client OPENS on (RFC 8216 §6.3.4 for native\n * HLS; hls.js with `startLevel` unset uses \"the first level in the\n * manifest\"), so the order is a delivery fact — do not sort it in place.\n *\n * Use {@link hlsLadderAlignment} rather than eyeballing `segments`: a ladder\n * can be complete and still unable to adapt.\n */\n rungs?: HlsRung[];\n};\n\n/**\n * One rung of an adaptive HLS ladder.\n *\n * `segments` / `durationSec` are what make a ladder JUDGEABLE rather than\n * merely present. Measured on a prod ladder of an 87 s 4K source: 240p cut at\n * 14.35 s in 12 segments, 720p at 5.63 s in 12, 2160p at 5.88 s in 14. Cuts\n * that do not line up cannot be swapped, and a swap is what a rendition switch\n * IS — so that ladder looked complete and adapted badly. One segment means zero\n * switch points: whichever rung the player opens on is the rung it finishes on.\n *\n * Both are optional because a rung whose playlist could not be read is recorded\n * WITHOUT them rather than with a zero — unmeasured and none are different\n * facts, and a `0` there would read as the latter.\n */\nexport type HlsRung = {\n /** Rung directory / identity — `\"720p\"`. */\n name: string;\n /** `RESOLUTION` from the master playlist. */\n width: number;\n height: number;\n /** `BANDWIDTH` in bits per second. */\n bandwidth: number;\n /** Absolute URL of this rung's media playlist. */\n url: string;\n /** `#EXTINF` count. */\n segments?: number;\n /** Sum of the `#EXTINF` values, seconds. */\n durationSec?: number;\n};\n\n/**\n * The ladder of an asset, or `null` when it has none / the DTO does not carry\n * `variants` (the slim list shape never does — use `hasPreset(a, \"hls\")` there).\n */\nexport function getHlsLadder(\n asset: Pick<AssetDTO, \"variants\">,\n): AssetVariant | null {\n return asset.variants?.find((v) => v.preset === \"hls\") ?? null;\n}\n\n/**\n * What the recorded rungs actually support — computed here, never stored, so\n * one rule serves every consumer and a change to it does not need a backfill.\n *\n * - `switchable`: more than one rung AND more than one segment. False means the\n * player is pinned to whatever rung it opens on for the entire clip.\n * - `aligned`: every measured rung reports the same segment count. `null` means\n * fewer than two rungs were measured — **unknown, not false.** Treating that\n * as `false` rejects ladders nobody looked at.\n * - `ceilingHeight`: the tallest rung. The ceiling of any composition using it.\n */\nexport function hlsLadderAlignment(rungs: HlsRung[]): {\n rungCount: number;\n ceilingHeight: number;\n floorHeight: number;\n switchable: boolean;\n aligned: boolean | null;\n minSegments: number | null;\n} {\n const heights = rungs.map((r) => r.height);\n const measured = rungs\n .map((r) => r.segments)\n .filter((s): s is number => typeof s === \"number\" && s > 0);\n const minSegments = measured.length > 0 ? Math.min(...measured) : null;\n return {\n rungCount: rungs.length,\n ceilingHeight: heights.length > 0 ? Math.max(...heights) : 0,\n floorHeight: heights.length > 0 ? Math.min(...heights) : 0,\n switchable: rungs.length > 1 && (minSegments ?? 0) > 1,\n aligned: measured.length >= 2 ? new Set(measured).size === 1 : null,\n minSegments,\n };\n}\n\n/**\n * Compact wire shape — what the server actually sends. Aliases (`w`, `h`,\n * `dur`) are intentional to shave bytes per asset on dense lists.\n */\nexport type AssetDTO = {\n id: string;\n /** First 16 hex chars of sha256 — used to derive CDN URLs. */\n sha: string;\n kind: \"image\" | \"video\" | \"document\" | \"audio\" | \"other\";\n mime: string;\n bytes: number;\n /** Source dims (for aspect-ratio calc on the client). Optional for non-images. */\n w?: number | null;\n h?: number | null;\n /** Duration ms for videos. */\n dur?: number | null;\n /** LQIP placeholder (data URL). */\n blur?: string | null;\n palette?: AssetPalette | null;\n /**\n * Compact list of generated variants as their 1-char codes\n * concatenated, e.g. \"tcwh\" (image) / \"pv\" (video without aiproxy).\n * Ordered by ascending dimension.\n */\n presets: string;\n status: \"processing\" | \"ready\" | \"failed\";\n /**\n * Who may fetch the bytes.\n *\n * - `\"public\"` — the CDN serves it to anyone with the URL. The default,\n * and what all 27 484 assets were until this field existed.\n * - `\"private\"` — every public door answers **404**: the stored variants,\n * the raw original, the HLS ladder and `/t/`. The bytes are reachable\n * only through a signed URL under `/a/{tenant}/…?exp&sig`, which your\n * BACKEND mints with {@link getPrivateAssetUrl}.\n *\n * Optional so an older server that does not send it is read as `\"public\"` —\n * which is what such a server means.\n *\n * ⚠️ A 404 on a private asset is not a missing file. It is the feature\n * working. See {@link getPrivateAssetUrl}.\n */\n visibility?: \"public\" | \"private\";\n /** Soft-delete timestamp (ISO). Hidden from catalog when set. */\n deletedAt?: string | null;\n /**\n * Full variant list with URLs + sizes. Sent by `GET /assets/:id`; absent on\n * the slim list shape used by the resolver / catalog. Use `presets` for\n * compact existence checks, and this when you need the actual URLs.\n */\n variants?: AssetVariant[];\n /**\n * The extension the `original` variant was really stored under — the server\n * keys it off the uploaded filename, so it cannot be derived from `mime`.\n * Sent by `GET /assets/:id`; `null` when the asset has no original.\n * {@link getAssetUrl} uses it automatically when you pass the whole DTO.\n */\n oext?: string | null;\n};\n\nexport {\n accessMessage,\n assertPublic,\n assertSha,\n deriveAccessKey,\n type SignAccessOptions,\n signAccessUrl,\n type VisibilityHint,\n} from \"./access\";\nexport type {\n AssetPalette,\n PaletteSwatch,\n} from \"./palette\";\n\nexport {\n bestTextContrast,\n contrastRatio,\n getAmbientGradient,\n getPaletteBlurBackground,\n getPaletteCssVars,\n getTextColorForBackground,\n iteratePaletteSwatches,\n pickAmbientBackground,\n relativeLuminance,\n} from \"./palette\";\n\nimport {\n assertPublic,\n assertSha,\n type SignAccessOptions,\n signAccessUrl,\n type VisibilityHint,\n} from \"./access\";\nimport type { AssetPalette } from \"./palette\";\nimport {\n getTransformUrlUnchecked,\n type SignedTransformOptions,\n} from \"./transform\";\n\n// ---------------------------------------------------------------------------\n// CDN base\n// ---------------------------------------------------------------------------\n\nlet cdnBaseUrl = \"https://8ok.uk\";\n/**\n * Override the CDN base for the entire process (e.g. in tests, or when\n * pointing at a tenant-specific CDN). Storefront layouts call this once at\n * boot.\n */\nexport function setCdnBase(url: string): void {\n cdnBaseUrl = url.replace(/\\/$/, \"\");\n}\nexport function getCdnBase(): string {\n return cdnBaseUrl;\n}\n\n// ---------------------------------------------------------------------------\n// Tenant scope\n//\n// Post-May-2026 the CDN serves variants under a tenant-prefixed path\n// `<cdn>/<tenantId base36>/v/<sha16>-<preset>.<ext>` (see the server\n// `variantKey`). Variant URL builders MUST include that prefix or every\n// URL 404s. The tenant id is process-global (one tenant per client/app),\n// set once at boot — `NitidaClient` does this from its `tenantId` option;\n// standalone consumers call `setTenantId()` directly. Left unset, builders\n// fall back to the legacy pre-cutover bare path for back-compat.\n// ---------------------------------------------------------------------------\n\nlet tenantId: number | null = null;\n/** Set the process-global tenant id used to build tenant-prefixed CDN URLs. */\nexport function setTenantId(id: number | null | undefined): void {\n tenantId =\n typeof id === \"number\" && Number.isFinite(id) && id > 0 ? id : null;\n}\nexport function getTenantId(): number | null {\n return tenantId;\n}\n/** Variant path prefix `<tid b36>/v/`, or \"\" when no tenant is configured. */\nfunction variantPrefix(): string {\n return tenantId != null ? `${tenantId.toString(36)}/v/` : \"\";\n}\n\n// ---------------------------------------------------------------------------\n// URL builders\n// ---------------------------------------------------------------------------\n\n/**\n * LAST-RESORT guess at the ORIGINAL variant's extension, from the asset's mime.\n *\n * ⚠️ This is a guess. Prefer `oext` or `variants` (see {@link getAssetUrl}) — the\n * server sends both and they ARE the key.\n *\n * The server derives the extension from the MIME with the `mime-types` package\n * (`mime.extension(body.mime)`, at presign). So a table that matched that one\n * exactly would usually be right — and this table did not: it said\n * `image/jpeg` → `jpeg` while `mime-types` says `jpg`, under a comment claiming\n * to mirror it. Usually, but not always: the row's stored `mime` is not always\n * the mime the key was built from, so no client-side table can close the gap.\n *\n * Measured against the 2 001 stored originals in production, 2026-08-17: **all\n * 420 `image/jpeg` originals are stored `.jpg` and none `.jpeg`** — the old\n * entry here 404'd on every single JPEG. And 234 originals carry\n * `application/octet-stream` (`.mpga`, `.docx`, `.m4a`), where no mime table can\n * produce the right key at all — those need `oext`.\n */\nconst ORIGINAL_EXT_BY_MIME: Record<string, string> = {\n \"image/png\": \"png\",\n \"image/jpeg\": \"jpg\",\n \"image/webp\": \"webp\",\n \"image/gif\": \"gif\",\n \"image/avif\": \"avif\",\n \"image/svg+xml\": \"svg\",\n \"image/heic\": \"heic\",\n \"image/heif\": \"heif\",\n \"image/bmp\": \"bmp\",\n \"image/tiff\": \"tiff\",\n \"application/pdf\": \"pdf\",\n \"video/mp4\": \"mp4\",\n \"video/webm\": \"webm\",\n \"video/quicktime\": \"mov\",\n // Audio — absent until 2026-08-17. While they were missing this table\n // returned the `bin` sentinel for every voice note, so consumers had to\n // hand-roll the extension themselves. Measured: `-o.bin` 404s, `-o.m4a` 200s.\n //\n // ⚠️ These are keyed by the FULL mime, not the subtype: `audio/mp4` is stored\n // as `.m4a` and `video/mp4` as `.mp4`. A `switch` on the `mp4` subtype cannot\n // tell them apart — a mistake worth not repeating.\n \"audio/mpeg\": \"mpga\",\n \"audio/mp4\": \"m4a\",\n \"audio/x-m4a\": \"m4a\",\n \"audio/wav\": \"wav\",\n \"audio/webm\": \"weba\",\n \"audio/ogg\": \"oga\",\n \"audio/aac\": \"adts\",\n};\nfunction originalExtForMime(mime: string | undefined): string {\n return (mime ? ORIGINAL_EXT_BY_MIME[mime] : undefined) ?? PRESET_EXT.original;\n}\n\n/** What the asset itself knows about where its `original` lives. */\ntype OriginalHints = {\n mime?: string;\n /** The extension the server actually stored it under. Authoritative. */\n oext?: string | null;\n /** Full variant list — carries the stored URL verbatim. Authoritative. */\n variants?: AssetVariant[];\n};\n\n/**\n * Build the public CDN URL for a specific variant of an asset. The variant\n * may not actually exist (regenerate may not have run, or video has no\n * `aiproxy`); call `hasPreset()` first or expect a 404.\n *\n * For the `original` preset, pass the whole `AssetDTO` — it carries `variants`\n * and `oext`, either of which gives the EXACT stored key. `mime` alone is only a\n * guess (the server keys the original off the uploaded filename), and with\n * nothing at all the original falls back to the `\"bin\"` sentinel, which 404s.\n *\n * @example Video — the tenant segment is base36, so let this build the path\n * ```ts\n * import { getAssetUrl, setTenantId } from \"@nitida/asset-client\";\n *\n * setTenantId(12); // 12 → \"c\"; a decimal \"/12/v/\" 404s\n * getAssetUrl({ sha }, \"video\"); // → https://8ok.uk/c/v/<sha16>-v.mp4\n * getAssetUrl({ sha }, \"poster\"); // → https://8ok.uk/c/v/<sha16>-p.webp\n * ```\n *\n * @example The `original` — pass the DTO, not just the sha\n * ```ts\n * const asset = await aq.assets.get(id);\n *\n * getAssetUrl({ sha }, \"original\"); // → …-o.bin ❌ 404, always\n * getAssetUrl({ sha, mime }, \"original\"); // → a guess from the mime table\n * getAssetUrl(asset, \"original\"); // ✓ the stored key, verbatim\n * ```\n *\n * @example Check before you link\n * ```ts\n * import { getAssetUrl, hasPreset } from \"@nitida/asset-client\";\n * const url = hasPreset(asset, \"thumb\") ? getAssetUrl(asset, \"thumb\") : null;\n * ```\n */\nexport function getAssetUrl(\n asset: Pick<AssetDTO, \"sha\"> & OriginalHints & VisibilityHint,\n preset: VariantPreset,\n): string {\n // Refuses rather than returning a URL that 404s. See `assertPublic`.\n assertSha(asset, \"getAssetUrl\");\n assertPublic(\n asset,\n \"getAssetUrl\",\n `getPrivateAssetUrl(asset, \"${preset}\", signingKey, { expiresInSeconds: 300 })`,\n );\n // ⭐ THE TWO DEFAULTS DID NOT COMPOSE, AND THIS IS THE SEAM\n //\n // `upload()` defaults to `presets: [\"original\"]` — deliberately, so a bare\n // upload never silently spends the storage budget. `urlFor()` defaults to\n // `lg`. Put together, the obvious two-line program a programmatic caller\n // writes —upload, then ask for a URL— produced a **404**, because `lg` was\n // never generated. Measured 2026-08-22: 1 290 assets are in exactly that\n // state, 228 of them in a production tenant.\n //\n // So when the DTO TELLS us the preset was never materialised, fall back to\n // the transform route, which generates it on demand and caches it. The\n // caller gets optimised bytes instead of a dead link, the raw stays\n // untouched, and no storage is spent on sizes nobody asked for — measured on\n // the same tenant, the stored ladder is 79% of raw, so pre-materialising\n // everything would be ~9 GB for sizes that may never be requested.\n //\n // Same discipline as `assertPublic`: this only fires when we were TOLD.\n // `Pick<AssetDTO,\"sha\">` carries no `presets`, so the common call is\n // untouched and no existing behaviour changes.\n const fallback = transformFallbackFor(asset, preset);\n if (fallback) return fallback;\n return buildPublicAssetUrl(asset, preset);\n}\n\n/**\n * The `/t/` URL that stands in for a preset the asset does not have, or `null`\n * when there is nothing to stand in for.\n *\n * ⚠️ It can be over-eager, and that is the honest trade. Measured 2026-08-22:\n * an asset whose row lists only `original` served `-l.webp` with a 200 — the\n * `variants` column under-reports (the contamination behind doc 240 §4.3b), so\n * the fallback sometimes pays for a transform of a rendition that already\n * exists. Both answers are correct bytes; one costs an encode. That is a much\n * smaller wrong than the 404 it replaces, and it heals itself as rows are\n * reconciled — but it is a reason to fix the rows, not to trust them more.\n *\n * Returns null — i.e. keeps the old behaviour — when the DTO does not say what\n * it has, when the preset IS present, or when the preset has no pixel ceiling\n * to translate into a width (`original`, `poster`, `video`, `hls`, `mp3`);\n * those are stored objects, not renditions, and inventing a transform for them\n * would trade a 404 for a wrong answer.\n */\nfunction transformFallbackFor(\n asset: Pick<AssetDTO, \"sha\"> & Partial<Pick<AssetDTO, \"presets\">>,\n preset: VariantPreset,\n): string | null {\n if (typeof asset.presets !== \"string\") return null;\n if (hasPreset({ presets: asset.presets }, preset)) return null;\n const maxDim = PRESET_MAX_DIM[preset];\n if (maxDim == null) return null;\n return `${cdnBaseUrl}/t/format=webp,width=${maxDim}/${asset.sha}.webp`;\n}\n\n/**\n * The public-tree URL, with no visibility check.\n *\n * Split out because `getPrivateAssetUrl` needs exactly this and must NOT be\n * refused by the guard: the private tree is the same path with `/a/` in front\n * and a signature behind, so the builder that mints a legitimate private URL\n * would otherwise be blocked by the check that exists to send callers to it.\n */\nfunction buildPublicAssetUrl(\n asset: Pick<AssetDTO, \"sha\"> & OriginalHints,\n preset: VariantPreset,\n): string {\n // The guard lives HERE and not only in `getAssetUrl`, because\n // `getPrivateAssetUrl` reaches this function directly. Without it, passing an\n // upload result produced `/a/5/v/undefined-l.webp?exp=…&sig=…` — a URL with a\n // **cryptographically valid signature over a path containing `undefined`**.\n // That is strictly worse than the public case: the signature makes it look\n // authoritative, and it passes shape checks at the edge before 404ing.\n assertSha(asset, \"getPrivateAssetUrl\");\n if (preset === \"original\") {\n // The stored URL beats every derivation, because it IS the key. Only fall\n // through to a guess when the caller gave us the sha and nothing else.\n const stored = asset.variants?.find((v) => v.preset === \"original\")?.url;\n if (stored) return stored;\n const ext = asset.oext || originalExtForMime(asset.mime);\n return `${cdnBaseUrl}/${variantPrefix()}${asset.sha}-${PRESET_SHORT.original}.${ext}`;\n }\n if (preset === \"hls\") {\n // A ladder is a PREFIX (`<sha16>-hls<dslHash>/master.m3u8`), not a\n // `<sha16>-h.m3u8` file, and `<dslHash>` is a server-side hash. Deriving a\n // key from the pattern below would produce a URL that 404s on every asset\n // — the same class of polite lie that once pointed a large share of assets\n // at a `-o.<ext>` that was never written. So: the stored URL when the DTO\n // carries it, else the\n // transform route, which 302s to the master and BUILDS the ladder if it is\n // missing. Both are real; neither is a guess.\n const stored = asset.variants?.find((v) => v.preset === \"hls\")?.url;\n if (stored) return stored;\n return `${cdnBaseUrl}/t/format=hls/${asset.sha}.m3u8`;\n }\n return `${cdnBaseUrl}/${variantPrefix()}${asset.sha}-${PRESET_SHORT[preset]}.${PRESET_EXT[preset]}`;\n}\n\n/**\n * The signed URL for one preset of a PRIVATE asset — what every refusal above\n * points at.\n *\n * ```ts\n * // On your BACKEND, once you have decided this viewer may see it:\n * const url = await getPrivateAssetUrl(asset, \"lg\", tenantSigningKey, {\n * expiresInSeconds: 300,\n * });\n * ```\n *\n * It works on a public asset too — `/a/` is a different door onto the same\n * object — but there is no reason to pay for it: a public URL is cacheable at\n * the edge and costs nothing, a signed one is neither.\n *\n * ⚠️ **Backend only.** Handing the signing key to a browser lets any visitor\n * mint URLs for every private asset the tenant owns, which is the whole\n * property the private tree exists to provide.\n *\n * ⚠️ Needs {@link setTenantId} (or a `NitidaClient` with `tenantId`), like\n * every variant URL builder: the tenant segment is base36 and part of what the\n * signature covers, so a missing tenant does not produce a wrong URL — it\n * produces an unsignable one.\n */\nexport async function getPrivateAssetUrl(\n asset: Pick<AssetDTO, \"sha\"> & OriginalHints,\n preset: VariantPreset,\n signingKey: string,\n opts: SignAccessOptions,\n): Promise<string> {\n return signAccessUrl(buildPublicAssetUrl(asset, preset), signingKey, opts);\n}\n\n/**\n * The signed URL for a TRANSFORM of a private asset — an arbitrary width, crop\n * or format, not just the sizes that happen to be materialised.\n *\n * ```ts\n * const url = await getPrivateTransformUrl(\n * asset,\n * { width: 1280, format: \"webp\" },\n * tenantSigningKey,\n * { expiresInSeconds: 300 },\n * );\n * // → https://8ok.uk/a/5/t/format=webp,width=1280/<sha>.webp?exp=…&sig=…\n * ```\n *\n * Why this exists at all: a private asset that can only be served at the sizes\n * someone already generated is barely a product. The signed tree mirrors the\n * public one, transforms included.\n *\n * Returns `null` when `opts` serialize to an empty DSL — same contract as\n * {@link getTransformUrl}, because \"no transform requested\" is not an error,\n * it just means you wanted {@link getPrivateAssetUrl}.\n *\n * ⚠️ **Backend only**, like every signer here. And note the width is a plain\n * `number`: a signed URL is a trusted caller, so the edge ladder does not\n * apply — the same rule `getSignedTransformUrl` already follows.\n */\nexport async function getPrivateTransformUrl(\n asset: Pick<AssetDTO, \"sha\">,\n opts: SignedTransformOptions,\n signingKey: string,\n signOpts: SignAccessOptions,\n): Promise<string | null> {\n const url = getTransformUrlUnchecked(asset, opts);\n if (!url) return null;\n // `/t/<dsl>/<file>` has no tenant in it — the private tree needs one, and it\n // is the same process-global the variant builders use.\n const tid = getTenantId();\n if (tid == null) {\n throw new Error(\n \"getPrivateTransformUrl: no tenant is configured. Call setTenantId(id) (or construct a NitidaClient with `tenantId`) — the tenant is part of what the signature covers, so this cannot be guessed.\",\n );\n }\n const u = new URL(url);\n return signAccessUrl(\n `${u.origin}/${tid.toString(36)}${u.pathname}`,\n signingKey,\n signOpts,\n );\n}\n\n/**\n * Did the processor actually generate this preset?\n *\n * Reads `dto.presets` — the compact 1-char code string the server sends on EVERY shape, including\n * the slim list/resolver one that carries no `variants` at all. That is why this exists and why it\n * stays the right existence check even now that `GET /assets/:id` really does send `variants`\n * (it did not until the 2026-08-17 deploy).\n *\n * @example\n * ```ts\n * import { hasPreset } from \"@nitida/asset-client\";\n *\n * hasPreset({ presets: \"oq\" }, \"original\"); // → true (\"o\" = original, \"q\" = thumb)\n * hasPreset({ presets: \"oq\" }, \"lg\"); // → false — do not link to it\n * hasPreset({ presets: \"pv\" }, \"aiproxy\"); // → false — video without the AI proxy\n * ```\n */\nexport function hasPreset(\n asset: Pick<AssetDTO, \"presets\">,\n preset: VariantPreset,\n): boolean {\n if (preset === \"mp3\") return asset.presets.includes(\"mp3\");\n return stripMultiCharTokens(asset.presets).includes(PRESET_SHORT[preset]);\n}\n\n/**\n * Remove every MULTI-character token from a `presets` string, leaving only the\n * 1-char codes that a `.includes` can safely be run against.\n *\n * `presets` is documented as a concatenation of 1-char codes, and membership is\n * a 1-char substring test — so any longer token is a false-positive generator.\n * Servers before the 2026-08-17 deploy emitted several:\n *\n * | token in the string | letters it donates | presets it falsely answers |\n * |---|---|---|\n * | `transform-<hex>` | t r a n s f o m + a–f | `sm` `md` `original` `aiproxy` |\n * | `pr` (probe) | p r | `poster` |\n * | `mp3` (audio) | m p | `md` `poster` — stripped here since forever |\n *\n * This is not theoretical: on a corpus written by pre-2026-08-17 servers the\n * majority of rows carried a polluted string, and a large minority of those\n * were told they have an `original` they do not — which builds a `-o.<ext>`\n * URL that 404s. `aiproxy` is affected at the same order of magnitude, `sm`\n * and `md` far less, and the `pr` → `poster` collision is real but rare.\n *\n * Current servers no longer emit these, but this stays: older server deploys\n * keep sending them, and this is the check every guide points at as the\n * reliable one. Order matters — strip the longest tokens first.\n */\nfunction stripMultiCharTokens(presets: string): string {\n return presets\n .replace(/transform-[0-9a-f]*/g, \"\")\n .replace(/upscale_[a-z0-9_]*/g, \"\")\n .replace(/mp3/g, \"\")\n .replace(/u[2-8]|t[1248ghij]/g, \"\")\n .replace(/pr/g, \"\");\n}\n\n/**\n * Build a srcSet string for responsive `<img>`. Walks the available image\n * presets in size order and only includes the ones the asset actually has.\n *\n * <img\n * src={getAssetUrl(asset, 'web')}\n * srcSet={getAssetSrcSet(asset)}\n * sizes=\"(max-width: 768px) 100vw, 800px\"\n * />\n */\nconst IMAGE_PRESETS: VariantPreset[] = [\"thumb\", \"sm\", \"md\", \"lg\", \"xl\"];\nexport function getAssetSrcSet(\n asset: Pick<AssetDTO, \"sha\" | \"presets\"> & VisibilityHint,\n): string {\n // ⚠️ NOT given the transform fallback that `getAssetUrl` has, on purpose.\n //\n // A srcSet is a set of PROMISES about pixel width, and the fallback cannot\n // keep them. `/t/width=3840/` on a 900 px source returns 900 px — sharp runs\n // `withoutEnlargement: true` — so the candidate would advertise 3840w and\n // deliver 900, and the browser would pick it for a large viewport and get\n // the small image. That is worse than the empty srcSet it replaces: an empty\n // srcSet degrades to `src`, which now resolves through the fallback and\n // works. A lying srcSet degrades to a wrong choice, silently.\n //\n // Doing this properly means capping the rungs by the asset's real width, and\n // this signature does not carry it (`Pick<AssetDTO,\"sha\"|\"presets\">`). Worth\n // doing; not worth guessing.\n assertSha(asset, \"getAssetSrcSet\");\n assertPublic(\n asset,\n \"getAssetSrcSet\",\n \"getPrivateAssetUrl(asset, preset, signingKey, { expiresInSeconds: 300 }) per preset\",\n );\n return IMAGE_PRESETS.filter(\n (p) => hasPreset(asset, p) && PRESET_MAX_DIM[p] != null,\n )\n .map((p) => `${getAssetUrl(asset, p)} ${PRESET_MAX_DIM[p]}w`)\n .join(\", \");\n}\n\n/**\n * Compute the dimensions a variant would have given the source asset's\n * width/height and the variant's bounding box. For thumbnails (square\n * smart-crop) the result is always 256×256; for the other presets, scales\n * the max side to the box dimension and the other side proportionally.\n */\nexport function computeVariantDimensions(\n asset: Pick<AssetDTO, \"w\" | \"h\">,\n preset: VariantPreset,\n): { width: number; height: number } | null {\n const cap = PRESET_MAX_DIM[preset];\n if (cap == null) return null;\n if (preset === \"thumb\") return { width: cap, height: cap };\n if (!asset.w || !asset.h) return null;\n const scale = Math.min(cap / asset.w, cap / asset.h, 1);\n return {\n width: Math.round(asset.w * scale),\n height: Math.round(asset.h * scale),\n };\n}\n\n/** Source dimensions, for aspect-ratio sizing. */\nexport function getAssetDimensions(\n asset: Pick<AssetDTO, \"w\" | \"h\">,\n): { width: number; height: number } | null {\n if (asset.w && asset.h) return { width: asset.w, height: asset.h };\n return null;\n}\n\n// ---------------------------------------------------------------------------\n// Slot system — tenant-named asset bindings.\n// See ./slots for full docs.\n// ---------------------------------------------------------------------------\n\nexport {\n configureSlotResolver,\n invalidateSlotCache,\n type ResolveSlotOptions,\n resolveSlot,\n resolveSlots,\n type SlotDTO,\n type SlotResolution,\n} from \"./slots\";\n\n// ---------------------------------------------------------------------------\n// On-the-fly transforms — see ./transform for docs.\n// ---------------------------------------------------------------------------\n\nexport {\n extractAssetSha,\n getHlsStreamingUrl,\n getSignedTransformUrl,\n getTransformSrcSet,\n getTransformUrl,\n getVideoTransformUrl,\n type SignedTransformOptions,\n serializeTransform,\n signTransformUrl,\n TRANSFORM_WIDTHS,\n type TransformEffect,\n type TransformFit,\n type TransformFormat,\n type TransformGravity,\n type TransformOptions,\n type TransformWidth,\n} from \"./transform\";\n"],"mappings":"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","names":["toHex"]}
|
|
1
|
+
{"version":3,"sources":["../src/access.ts","../src/palette.ts","../src/transform.ts","../src/slots.ts","../src/index.ts"],"sourcesContent":["/**\n * Signed URLs for PRIVATE assets — the `/a/{tenant}/…?exp&sig` tree.\n *\n * ## Who calls this, and who must not\n *\n * The tenant's BACKEND, which knows who the viewer is and holds the signing\n * key. Never a browser: shipping the signing key to the client would let any\n * visitor mint URLs for any private asset of that tenant, which is the whole\n * property the tree exists to provide. This module is deliberately importable\n * from anywhere — it runs on WebCrypto, so browsers, Node, Bun and Workers all\n * work — and that convenience is exactly why the warning is here rather than\n * in a doc nobody reads at the call site.\n *\n * ## Not the same signature as `signTransformUrl`\n *\n * `signTransformUrl` vouches for a WIDTH; this vouches for a VIEWER, until\n * `exp`. They come from the same `signing_key` but not the same key material:\n * the access key is derived (`HMAC(signing_key, \"nitida/access/v1\")`) so that\n * no crafted transform path can be replayed as an access signature. The full\n * argument lives beside the server implementation in `access-signing.ts`; the\n * short version is that a shared payload with a prefix separator IS\n * collidable, because both fields of the transform message are\n * attacker-influenced path segments.\n *\n * ## `exp` is mandatory\n *\n * A signed URL that never expires is a public URL as soon as someone forwards\n * it. There is no \"no expiry\" option here, and there will not be one.\n *\n * ## Where `signingKey` comes from\n *\n * The response that created your project. `POST /admin/projects` returns\n * `signingKey` next to the three API keys, and the console shows it in the\n * same panel — **once**. Save it with the keys.\n *\n * If it is gone — or you never saw one, which is the case for every project\n * created before 2026-08-23 — the only endpoint that returns a key is\n * `POST /admin/projects/:code/rotate-signing-key`, and rotating invalidates\n * every URL already signed. That is free for a tenant with nothing in flight\n * and expensive for a live one, which is exactly why the key is handed over at\n * creation, when rotating would be free anyway. With URLs already circulating,\n * ask the platform operator for the current key rather than rotating.\n */\n\nconst ACCESS_KEY_INFO = \"nitida/access/v1\";\n\nasync function hmac(\n key: ArrayBuffer | Uint8Array,\n message: string,\n): Promise<Uint8Array> {\n const cryptoKey = await crypto.subtle.importKey(\n \"raw\",\n key as unknown as ArrayBuffer,\n { name: \"HMAC\", hash: \"SHA-256\" },\n false,\n [\"sign\"],\n );\n return new Uint8Array(\n await crypto.subtle.sign(\n \"HMAC\",\n cryptoKey,\n new TextEncoder().encode(message),\n ),\n );\n}\n\nconst toHex = (b: Uint8Array) =>\n [...b].map((x) => x.toString(16).padStart(2, \"0\")).join(\"\");\n\n/**\n * The per-tenant access key, derived from `signing_key`. Same value the origin\n * computes and hands the edge — nothing needs to be stored or synchronised.\n */\nexport async function deriveAccessKey(signingKey: string): Promise<Uint8Array> {\n return hmac(new TextEncoder().encode(signingKey), ACCESS_KEY_INFO);\n}\n\n/**\n * The signed message. Byte-identical to the server's `accessMessage` and the\n * worker's — three implementations of one string, which is why all three pin\n * the exact bytes in a test.\n *\n * `tenantPrefix` is the base36 segment as it appears in the URL, not the\n * decimal id: tenant 10 lives at `/a/a/`, and the thing being vouched for is a\n * path.\n */\nexport function accessMessage(\n tenantPrefix: string,\n exp: number,\n resourcePath: string,\n): string {\n return `${tenantPrefix}\\n${exp}\\n${resourcePath.replace(/^\\/+/, \"\")}`;\n}\n\nexport type SignAccessOptions = {\n /** Lifetime in seconds. Required — see the header. */\n expiresInSeconds: number;\n /** Injectable clock, for tests that need a URL already dead on arrival. */\n nowSeconds?: number;\n};\n\n/**\n * Turn a PUBLIC-tree URL into a signed PRIVATE-tree URL.\n *\n * https://8ok.uk/5/v/<sha16>-lg.webp\n * → https://8ok.uk/a/5/v/<sha16>-lg.webp?exp=…&sig=…\n *\n * Accepts a URL that is already under `/a/` and re-signs it, so calling twice\n * is not an error and does not produce `/a/a/`.\n */\nexport async function signAccessUrl(\n publicUrl: string,\n signingKey: string,\n opts: SignAccessOptions,\n): Promise<string> {\n if (!Number.isFinite(opts.expiresInSeconds) || opts.expiresInSeconds <= 0) {\n throw new Error(\n \"signAccessUrl: `expiresInSeconds` must be a positive number — a signed URL without an expiry is a public URL the moment it is forwarded.\",\n );\n }\n const u = new URL(publicUrl);\n const segments = u.pathname.split(\"/\").filter(Boolean);\n // ⚠️ `a` is BOTH the private tree's prefix and tenant 10 in base36, so\n // \"starts with /a/ ⇒ already signed\" would eat tenant 10's own segment and\n // sign `/a/v/…` as if `v` were the tenant. What tells them apart is the\n // resource kind, always `v` or `r` directly after the tenant: the private\n // tree is `/a/<tenant>/<v|r>/…`, and tenant 10's public `/a/v/<sha>-lg.webp`\n // is not. Same base36 trap that makes `/10/` the platform's favourite 404.\n // `[vrt]`, not `[vr]`: `t` joined the private tree when transforms did, and\n // this line was left behind — so re-signing `/a/5/t/<dsl>/<sha>.webp` read\n // `a` as the tenant and `5` as the resource kind, and threw. The check two\n // dozen lines below already said `[vrt]`; a regex that disagrees with its own\n // file is the shape this bug always takes.\n if (segments[0] === \"a\" && segments[2] && /^[vrt]$/.test(segments[2])) {\n segments.shift();\n }\n const tenantPrefix = segments.shift();\n if (!tenantPrefix || segments.length === 0) {\n throw new Error(\n `signAccessUrl: expected a tenant-prefixed CDN path like /<tenant>/v/<sha>-<preset>.<ext>, got ${u.pathname}`,\n );\n }\n // ⭐ The segment after the tenant is ALWAYS the resource kind. Checking it is\n // not pedantry — it catches the one mistake this signature shape invites.\n //\n // `/t/<dsl>/<sha>.webp` is a real, valid public transform URL, and it is the\n // obvious thing to hand this function. Without this check `t` is read as the\n // TENANT (29 in base36) and the result is `/a/t/<dsl>/…`: a URL that is\n // perfectly signed, structurally plausible, and 404s for a reason nobody can\n // see. Found by using it, 2026-08-22, on the very first private transform.\n //\n // A transform needs its tenant prepended first — which is exactly what\n // `getPrivateTransformUrl` does, so the fix is almost always to call that.\n if (!/^[vrt]$/.test(segments[0]!)) {\n throw new Error(\n `signAccessUrl: expected /<tenant>/<v|r|t>/… but the segment after the tenant is \"${segments[0]}\". ` +\n (segments[0]?.includes(\"=\")\n ? `That looks like a transform DSL, so the path is probably /t/<dsl>/<sha>.<ext> — which has no tenant in it (\\`t\\` here was read as tenant ${Number.parseInt(tenantPrefix, 36)}). Use getPrivateTransformUrl(asset, opts, signingKey, { expiresInSeconds }) instead.`\n : `Got ${u.pathname}.`),\n );\n }\n const resourcePath = segments.join(\"/\");\n const now = opts.nowSeconds ?? Math.floor(Date.now() / 1000);\n const exp = now + Math.floor(opts.expiresInSeconds);\n\n const sig = toHex(\n await hmac(\n await deriveAccessKey(signingKey),\n accessMessage(tenantPrefix, exp, resourcePath),\n ),\n );\n\n u.pathname = `/a/${tenantPrefix}/${resourcePath}`;\n u.searchParams.set(\"exp\", String(exp));\n u.searchParams.set(\"sig\", sig);\n return u.toString();\n}\n\n/** What every URL builder accepts so it can refuse a doomed URL. */\nexport type VisibilityHint = { visibility?: \"public\" | \"private\" };\n\n/**\n * Refuse to build a public URL for a private asset.\n *\n * Doctrine of the house: **a silence reads as \"you can't\"**. Returning\n * `https://8ok.uk/5/v/<sha>-lg.webp` for a private asset is not a smaller\n * failure than throwing — it is a URL that answers 404, in a platform where a\n * 404 has always meant \"that file does not exist\". The caller then debugs the\n * wrong thing.\n *\n * Only refuses when it was actually TOLD. A caller passing `{ sha }` carries no\n * visibility, and guessing would break every existing call site to protect\n * assets that are not there.\n */\nexport function assertPublic(\n asset: VisibilityHint,\n fn: string,\n /**\n * The call to make instead — declared per call site, not guessed.\n *\n * Named `escapeHatch`, not `escape`: the bare name shadows the deprecated\n * global `escape`, which biome flags as an error. Nothing here calls that\n * global, so this was never a defect — but it is a lint error standing in a\n * PUBLISHED package, and a parameter name is not part of the API, so the\n * cost of clearing it is zero.\n *\n * It matters which one: `getPrivateAssetUrl` signs a STORED preset, and\n * pointing a transform caller at it sends them to a function that cannot do\n * what they asked for. The first version of this message named\n * `getPrivateAssetUrl` for all seven builders; a test caught it.\n */\n escapeHatch: string,\n): void {\n if (asset.visibility !== \"private\") return;\n throw new Error(\n `${fn}: this asset is private, so a public CDN URL for it will answer 404 — that is the feature, not a missing file. ` +\n `Mint a signed URL on your BACKEND instead: await ${escapeHatch}. ` +\n \"Never ship the signing key to a browser.\",\n );\n}\n\n/**\n * Refuse a value whose `sha` is missing or malformed, instead of interpolating\n * it into a URL.\n *\n * ## Found by a Haiku agent, 2026-08-23\n *\n * It did the most natural thing there is — passed the result of `upload()`\n * straight to `transform()` — and got:\n *\n * https://8ok.uk/t/width=1280/undefined.webp\n *\n * `UploadResult` carries `sha256`; every URL builder wants `sha`. TypeScript\n * catches the mismatch, but an agent running through `bun` (or anyone in plain\n * JS) sees no error at all: just a 200-shaped URL with the word `undefined` in\n * it, which 404s later and somewhere else.\n *\n * The house rule applies exactly as it does to private assets: **a silence\n * reads as \"you can't\"**. A builder that cannot name the asset must say so at\n * the call site, not hand back a string that will fail far from here.\n */\nexport function assertSha(asset: { sha?: unknown }, fn: string): void {\n const sha = asset?.sha;\n if (typeof sha === \"string\" && /^[0-9a-f]{16,64}$/i.test(sha)) return;\n const hint =\n asset && typeof asset === \"object\" && \"sha256\" in asset\n ? \" The value you passed has `sha256` but not `sha` — that is the shape `upload()` returns. Use `{ sha: result.sha256.slice(0, 16) }`, or fetch the DTO with `assets.get(id)`.\"\n : ` Got ${JSON.stringify(sha)}.`;\n throw new Error(\n `${fn}: no usable \\`sha\\` on the value you passed, so the URL would contain \"undefined\" and 404 somewhere else.${hint}`,\n );\n}\n","/**\n * Color palette helpers — render harmonious ambient backgrounds behind\n * product images, inspired by Spotify Now Playing / Apple Music / Pico.\n *\n * Wire format is intentionally compact: only the hex per swatch, only the\n * swatches the source actually had. The full names map to 1-2 letter aliases\n * (`d` dominant, `v` vibrant, `m` muted, `dv` darkVibrant, `lv` lightVibrant,\n * `dm` darkMuted, `lm` lightMuted) to shave bytes for catalog-sized payloads\n * (palette was 62% of asset DTO before this).\n *\n * Population + RGB array + textColor are derivable client-side; we don't\n * ship them. textColor is computed via WCAG relative luminance on demand.\n */\n\n/** Compact wire shape for an asset's palette. All swatches optional except dominant. */\nexport type AssetPalette = {\n /** dominant hex (always present when palette exists) */\n d: string;\n /** vibrant */ v?: string;\n /** muted */ m?: string;\n /** darkVibrant */ dv?: string;\n /** lightVibrant */ lv?: string;\n /** darkMuted */ dm?: string;\n /** lightMuted */ lm?: string;\n};\n\n/** Backwards-compat alias for older callers that referenced PaletteSwatch. */\nexport type PaletteSwatch = { hex: string; textColor: \"#000000\" | \"#FFFFFF\" };\n\n/**\n * Resolve a palette key to its hex value if present.\n */\nfunction resolveSwatch(\n palette: AssetPalette | null | undefined,\n ...keys: (keyof AssetPalette)[]\n): string | null {\n if (!palette) return null;\n for (const k of keys) {\n const v = palette[k];\n if (v) return v;\n }\n return null;\n}\n\n/**\n * Pick the swatch best suited for an ambient surface behind the image.\n * Prefers muted/light tones — too vibrant a background fights the image.\n *\n * Order: lightMuted → muted → lightVibrant → dominant.\n */\nexport function pickAmbientBackground(\n palette: AssetPalette | null | undefined,\n): PaletteSwatch | null {\n const hex = resolveSwatch(palette, \"lm\", \"m\", \"lv\", \"d\");\n if (!hex) return null;\n return { hex, textColor: textColorForHex(hex) };\n}\n\n/**\n * Build a CSS linear-gradient from the palette. Useful for hero / detail\n * backgrounds.\n */\nexport function getAmbientGradient(\n palette: AssetPalette | null | undefined,\n opts: {\n angle?: string;\n from?: keyof AssetPalette;\n to?: keyof AssetPalette;\n } = {},\n): string | undefined {\n if (!palette) return undefined;\n const fromHex = palette[opts.from ?? \"lm\"] ?? palette.m ?? palette.d;\n const toHex = palette[opts.to ?? \"m\"] ?? palette.dm ?? palette.d;\n if (!fromHex || !toHex) return undefined;\n return `linear-gradient(${opts.angle ?? \"135deg\"}, ${fromHex}, ${toHex})`;\n}\n\n/**\n * Recommended text color (#000 or #FFF) for any background hex,\n * computed via WCAG relative luminance.\n */\nexport function getTextColorForBackground(\n swatch: PaletteSwatch | string | null | undefined,\n): string {\n if (!swatch) return \"#000000\";\n const hex = typeof swatch === \"string\" ? swatch : swatch.hex;\n return textColorForHex(hex);\n}\n\n/** Luminancia relativa WCAG de un hex. 0 = negro, 1 = blanco. */\nexport function relativeLuminance(hex: string): number {\n const h = hex.replace(\"#\", \"\");\n const r = Number.parseInt(h.slice(0, 2), 16) / 255;\n const g = Number.parseInt(h.slice(2, 4), 16) / 255;\n const b = Number.parseInt(h.slice(4, 6), 16) / 255;\n // sRGB → linear\n const lin = (c: number) =>\n c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;\n return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);\n}\n\n/**\n * WCAG contrast ratio between two luminances: `(L1 + 0.05) / (L2 + 0.05)`,\n * lighter on top. 1 = identical, 21 = black against white.\n */\nexport function contrastRatio(l1: number, l2: number): number {\n const [hi, lo] = l1 >= l2 ? [l1, l2] : [l2, l1];\n return (hi + 0.05) / (lo + 0.05);\n}\n\n/**\n * Black or white — whichever **actually** contrasts more against this\n * background.\n *\n * The tie point is NOT `L > 0.5`, which is the threshold most implementations\n * reach for. Equating the two contrast ratios gives it exactly:\n *\n * (1 + 0.05) / (L + 0.05) = (L + 0.05) / 0.05\n * (L + 0.05)² = 0.0525\n * L = 0.1791…\n *\n * Between **0.179 and 0.5** a `L > 0.5` test picks WHITE while black contrasts\n * more — a wide band, and exactly where saturated brand colours land. A\n * mid-luminance gold in that band can be handed white at **2.68:1** when black\n * would give **7.84:1**; 2.68 does not pass AA even for large text.\n *\n * So this does not swap one threshold for another: it **computes both ratios\n * and returns the winner**. A threshold is a constant somebody has to keep\n * correct; the comparison is correct by construction.\n *\n * ⚠️ This picks the BETTER of two — it does not guarantee the result is\n * enough. Over a mid-luminance background the best pair can still land under\n * 4.5:1. Use {@link bestTextContrast} when you need to know: it returns the\n * ratio it achieved.\n */\nfunction textColorForHex(hex: string): \"#000000\" | \"#FFFFFF\" {\n return bestTextContrast(hex).color;\n}\n\n/**\n * Same choice as the recommended text colour, but it also returns **the ratio\n * it achieved** and whether that passes AA — so a caller can decide with the\n * number in front of them instead of assuming it was enough.\n */\nexport function bestTextContrast(hex: string): {\n color: \"#000000\" | \"#FFFFFF\";\n ratio: number;\n passesAA: boolean;\n passesAALarge: boolean;\n} {\n const L = relativeLuminance(hex);\n const onBlack = contrastRatio(L, 0);\n const onWhite = contrastRatio(L, 1);\n const useBlack = onBlack >= onWhite;\n const ratio = useBlack ? onBlack : onWhite;\n return {\n color: useBlack ? \"#000000\" : \"#FFFFFF\",\n ratio,\n passesAA: ratio >= 4.5,\n passesAALarge: ratio >= 3,\n };\n}\n\n/**\n * CSS variables for a wrapper so a subtree can read --asset-bg / --asset-fg /\n * --asset-dominant / --asset-vibrant / etc.\n */\nexport function getPaletteCssVars(\n palette: AssetPalette | null | undefined,\n): Record<string, string> {\n if (!palette) return {};\n const bg = pickAmbientBackground(palette);\n return {\n \"--asset-bg\": bg?.hex ?? \"transparent\",\n \"--asset-fg\": bg ? bg.textColor : \"#000000\",\n \"--asset-dominant\": palette.d,\n ...(palette.v && { \"--asset-vibrant\": palette.v }),\n ...(palette.m && { \"--asset-muted\": palette.m }),\n ...(palette.lv && { \"--asset-light-vibrant\": palette.lv }),\n ...(palette.dv && { \"--asset-dark-vibrant\": palette.dv }),\n ...(palette.lm && { \"--asset-light-muted\": palette.lm }),\n ...(palette.dm && { \"--asset-dark-muted\": palette.dm }),\n };\n}\n\n/**\n * Iterate the palette in display order (dominant first, then vibrant +\n * muted families). Useful for rendering a swatch strip in admin UIs.\n */\nexport function iteratePaletteSwatches(\n palette: AssetPalette | null | undefined,\n): Array<{ key: keyof AssetPalette; label: string; hex: string }> {\n if (!palette) return [];\n const order: Array<{ key: keyof AssetPalette; label: string }> = [\n { key: \"d\", label: \"dominant\" },\n { key: \"v\", label: \"vibrant\" },\n { key: \"lv\", label: \"lightVibrant\" },\n { key: \"dv\", label: \"darkVibrant\" },\n { key: \"m\", label: \"muted\" },\n { key: \"lm\", label: \"lightMuted\" },\n { key: \"dm\", label: \"darkMuted\" },\n ];\n return order\n .map(({ key, label }) => {\n const hex = palette[key];\n return hex ? { key, label, hex } : null;\n })\n .filter(\n (s): s is { key: keyof AssetPalette; label: string; hex: string } =>\n s != null,\n );\n}\n\n/**\n * Build a multi-radial-gradient CSS `background` string from the palette\n * swatches. Acts as a zero-extra-bytes alternative to the WebP LQIP: the\n * palette is already in the DTO, so this placeholder costs nothing extra\n * to ship. Renders as a smooth abstract \"color cloud\" reminiscent of the\n * source image's vibe.\n *\n * Strategy: anchor 4 radial gradients at fixed corners using vibrant/muted\n * pairs, layered over the dominant fill. Skips missing swatches gracefully.\n */\nexport function getPaletteBlurBackground(\n palette: AssetPalette | null | undefined,\n): string | undefined {\n if (!palette) return undefined;\n const corners: Array<{ pos: string; key: keyof AssetPalette }> = [\n { pos: \"20% 20%\", key: \"lv\" },\n { pos: \"80% 25%\", key: \"v\" },\n { pos: \"25% 80%\", key: \"lm\" },\n { pos: \"80% 80%\", key: \"dv\" },\n ];\n const layers = corners\n .map(({ pos, key }) => {\n const hex = palette[key];\n if (!hex) return null;\n return `radial-gradient(circle at ${pos}, ${hex} 0%, transparent 55%)`;\n })\n .filter(Boolean) as string[];\n // Fallback fill = dominant (or muted if dominant is missing — shouldn't happen)\n const base = palette.d ?? palette.m ?? \"#888\";\n return layers.length > 0 ? `${layers.join(\", \")}, ${base}` : base;\n}\n","/**\n * On-the-fly transform URL builder.\n *\n * Mirrors the server's DSL canonicalizer byte-for-byte so a URL generated\n * here hashes to the same cache key as the server's canonical form.\n *\n * Canonicalization rules (kept in sync with the server):\n * - Drop entries whose value is `undefined`\n * - Sort keys alphabetically\n * - Numbers rendered without leading zeros or trailing dots\n * - String values lowercased\n *\n * URL shape:\n * <cdnBase>/t/<dsl>/<sha>.<ext>\n *\n * The `.ext` is informational (browser content-sniff hint); the server\n * decides the actual output format from the DSL `format` param + the\n * request's Accept header.\n */\n\nimport { assertPublic, assertSha, type VisibilityHint } from \"./access\";\nimport type { AssetDTO } from \"./index\";\nimport { getCdnBase } from \"./index\";\n\n/**\n * Widths the CDN edge whitelists (DoS guard).\n * Requesting any OTHER width returns HTTP 400 at the edge (unsigned URLs). These are the\n * 1× base ladder values; DPR ×2/×3 multiples are applied + whitelisted server-side. Import\n * this instead of hardcoding magic widths so an unsupported size is caught in review/IDE.\n */\nexport const TRANSFORM_WIDTHS = [\n 96, 128, 160, 240, 256, 320, 400, 480, 600, 640, 800, 960, 1080, 1200, 1280,\n 1440, 1600, 1920, 2560, 3840,\n] as const;\n/**\n * A CDN-whitelisted transform width — the only widths `TransformOptions.width`\n * accepts. Off-ladder widths are a compile error; for signed URLs that need a\n * custom width, use {@link SignedTransformOptions} (number) via\n * {@link getSignedTransformUrl} / `aq.transform(asset, opts, { sign: true })`.\n */\nexport type TransformWidth = (typeof TRANSFORM_WIDTHS)[number];\n\nexport type TransformFit = \"cover\" | \"contain\" | \"fill\" | \"inside\" | \"outside\";\nexport type TransformGravity =\n | \"auto\"\n | \"face\"\n | \"center\"\n | \"north\"\n | \"south\"\n | \"east\"\n | \"west\";\nexport type TransformFormat =\n | \"auto\"\n | \"avif\"\n | \"webp\"\n | \"jpeg\"\n | \"png\"\n // Video-only formats (Phase 4). The image path ignores them.\n | \"mp4\"\n | \"webm\"\n // HLS adaptive ladder (Phase 5). Video-only. Output is a directory\n // of m3u8 + .ts segments fronted by master.m3u8; the SDK returns\n // the master URL via `getHlsStreamingUrl`.\n | \"hls\";\nexport type TransformEffect = \"removebg\" | \"genfill\";\n\nexport type TransformOptions = {\n /**\n * Target max-side width in CSS pixels (multiplied by `dpr` server-side).\n * MUST be a {@link TRANSFORM_WIDTHS} value — off-ladder widths are rejected\n * (HTTP 400) by the edge whitelist for unsigned URLs, so the type forbids\n * them at compile time. For SIGNED URLs with a custom width, use\n * {@link SignedTransformOptions} (which widens this to `number`).\n */\n width?: TransformWidth;\n /** Target max-side height. Multiplied by `dpr` server-side. */\n height?: number;\n /** Resize fit mode. Default `cover` server-side. */\n fit?: TransformFit;\n /** Crop gravity. `auto` picks the region with the most visual salience. */\n gravity?: TransformGravity;\n /** Output format. `auto` → the platform's policy decides. */\n format?: TransformFormat;\n /** Output quality. `auto` → format-specific default. */\n quality?: \"auto\" | number;\n /** Device pixel ratio. Width/height are multiplied by this before resize. */\n dpr?: 1 | 2 | 3;\n /**\n * AI effect applied before resize/encode.\n *\n * - `removebg`: remove the background; output is a transparent PNG\n * of the foreground subject. Forces `format=png` regardless of\n * other format hints. A single cache miss per (sha, dsl) tuple;\n * subsequent identical DSLs serve from cache — no inference, no\n * per-image cost.\n *\n * - `genfill`: aspect-extension outpaint. Requires BOTH `width`\n * and `height` — the server\n * fits the source centered into the target canvas and outpaints\n * the gutters. Output is PNG (forced) at exactly target dims.\n * ~$0.05/image first time; same cache as removebg after.\n * Primary use case: building OG cards (1200×630) from portrait\n * listing photos without awkward edge mirroring.\n */\n effect?: TransformEffect;\n /**\n * Video-only: clip start in seconds. Image transforms ignore.\n * Accepts decimals (e.g. 1.5 for sub-second seek).\n */\n start?: number;\n /**\n * Video-only: clip duration in seconds (1..300). Image transforms\n * ignore. With `start`, lets a single request grab a sub-clip.\n */\n duration?: number;\n};\n\n/**\n * Like {@link TransformOptions} but with `width` widened to any `number` —\n * the escape hatch for SIGNED URLs that need an off-ladder custom width.\n *\n * The edge whitelist only rejects off-ladder widths on UNSIGNED URLs; a valid\n * `?sig=` earns the whitelist bypass at the edge (the server still\n * does the real HMAC check). So a custom width is ONLY safe when the URL is\n * signed — hence this type is accepted exclusively by the signing helpers\n * ({@link getSignedTransformUrl} / `aq.transform(asset, opts, { sign: true })`),\n * never by the plain unsigned {@link getTransformUrl}.\n */\nexport type SignedTransformOptions = Omit<TransformOptions, \"width\"> & {\n /** Off-ladder width — valid ONLY on signed URLs (edge whitelist bypass). */\n width?: number;\n};\n\n/**\n * Serialize transform options into the canonical DSL path segment.\n * Empty options return an empty string (caller should fall back to a\n * variant URL instead of a transform URL in that case).\n *\n * Accepts {@link SignedTransformOptions} (the wider type) so it also covers\n * custom-width signed URLs; {@link TransformOptions} is assignable to it.\n */\n/**\n * Extract the 16-hex short sha from an aquienpz CDN URL, regardless of\n * shape:\n * - tenant-prefixed variant: `https://8ok.uk/4/v/<sha16>-<preset>.<ext>`\n * - legacy variant: `https://8ok.uk/<sha16>-<preset>.<ext>`\n * - on-the-fly transform: `https://8ok.uk/t/<dsl>/<sha16>.<ext>`\n * - streaming HLS: `https://8ok.uk/t/format=hls/<sha16>.m3u8`\n *\n * Returns `null` for non-aquienpz URLs (pexels, googleusercontent, raw\n * uploaded URLs to other CDNs) so call sites can fall back to the URL\n * with a plain `<img>` instead of generating a broken transform URL.\n *\n * Useful when a value reaches the component as a pre-built URL string\n * (legacy data, site-config JSON, third-party feeds) but you want to\n * drop in `getTransformSrcSet` for the responsive ladder if it happens\n * to be an aquienpz asset.\n */\nexport function extractAssetSha(url: string | null | undefined): string | null {\n if (!url) return null;\n // Match the canonical aquienpz sha pattern: 16 lowercase hex chars\n // appearing as a path segment, optionally followed by `-<preset>`\n // (variant URL) or `.<ext>` (transform URL).\n const m = url.match(/\\/([0-9a-f]{16})(?:[-.]|$)/);\n return m ? m[1]! : null;\n}\n\nexport function serializeTransform(opts: SignedTransformOptions): string {\n const entries: Array<[string, string]> = [];\n const keys = Object.keys(opts).sort() as Array<keyof SignedTransformOptions>;\n for (const k of keys) {\n const v = opts[k];\n if (v == null) continue;\n const serialized = typeof v === \"string\" ? v.toLowerCase() : String(v);\n entries.push([k, serialized]);\n }\n return entries.map(([k, v]) => `${k}=${v}`).join(\",\");\n}\n\nfunction extForOptions(opts: SignedTransformOptions): string {\n // effect=removebg forces PNG output server-side (needs alpha).\n if (opts.effect === \"removebg\") return \"png\";\n // effect=genfill defaults to WebP (12× lighter than the raw generated\n // PNG output with no visible loss at q=85). Explicit `format=png`\n // opts back into lossless for print / marketing fold-outs. The server\n // re-encodes the generated PNG → target format before caching.\n if (opts.effect === \"genfill\") {\n switch (opts.format) {\n case \"png\":\n return \"png\";\n case \"avif\":\n return \"avif\";\n case \"jpeg\":\n return \"jpg\";\n default:\n return \"webp\";\n }\n }\n switch (opts.format) {\n case \"avif\":\n return \"avif\";\n case \"jpeg\":\n return \"jpg\";\n case \"png\":\n return \"png\";\n case \"mp4\":\n return \"mp4\";\n case \"webm\":\n return \"webm\";\n case \"hls\":\n // HLS uses `.m3u8` as the URL extension; the route resolves the\n // master playlist under the cache key prefix.\n return \"m3u8\";\n default:\n // \"webp\" / \"auto\" / undefined / anything new\n return \"webp\";\n }\n}\n\n/**\n * Build a transform URL for a VIDEO asset. Same DSL shape as image\n * transforms; the server branches on the asset's `kind` column. Video\n * URLs use `.mp4` (default) or `.webm` extension and on cache miss the\n * server returns 202 Accepted while a background job encodes the clip;\n * subsequent GETs return 302 to the cached object.\n *\n * <video src={aq.transformVideo(asset, { width: 1080, height: 1920 })}\n * autoPlay muted loop playsInline />\n */\nexport function getVideoTransformUrl(\n asset: Pick<AssetDTO, \"sha\"> & VisibilityHint,\n opts: TransformOptions,\n): string | null {\n assertSha(asset, \"getVideoTransformUrl\");\n assertPublic(\n asset,\n \"getVideoTransformUrl\",\n \"getPrivateTransformUrl(asset, opts, signingKey, { expiresInSeconds: 300 })\",\n );\n const dsl = serializeTransform(opts);\n if (!dsl) return null;\n const ext = opts.format === \"webm\" ? \"webm\" : \"mp4\";\n return `${getCdnBase()}/t/${dsl}/${asset.sha}.${ext}`;\n}\n\n/**\n * Build an HLS streaming URL for a VIDEO asset (Phase 5). Returns the\n * master.m3u8 entry point — HLS-aware players (Video.js's\n * @videojs/http-streaming, hls.js, native iOS Safari) follow it to\n * fetch the variant playlist + segments at the appropriate bitrate\n * for the connection.\n *\n * ⚠️ A master.m3u8 is NOT a video file. Assigning it to `<video src>`\n * works only where the engine has native HLS; everywhere else it needs\n * an MSE player. And the classic feature test is now WRONG: Chrome 147\n * (April 2026) added native HLS, so `canPlayType(\"application/vnd.apple.mpegurl\")`\n * answers \"maybe\" there and routes Chrome to the native branch, where it\n * opened a measured 17 s hero at 426x240 for ~8 s. Branch on the ENGINE:\n *\n * @example\n * ```ts\n * function prefersNativeHls(video: HTMLVideoElement): boolean {\n * if (video.canPlayType(\"application/vnd.apple.mpegurl\") === \"\") return false;\n * // Apple's engine, or an engine with no MSE to fall back on (iOS < 17.1).\n * return \"ManagedMediaSource\" in globalThis || !(\"MediaSource\" in globalThis);\n * }\n *\n * const src = getHlsStreamingUrl(asset);\n * if (prefersNativeHls(video)) {\n * video.src = src;\n * } else {\n * const hls = new Hls({ capLevelToPlayerSize: false, abrEwmaDefaultEstimate: 5_000_000 });\n * hls.loadSource(src);\n * hls.attachMedia(video);\n * }\n * ```\n *\n * ⚠️ **Do NOT pass `startLevel: -1` with `testBandwidth: true`.** That pair is\n * documented by hls.js as *\"forces the player to download a fragment from the\n * lowest level to establish a bandwidth estimate\"* — on a clip short enough to\n * be one segment, the probe IS the whole video, and it plays at the bottom\n * rung from first frame to last. (This doc-comment recommended exactly that\n * until 2026-08-18; a 5.042 s 4K asset was measured being delivered at\n * 426x240 because of it.) Leave `startLevel` unset: hls.js then opens on the\n * FIRST level in the manifest, and the server puts the right one there —\n * a mid rung for long video, the top rung for a clip under 18 s, which is the\n * same rung native HLS opens on per RFC 8216 §6.3.4. The ladder decides; the\n * player should not second-guess it.\n *\n * `capLevelToPlayerSize` is worth disabling explicitly: `@videojs/core`\n * defaults it to `true`, which caps quality to the player's rendered pixel box,\n * so a small inline player is pinned to 240p/360p on any connection.\n *\n * On first request the server returns 202 Accepted while a background\n * job transcodes the ladder (typically 1-3 min for a 90 s source);\n * subsequent requests get 302 to the cached master.m3u8. Keep the\n * progressive MP4 as a fallback source for that window.\n *\n * The ladder's ceiling is the source the job probes: built at ingest it\n * reads the RAW upload and a 4K master yields 1440p/2160p rungs; rebuilt\n * on demand after the raw is unavailable it reads the `-v.mp4`, which is\n * capped at 1920 wide. `getAssetUrl(sha, \"video\")` is always <= 1080p.\n */\nexport function getHlsStreamingUrl(\n asset: Pick<AssetDTO, \"sha\"> & VisibilityHint,\n opts: Omit<TransformOptions, \"format\"> = {},\n): string {\n assertSha(asset, \"getHlsStreamingUrl\");\n assertPublic(\n asset,\n \"getHlsStreamingUrl\",\n 'getPrivateAssetUrl(asset, \"hls\", signingKey, { expiresInSeconds: 300 }) — the worker re-signs the playlist children',\n );\n // Always serialize with format=hls so the server routes correctly.\n const merged: TransformOptions = { ...opts, format: \"hls\" };\n const dsl = serializeTransform(merged);\n return `${getCdnBase()}/t/${dsl}/${asset.sha}.m3u8`;\n}\n\n/**\n * Build a transform URL. Returns null when the caller passed no options —\n * callers should prefer the existing variant URL builder in that case so\n * the request hits a pre-generated variant instead of an on-the-fly encode.\n */\nexport { buildTransformUrl as getTransformUrlUnchecked };\n\nfunction buildTransformUrl(\n asset: Pick<AssetDTO, \"sha\">,\n opts: SignedTransformOptions,\n): string | null {\n const dsl = serializeTransform(opts);\n if (!dsl) return null;\n const ext = extForOptions(opts);\n return `${getCdnBase()}/t/${dsl}/${asset.sha}.${ext}`;\n}\n\nexport function getTransformUrl(\n asset: Pick<AssetDTO, \"sha\"> & VisibilityHint,\n opts: TransformOptions,\n): string | null {\n assertSha(asset, \"getTransformUrl\");\n assertPublic(\n asset,\n \"getTransformUrl\",\n \"getPrivateTransformUrl(asset, opts, signingKey, { expiresInSeconds: 300 })\",\n );\n return buildTransformUrl(asset, opts);\n}\n\n/**\n * Build AND sign a transform URL, allowing an off-ladder custom `width`.\n *\n * This is the escape hatch for {@link SignedTransformOptions}: off-ladder\n * widths only pass the edge whitelist when the URL is signed, so building one\n * and signing it must happen together. For on-ladder widths prefer the plain\n * {@link getTransformUrl} (+ {@link signTransformUrl} if you need a signature).\n *\n * Returns `null` only when `opts` serialize to an empty DSL (no transform\n * requested) — same contract as {@link getTransformUrl}.\n */\nexport function getSignedTransformUrl(\n asset: Pick<AssetDTO, \"sha\"> & VisibilityHint,\n opts: SignedTransformOptions,\n signingKey: string,\n): Promise<string> | null {\n // ⭐ The guard belongs here MOST of all, and it was the one place it was\n // missing. A `?sig=` on `/t/` is a WIDTH permit, not access: on a private\n // asset the URL it produces is a perfectly signed 404. And this is exactly\n // where a backend developer holding a signing key and a private asset ends\n // up — so without this, the same call site throws when unsigned and returns\n // a doomed URL when signed, which is the worst of both.\n assertSha(asset, \"getSignedTransformUrl\");\n assertPublic(\n asset,\n \"getSignedTransformUrl\",\n \"getPrivateTransformUrl(asset, opts, signingKey, { expiresInSeconds: 300 })\",\n );\n const url = buildTransformUrl(asset, opts);\n if (!url) return null;\n return signTransformUrl(url, signingKey);\n}\n\n/**\n * Sign a transform URL with the tenant's HMAC signing key. Appends\n * `?sig=<hex>` where hex = HMAC-SHA256(signingKey, `<canonical-DSL>/<filename>`).\n *\n * Must agree byte-for-byte with the server's `verifyTransformSignature`.\n * Uses WebCrypto, so works in browsers, Node ≥ 16, Bun, and Workers.\n *\n * The canonical DSL is the one already produced by `serializeTransform`\n * (sort keys + lowercase strings), so signing a URL built by `getTransformUrl`\n * is automatic — the same canonical form is in the URL path.\n */\nexport async function signTransformUrl(\n unsignedUrl: string,\n signingKey: string,\n): Promise<string> {\n const u = new URL(unsignedUrl);\n // Path shape: /t/<dsl>/<filename>\n const parts = u.pathname.split(\"/\").filter(Boolean);\n // First segment must be \"t\"; the rest is dsl groups + filename. With\n // Phase 1 we ship a single DSL group; chained groups stay flat for\n // signing purposes (server canonicalizer flattens them too).\n if (parts[0] !== \"t\" || parts.length < 3) {\n throw new Error(`signTransformUrl: unexpected URL shape ${unsignedUrl}`);\n }\n const filename = parts[parts.length - 1]!;\n const dsl = parts.slice(1, -1).join(\"/\");\n const message = `${dsl}/${filename}`;\n const sig = await hmacSha256Hex(signingKey, message);\n u.searchParams.set(\"sig\", sig);\n return u.toString();\n}\n\nasync function hmacSha256Hex(key: string, message: string): Promise<string> {\n const enc = new TextEncoder();\n const cryptoKey = await crypto.subtle.importKey(\n \"raw\",\n enc.encode(key),\n { name: \"HMAC\", hash: \"SHA-256\" },\n false,\n [\"sign\"],\n );\n const buf = await crypto.subtle.sign(\"HMAC\", cryptoKey, enc.encode(message));\n return [...new Uint8Array(buf)]\n .map((b) => b.toString(16).padStart(2, \"0\"))\n .join(\"\");\n}\n\n/**\n * Build a responsive `srcSet` string by generating one transform URL per\n * width. All other options apply to every URL.\n *\n * <img\n * src={aq.transform(asset, { width: 800 })!}\n * srcSet={aq.transformSrcSet(asset, [320, 640, 960, 1280])}\n * sizes=\"(max-width: 768px) 100vw, 50vw\"\n * />\n */\nexport function getTransformSrcSet(\n asset: Pick<AssetDTO, \"sha\"> & VisibilityHint,\n widths: number[],\n extraOpts: Omit<TransformOptions, \"width\"> = {},\n): string {\n assertSha(asset, \"getTransformSrcSet\");\n assertPublic(\n asset,\n \"getTransformSrcSet\",\n \"getPrivateTransformUrl(asset, opts, signingKey, { expiresInSeconds: 300 }) per width\",\n );\n return widths\n .map((w) => {\n // Build via the internal (number-width) builder: `widths` is an explicit\n // responsive ladder the caller chose, so it stays `number[]`. Off-ladder\n // unsigned widths 400 at the edge — the caller's responsibility, exactly\n // as before the width type was tightened.\n const url = buildTransformUrl(asset, { ...extraOpts, width: w });\n return url ? `${url} ${w}w` : null;\n })\n .filter((s): s is string => s != null)\n .join(\", \");\n}\n","/**\n * @nitida/asset-client/slots — slot resolver for tenant-named assets.\n *\n * Slots give tenants a way to attach stable, human-readable names\n * (\"webapp.wizard.pool-type.icon-1\", \"storefront.cr.hero-video.landscape_hd_16x9.mp4\")\n * to assets they uploaded. Consumers resolve names → AssetDTOs at\n * build / runtime so their source never hardcodes a CDN URL; an\n * admin rebinds a slot in the platform console and every consumer\n * picks up the swap on cache refresh.\n *\n * Two layers in this package:\n * - `resolveSlot` / `resolveSlots` — universal (server, edge,\n * workers) fetch helpers. Cache 60s by default.\n * - React hooks live in `@nitida/asset-client/react/use-slot`\n * (kept out of this module so the SSR-safe core stays\n * dependency-free of react).\n */\n\nimport type { AssetDTO, VariantPreset } from \"./index\";\nimport { getAssetUrl, hasPreset } from \"./index\";\n\n// ---------------------------------------------------------------------------\n// Wire shape — matches the server's slots route.\n// ---------------------------------------------------------------------------\n\nexport type SlotDTO = {\n slotKey: string;\n /** Preset hint set when the slot was bound (e.g. `thumb` for icon slots). */\n preset: VariantPreset | null;\n description: string | null;\n updatedAt: string;\n asset: AssetDTO;\n};\n\nexport type SlotResolution = {\n /** The resolved DTO (`null` when the slot is unbound or asset missing). */\n slot: SlotDTO | null;\n /**\n * Effective preset — what `url` below was built with. Resolution order:\n * 1. caller's `preset` override\n * 2. slot's `preset` hint\n * 3. `lg` for images, `video` for video kind\n */\n preset: VariantPreset;\n /** The CDN URL the consumer should use. */\n url: string | null;\n};\n\n// ---------------------------------------------------------------------------\n// Config\n// ---------------------------------------------------------------------------\n\nconst DEFAULT_TTL_MS = 60_000;\nconst cache = new Map<string, { fetchedAt: number; value: SlotDTO | null }>();\n\n// The published API host — the same address the docs give out, so a caller\n// that configured nothing still talks to the documented endpoint. Override it\n// with `configureSlotResolver({ endpoint })` to point at a different\n// deployment.\nlet endpoint = \"https://api.nitida.gofuture.space\";\nlet apiKey: string | null = null;\nlet tenantCode: string | null = null;\n\n/**\n * Configure the resolver process-wide. Call once at boot from your\n * storefront layout / server entry / worker init.\n *\n * configureSlotResolver({\n * endpoint: process.env.AQUIENPZ_URL,\n * apiKey: process.env.AQUIENPZ_API_KEY, // amk_rt_* — server-only\n * tenantCode: \"acme-co\",\n * });\n */\nexport function configureSlotResolver(opts: {\n endpoint?: string;\n apiKey?: string;\n tenantCode?: string;\n}): void {\n if (opts.endpoint) endpoint = opts.endpoint.replace(/\\/+$/, \"\");\n if (opts.apiKey !== undefined) apiKey = opts.apiKey;\n if (opts.tenantCode !== undefined) tenantCode = opts.tenantCode;\n}\n\n/** Wipe the in-process cache (test helper or forced refresh). */\nexport function invalidateSlotCache(slotKey?: string): void {\n if (slotKey === undefined) cache.clear();\n else\n for (const k of cache.keys())\n if (k.endsWith(`:${slotKey}`)) cache.delete(k);\n}\n\n// ---------------------------------------------------------------------------\n// Internal fetch helper\n// ---------------------------------------------------------------------------\n\nconst baseHeaders = (): Record<string, string> => {\n const h: Record<string, string> = {};\n if (apiKey) h.Authorization = `Bearer ${apiKey}`;\n if (tenantCode) h[\"X-Tenant-Code\"] = tenantCode;\n return h;\n};\n\nasync function fetchSlot(slotKey: string): Promise<SlotDTO | null> {\n const r = await fetch(`${endpoint}/slots/${encodeURIComponent(slotKey)}`, {\n headers: baseHeaders(),\n });\n if (r.status === 404) return null;\n if (!r.ok) throw new Error(`slot fetch ${r.status}: ${await r.text()}`);\n return (await r.json()) as SlotDTO;\n}\n\nasync function fetchSlotsBulk(\n slotKeys: string[],\n): Promise<Record<string, SlotDTO | null>> {\n if (slotKeys.length === 0) return {};\n const r = await fetch(`${endpoint}/slots/resolve`, {\n method: \"POST\",\n headers: { ...baseHeaders(), \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ keys: slotKeys }),\n });\n if (!r.ok) throw new Error(`slots resolve ${r.status}: ${await r.text()}`);\n const body = (await r.json()) as { resolved: Record<string, SlotDTO | null> };\n return body.resolved;\n}\n\n// ---------------------------------------------------------------------------\n// Public API\n// ---------------------------------------------------------------------------\n\nexport type ResolveSlotOptions = {\n /** Override preset (caller knows the use case better than the slot binding). */\n preset?: VariantPreset;\n /** TTL for the in-process cache. Default 60s. Set 0 to bypass. */\n ttlMs?: number;\n};\n\n/**\n * Resolve a single slot to a CDN URL. Returns `{slot: null, url: null}`\n * when the slot is unbound — callers fall back to a placeholder.\n *\n * Cached for `ttlMs` (default 60s). Slot rebindings propagate within the\n * TTL window without an app restart.\n */\nexport async function resolveSlot(\n slotKey: string,\n opts: ResolveSlotOptions = {},\n): Promise<SlotResolution> {\n const ttl = opts.ttlMs ?? DEFAULT_TTL_MS;\n const cacheKey = `${tenantCode ?? \"_\"}:${slotKey}`;\n const now = Date.now();\n let dto: SlotDTO | null;\n const hit = cache.get(cacheKey);\n if (hit && now - hit.fetchedAt < ttl) {\n dto = hit.value;\n } else {\n dto = await fetchSlot(slotKey);\n cache.set(cacheKey, { fetchedAt: now, value: dto });\n }\n return materializeResolution(dto, opts.preset);\n}\n\n/**\n * Bulk-resolve N slot keys in one round-trip. The SDK's `useSlots`\n * React hook calls this so every storefront header (logo + tagline +\n * nav cover + …) loads as one request.\n */\nexport async function resolveSlots(\n slotKeys: string[],\n opts: ResolveSlotOptions = {},\n): Promise<Record<string, SlotResolution>> {\n if (slotKeys.length === 0) return {};\n const ttl = opts.ttlMs ?? DEFAULT_TTL_MS;\n const now = Date.now();\n const missing: string[] = [];\n const out: Record<string, SlotResolution> = {};\n for (const k of slotKeys) {\n const cacheKey = `${tenantCode ?? \"_\"}:${k}`;\n const hit = cache.get(cacheKey);\n if (hit && now - hit.fetchedAt < ttl) {\n out[k] = materializeResolution(hit.value, opts.preset);\n } else {\n missing.push(k);\n }\n }\n if (missing.length > 0) {\n const resolved = await fetchSlotsBulk(missing);\n for (const k of missing) {\n const dto = resolved[k] ?? null;\n cache.set(`${tenantCode ?? \"_\"}:${k}`, { fetchedAt: now, value: dto });\n out[k] = materializeResolution(dto, opts.preset);\n }\n }\n return out;\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\nfunction defaultPresetFor(asset: AssetDTO | undefined): VariantPreset {\n if (!asset) return \"lg\";\n return asset.kind === \"video\" ? \"video\" : \"lg\";\n}\n\nfunction materializeResolution(\n dto: SlotDTO | null,\n overridePreset?: VariantPreset,\n): SlotResolution {\n if (!dto) return { slot: null, preset: overridePreset ?? \"lg\", url: null };\n const effective = overridePreset ?? dto.preset ?? defaultPresetFor(dto.asset);\n // Fall back to \"lg\" when the bound preset doesn't exist on the asset\n // (e.g. slot was bound to a video but caller asked for a thumb).\n const finalPreset = hasPreset(dto.asset, effective)\n ? effective\n : defaultPresetFor(dto.asset);\n return {\n slot: dto,\n preset: finalPreset,\n url: getAssetUrl(dto.asset, finalPreset),\n };\n}\n","/**\n * @nitida/asset-client — read helpers for asset URLs.\n *\n * Universal CDN model (May 2026): the API ships a compact AssetDTO with a\n * 16-char SHA prefix + a `presets` string of 1-char codes; client helpers\n * construct CDN URLs deterministically from `(cdnBase, sha, preset, ext)`.\n *\n * Why: catalog sync over Electric SSE shipped 4 nearly-identical full URLs\n * per asset × hundreds of thousands of assets per snapshot. Sending only\n * what differs — sha + presets bitmap — collapses ~600 bytes per asset to\n * ~70 (88% reduction).\n *\n * No runtime dependencies — pure types + pure functions. Safe everywhere.\n * @module @nitida/asset-client\n */\n\n/**\n * Image presets are size-based (Vercel `next/image` style):\n * - `thumb` is the only square crop — semantic icon use case\n * - `sm/md/lg` are max-side bounding boxes that preserve aspect ratio\n *\n * Naming over the legacy `thumbnail/cover/web/hero` because the new names\n * say what the variant IS (a size class) rather than what it might be\n * USED for, removing the implicit landscape-only assumption that bit us\n * with vertical product photos.\n */\nexport type VariantPreset =\n // image presets\n | \"thumb\" // 256x256 square smart-crop (icon)\n | \"sm\" // 640 max-side\n | \"md\" // 1280 max-side\n | \"lg\" // 1920 max-side\n | \"xl\" // 3840 max-side (4K) — OPT-IN; not generated by default\n // passthrough for non-image kinds (PDF etc.)\n | \"original\"\n // video presets — semantic (not size classes)\n | \"poster\"\n | \"video\"\n | \"aiproxy\"\n // The adaptive HLS ladder. Unlike every other preset this is NOT one file:\n // it is a `master.m3u8` plus one media playlist + segment set per rung. It is\n // a preset because the question callers ask about it is the same one they ask\n // about the others — *does this asset have one?* — and `hasPreset` is the\n // place that question is answered. What it HAS, rung by rung, is in the\n // `hls` entry of `variants` ({@link AssetVariant.rungs}).\n | \"hls\"\n // audio preset — the cross-browser mp3 transcode of a voice note\n // (libmp3lame) emitted alongside the original so chat audio plays on\n // both Chrome/Android (webm/opus) and iOS Safari (which can't decode opus).\n | \"mp3\";\n\n/**\n * What you may ASK the server to produce.\n *\n * NOT the same set as {@link VariantPreset}, and conflating the two is the\n * single most expensive type error this package has shipped. Three unknown\n * agents evaluating the SDK all hit it, independently, in the same afternoon:\n *\n * regenerate(id, { presets: [\"hls\"] }) // compiled → HTTP 400\n * upload(file, { presets: [\"mp3\"] }) // compiled → HTTP 400\n *\n * Both symbols are perfectly real — they are things a variant CAN BE. Neither\n * is something you can ASK FOR. `hls` is built by the video pipeline when a\n * video is transcoded; `mp3` is emitted automatically alongside any audio\n * original so iOS Safari can play it. You do not order either one.\n *\n * And it was wrong in the other direction too, which nobody had noticed:\n * **`probe` is requestable and was not on `VariantPreset` at all**, so the type\n * forbade a request the server has always accepted.\n *\n * Verified 2026-08-21 against the Elysia schemas of all four write routes —\n * `/assets/process`, the presign route, `/assets/:id/regenerate` and both\n * multipart routes. All four accept exactly this list and nothing else, with\n * no drift between them.\n */\nexport type RequestablePreset =\n | Exclude<VariantPreset, \"hls\" | \"mp3\">\n // Still frames at evenly spaced offsets, stored under indexed keys\n // (`-pr0.jpg`, `-pr1.jpg`, …). Requestable, and deliberately absent from the\n // compact `presets` wire string — so it is here and not on VariantPreset.\n | \"probe\";\n\n/**\n * The same set as {@link RequestablePreset}, at RUNTIME.\n *\n * The type stops the mistake in TypeScript. It cannot stop it anywhere else,\n * and \"anywhere else\" is where it keeps happening: a preset list assembled\n * from config, from a route body, from JSON, or from a script's argv arrives\n * as `string[]`, and the only way past the type was a cast.\n *\n * Measured in neo-real-estate on 2026-08-23, in THREE independent files:\n *\n * presets: [...opts.presets] as VariantPreset[]\n *\n * — a blind cast, and to the wrong type at that: `VariantPreset` includes\n * `hls` and `mp3`, which are precisely the two you may not ask for. Every one\n * of those casts would have compiled a request the server answers 400.\n *\n * So the narrowing lives here, once, instead of being re-invented per repo.\n */\nexport const REQUESTABLE_PRESETS: readonly RequestablePreset[] = [\n \"thumb\",\n \"sm\",\n \"md\",\n \"lg\",\n \"xl\",\n \"original\",\n \"poster\",\n \"video\",\n \"aiproxy\",\n \"probe\",\n] as const;\n\n/** Type guard for a single value. */\nexport const isRequestablePreset = (v: string): v is RequestablePreset =>\n (REQUESTABLE_PRESETS as readonly string[]).includes(v);\n\n/**\n * Narrow a `string[]` to the presets the server can actually produce, or throw\n * naming the offender.\n *\n * Throws rather than filtering silently, for the same reason the seven URL\n * builders throw on a private asset: **a silence reads as \"you can't\"**. A\n * caller that asked for `hls` wants an HLS ladder; dropping it quietly returns\n * a 200 and no ladder, and the 404 lands later and somewhere else. The error\n * names the value, says why the server cannot make it, and lists what it can.\n */\nexport const toRequestablePresets = (\n input: readonly string[],\n): RequestablePreset[] => {\n const bad = input.filter((p) => !isRequestablePreset(p));\n if (bad.length > 0) {\n throw new Error(\n `Cannot request ${bad.map((b) => `\"${b}\"`).join(\", \")}. ` +\n \"`hls` and `mp3` are things a variant can BE, not things you may ask \" +\n \"for — the server derives them itself (hls when a video is \" +\n \"transcoded, mp3 alongside any audio original). \" +\n `Requestable: ${REQUESTABLE_PRESETS.join(\", \")}.`,\n );\n }\n return input as RequestablePreset[];\n};\n\n/** 1-char alias used in storage keys / wire `presets` string. */\nexport const PRESET_SHORT: Record<VariantPreset, string> = {\n thumb: \"q\",\n sm: \"s\",\n md: \"m\",\n lg: \"l\",\n xl: \"x\",\n original: \"o\",\n poster: \"p\",\n video: \"v\",\n aiproxy: \"a\",\n // `h` — free in both directions: no other preset claims it, and no\n // multi-character token that `stripMultiCharTokens` removes donates one\n // (`transform-<hex>` → t/r/a/n/s/f/o/m + a–f; `pr`; `mp3`). So a server that\n // starts emitting `h` cannot make an OLDER client answer `true` to anything.\n hls: \"h\",\n // 3 chars, NOT a 1-char alias: the server has no short-form for\n // audio so its `shortPreset(\"mp3\")` falls through to the literal token,\n // and the deployed server already writes the `-mp3.mp3` variant + emits\n // the bare `mp3` token in the wire `presets` string. Must stay in lockstep.\n mp3: \"mp3\",\n};\nexport const PRESET_LONG: Record<string, VariantPreset> = Object.fromEntries(\n Object.entries(PRESET_SHORT).map(([k, v]) => [v, k as VariantPreset]),\n);\n\n/** Variant extension by preset. Image variants are always WebP, video MP4. */\nexport const PRESET_EXT: Record<VariantPreset, string> = {\n thumb: \"webp\",\n sm: \"webp\",\n md: \"webp\",\n lg: \"webp\",\n xl: \"webp\",\n original: \"bin\", // overridden per-asset via mime when needed\n poster: \"webp\",\n video: \"mp4\",\n aiproxy: \"mp4\",\n // The ladder's ENTRY file. Never used to build a key — see `getAssetUrl`,\n // which refuses to derive `<sha>-h.m3u8` because no such object exists.\n hls: \"m3u8\",\n mp3: \"mp3\",\n};\n\n/** Max-side dimension by preset; null for video / passthrough. */\nexport const PRESET_MAX_DIM: Record<VariantPreset, number | null> = {\n thumb: 256,\n sm: 640,\n md: 1280,\n lg: 1920,\n xl: 3840,\n original: null,\n poster: null,\n video: null,\n aiproxy: null,\n // A ladder has no single max side — it has a rung per size. `null` keeps it\n // out of `getAssetSrcSet`, where offering an `.m3u8` as an `<img>` candidate\n // would be nonsense. Its ceiling is `variants.find(v => v.preset === \"hls\").height`.\n hls: null,\n // audio has no pixel dimensions; `null` keeps mp3 out of the\n // dimension-based `getAssetSrcSet` / `computeVariantDimensions` logic.\n mp3: null,\n};\n\n/**\n * One generated variant of an asset. Returned by the admin endpoints\n * (`GET /assets/:id`, `POST /assets/:id/regenerate`).\n */\n/**\n * What `AssetVariant.preset` can actually hold.\n *\n * ⚠️ NOT `VariantPreset`, and the difference is a real bug the type used to\n * hide. Measured against the live API on 2026-08-21, one asset came back with\n * **29 variants, 25 of them `transform-<hash>`** — 86 % of the array — while the\n * type said every entry was one of eleven known presets. So this compiles:\n *\n * ```ts\n * for (const v of asset.variants ?? []) getAssetUrl(asset, v.preset);\n * ```\n *\n * `tsc` exits 0, and at runtime 25 of those 29 URLs come out as\n * `<sha>-undefined.undefined` and answer 404, because `PRESET_SHORT[preset]`\n * and `PRESET_EXT[preset]` are `undefined` for a hash that is not a preset.\n *\n * The `transform-*` entries are NOT junk and are not being removed: they are\n * the materialised cache of past on-demand requests — still ready, still free\n * to fetch — and the inventory model treats them as a first-class\n * `transform-cache` family, which is exactly the question a composer asks\n * (\"what can I fetch cheaply right now?\"). Deleting them would destroy that.\n *\n * So the type tells the truth instead. `(string & {})` keeps autocomplete on\n * the known presets while admitting the rest, and a caller that wants to build\n * a URL now has to narrow first — which is the whole point.\n */\nexport type VariantEntryPreset =\n | VariantPreset\n /** Indexed stills (`-pr0.jpg`, …). Requestable, never on the compact string. */\n | \"probe\"\n /** `transform-<dslHash>` and `upscale_*` — materialised cache, not a rung. */\n | (string & {});\n\nexport type AssetVariant = {\n /**\n * What this entry IS. Usually a named preset; can also be a\n * `transform-<hash>` cache artifact — see {@link VariantEntryPreset} before\n * passing it to {@link getAssetUrl}.\n */\n preset: VariantEntryPreset;\n /** Public CDN URL of this variant. */\n url: string;\n /** Pixel width. Absent for `original`-only assets where image processing was skipped, or for video presets. */\n width?: number;\n /** Pixel height. Same caveat as `width`. */\n height?: number;\n /** Byte size of the stored variant file. */\n bytes: number;\n /**\n * Where the bytes for this variant came from. Useful for quality\n * traceability — a `thumb` with `sourceFrom: \"original\"` is the\n * canonical case, while `sourceFrom: \"lg\"` means it was derived\n * from an already-encoded WebP (slight quality compounding).\n *\n * - `\"upload\"` → first-write at `/assets/process`. The bytes came\n * straight from the client's PUT.\n * - `VariantPreset` → regenerated from that preset's variant.\n *\n * Absent on variants written before the trace field existed.\n */\n sourceFrom?:\n | VariantPreset\n | \"upload\"\n // Written by the on-demand `/t/` route (`transform-<hash>` entries).\n | \"transform-route\"\n // The two ways an `hls` entry comes to exist: written by the transcode that\n // produced the ladder, or read back off the CDN by the backfill/self-heal.\n | \"hls-transcode\"\n | \"cdn-probe\";\n /** ISO timestamp this variant was written. Absent on pre-trace variants. */\n createdAt?: string;\n /**\n * **`preset: \"hls\"` only** — the ladder's rungs, in MASTER ORDER.\n *\n * The reason this exists: an entry that only says *there is a ladder* leaves\n * a consumer that plans a composition exactly as blind as no entry at all,\n * because the ceiling of a composition is its weakest ingredient and there is\n * no upscale. Before this field the only way to learn a clip's real rungs was\n * to fetch the master playlist — or worse, download the asset.\n *\n * `rungs[0]` is the rung every client OPENS on (RFC 8216 §6.3.4 for native\n * HLS; hls.js with `startLevel` unset uses \"the first level in the\n * manifest\"), so the order is a delivery fact — do not sort it in place.\n *\n * Use {@link hlsLadderAlignment} rather than eyeballing `segments`: a ladder\n * can be complete and still unable to adapt.\n */\n rungs?: HlsRung[];\n};\n\n/**\n * One rung of an adaptive HLS ladder.\n *\n * `segments` / `durationSec` are what make a ladder JUDGEABLE rather than\n * merely present. Measured on a prod ladder of an 87 s 4K source: 240p cut at\n * 14.35 s in 12 segments, 720p at 5.63 s in 12, 2160p at 5.88 s in 14. Cuts\n * that do not line up cannot be swapped, and a swap is what a rendition switch\n * IS — so that ladder looked complete and adapted badly. One segment means zero\n * switch points: whichever rung the player opens on is the rung it finishes on.\n *\n * Both are optional because a rung whose playlist could not be read is recorded\n * WITHOUT them rather than with a zero — unmeasured and none are different\n * facts, and a `0` there would read as the latter.\n */\nexport type HlsRung = {\n /** Rung directory / identity — `\"720p\"`. */\n name: string;\n /** `RESOLUTION` from the master playlist. */\n width: number;\n height: number;\n /** `BANDWIDTH` in bits per second. */\n bandwidth: number;\n /** Absolute URL of this rung's media playlist. */\n url: string;\n /** `#EXTINF` count. */\n segments?: number;\n /** Sum of the `#EXTINF` values, seconds. */\n durationSec?: number;\n};\n\n/**\n * The ladder of an asset, or `null` when it has none / the DTO does not carry\n * `variants` (the slim list shape never does — use `hasPreset(a, \"hls\")` there).\n */\nexport function getHlsLadder(\n asset: Pick<AssetDTO, \"variants\">,\n): AssetVariant | null {\n return asset.variants?.find((v) => v.preset === \"hls\") ?? null;\n}\n\n/**\n * What the recorded rungs actually support — computed here, never stored, so\n * one rule serves every consumer and a change to it does not need a backfill.\n *\n * - `switchable`: more than one rung AND more than one segment. False means the\n * player is pinned to whatever rung it opens on for the entire clip.\n * - `aligned`: every measured rung reports the same segment count. `null` means\n * fewer than two rungs were measured — **unknown, not false.** Treating that\n * as `false` rejects ladders nobody looked at.\n * - `ceilingHeight`: the tallest rung. The ceiling of any composition using it.\n */\nexport function hlsLadderAlignment(rungs: HlsRung[]): {\n rungCount: number;\n ceilingHeight: number;\n floorHeight: number;\n switchable: boolean;\n aligned: boolean | null;\n minSegments: number | null;\n} {\n const heights = rungs.map((r) => r.height);\n const measured = rungs\n .map((r) => r.segments)\n .filter((s): s is number => typeof s === \"number\" && s > 0);\n const minSegments = measured.length > 0 ? Math.min(...measured) : null;\n return {\n rungCount: rungs.length,\n ceilingHeight: heights.length > 0 ? Math.max(...heights) : 0,\n floorHeight: heights.length > 0 ? Math.min(...heights) : 0,\n switchable: rungs.length > 1 && (minSegments ?? 0) > 1,\n aligned: measured.length >= 2 ? new Set(measured).size === 1 : null,\n minSegments,\n };\n}\n\n/**\n * Compact wire shape — what the server actually sends. Aliases (`w`, `h`,\n * `dur`) are intentional to shave bytes per asset on dense lists.\n */\nexport type AssetDTO = {\n id: string;\n /** First 16 hex chars of sha256 — used to derive CDN URLs. */\n sha: string;\n kind: \"image\" | \"video\" | \"document\" | \"audio\" | \"other\";\n mime: string;\n bytes: number;\n /** Source dims (for aspect-ratio calc on the client). Optional for non-images. */\n w?: number | null;\n h?: number | null;\n /** Duration ms for videos. */\n dur?: number | null;\n /** LQIP placeholder (data URL). */\n blur?: string | null;\n palette?: AssetPalette | null;\n /**\n * Compact list of generated variants as their 1-char codes\n * concatenated, e.g. \"tcwh\" (image) / \"pv\" (video without aiproxy).\n * Ordered by ascending dimension.\n */\n presets: string;\n status: \"processing\" | \"ready\" | \"failed\";\n /**\n * Who may fetch the bytes.\n *\n * - `\"public\"` — the CDN serves it to anyone with the URL. The default,\n * and what all 27 484 assets were until this field existed.\n * - `\"private\"` — every public door answers **404**: the stored variants,\n * the raw original, the HLS ladder and `/t/`. The bytes are reachable\n * only through a signed URL under `/a/{tenant}/…?exp&sig`, which your\n * BACKEND mints with {@link getPrivateAssetUrl}.\n *\n * Optional so an older server that does not send it is read as `\"public\"` —\n * which is what such a server means.\n *\n * ⚠️ A 404 on a private asset is not a missing file. It is the feature\n * working. See {@link getPrivateAssetUrl}.\n */\n visibility?: \"public\" | \"private\";\n /** Soft-delete timestamp (ISO). Hidden from catalog when set. */\n deletedAt?: string | null;\n /**\n * Full variant list with URLs + sizes. Sent by `GET /assets/:id`; absent on\n * the slim list shape used by the resolver / catalog. Use `presets` for\n * compact existence checks, and this when you need the actual URLs.\n */\n variants?: AssetVariant[];\n /**\n * The extension the `original` variant was really stored under — the server\n * keys it off the uploaded filename, so it cannot be derived from `mime`.\n * Sent by `GET /assets/:id`; `null` when the asset has no original.\n * {@link getAssetUrl} uses it automatically when you pass the whole DTO.\n */\n oext?: string | null;\n};\n\nexport {\n accessMessage,\n assertPublic,\n assertSha,\n deriveAccessKey,\n type SignAccessOptions,\n signAccessUrl,\n type VisibilityHint,\n} from \"./access\";\nexport type {\n AssetPalette,\n PaletteSwatch,\n} from \"./palette\";\n\nexport {\n bestTextContrast,\n contrastRatio,\n getAmbientGradient,\n getPaletteBlurBackground,\n getPaletteCssVars,\n getTextColorForBackground,\n iteratePaletteSwatches,\n pickAmbientBackground,\n relativeLuminance,\n} from \"./palette\";\n\nimport {\n assertPublic,\n assertSha,\n type SignAccessOptions,\n signAccessUrl,\n type VisibilityHint,\n} from \"./access\";\nimport type { AssetPalette } from \"./palette\";\nimport {\n getTransformUrlUnchecked,\n type SignedTransformOptions,\n} from \"./transform\";\n\n// ---------------------------------------------------------------------------\n// CDN base\n// ---------------------------------------------------------------------------\n\nlet cdnBaseUrl = \"https://8ok.uk\";\n/**\n * Override the CDN base for the entire process (e.g. in tests, or when\n * pointing at a tenant-specific CDN). Storefront layouts call this once at\n * boot.\n */\nexport function setCdnBase(url: string): void {\n cdnBaseUrl = url.replace(/\\/$/, \"\");\n}\nexport function getCdnBase(): string {\n return cdnBaseUrl;\n}\n\n// ---------------------------------------------------------------------------\n// Tenant scope\n//\n// Post-May-2026 the CDN serves variants under a tenant-prefixed path\n// `<cdn>/<tenantId base36>/v/<sha16>-<preset>.<ext>` (see the server\n// `variantKey`). Variant URL builders MUST include that prefix or every\n// URL 404s. The tenant id is process-global (one tenant per client/app),\n// set once at boot — `NitidaClient` does this from its `tenantId` option;\n// standalone consumers call `setTenantId()` directly. Left unset, builders\n// fall back to the legacy pre-cutover bare path for back-compat.\n// ---------------------------------------------------------------------------\n\nlet tenantId: number | null = null;\n/** Set the process-global tenant id used to build tenant-prefixed CDN URLs. */\nexport function setTenantId(id: number | null | undefined): void {\n tenantId =\n typeof id === \"number\" && Number.isFinite(id) && id > 0 ? id : null;\n}\nexport function getTenantId(): number | null {\n return tenantId;\n}\n/** Variant path prefix `<tid b36>/v/`, or \"\" when no tenant is configured. */\nfunction variantPrefix(): string {\n return tenantId != null ? `${tenantId.toString(36)}/v/` : \"\";\n}\n\n// ---------------------------------------------------------------------------\n// URL builders\n// ---------------------------------------------------------------------------\n\n/**\n * LAST-RESORT guess at the ORIGINAL variant's extension, from the asset's mime.\n *\n * ⚠️ This is a guess. Prefer `oext` or `variants` (see {@link getAssetUrl}) — the\n * server sends both and they ARE the key.\n *\n * The server derives the extension from the MIME with the `mime-types` package\n * (`mime.extension(body.mime)`, at presign). So a table that matched that one\n * exactly would usually be right — and this table did not: it said\n * `image/jpeg` → `jpeg` while `mime-types` says `jpg`, under a comment claiming\n * to mirror it. Usually, but not always: the row's stored `mime` is not always\n * the mime the key was built from, so no client-side table can close the gap.\n *\n * Measured against the 2 001 stored originals in production, 2026-08-17: **all\n * 420 `image/jpeg` originals are stored `.jpg` and none `.jpeg`** — the old\n * entry here 404'd on every single JPEG. And 234 originals carry\n * `application/octet-stream` (`.mpga`, `.docx`, `.m4a`), where no mime table can\n * produce the right key at all — those need `oext`.\n */\nconst ORIGINAL_EXT_BY_MIME: Record<string, string> = {\n \"image/png\": \"png\",\n \"image/jpeg\": \"jpg\",\n \"image/webp\": \"webp\",\n \"image/gif\": \"gif\",\n \"image/avif\": \"avif\",\n \"image/svg+xml\": \"svg\",\n \"image/heic\": \"heic\",\n \"image/heif\": \"heif\",\n \"image/bmp\": \"bmp\",\n \"image/tiff\": \"tiff\",\n \"application/pdf\": \"pdf\",\n \"video/mp4\": \"mp4\",\n \"video/webm\": \"webm\",\n \"video/quicktime\": \"mov\",\n // Audio — absent until 2026-08-17. While they were missing this table\n // returned the `bin` sentinel for every voice note, so consumers had to\n // hand-roll the extension themselves. Measured: `-o.bin` 404s, `-o.m4a` 200s.\n //\n // ⚠️ These are keyed by the FULL mime, not the subtype: `audio/mp4` is stored\n // as `.m4a` and `video/mp4` as `.mp4`. A `switch` on the `mp4` subtype cannot\n // tell them apart — a mistake worth not repeating.\n \"audio/mpeg\": \"mpga\",\n \"audio/mp4\": \"m4a\",\n \"audio/x-m4a\": \"m4a\",\n \"audio/wav\": \"wav\",\n \"audio/webm\": \"weba\",\n \"audio/ogg\": \"oga\",\n \"audio/aac\": \"adts\",\n};\nfunction originalExtForMime(mime: string | undefined): string {\n return (mime ? ORIGINAL_EXT_BY_MIME[mime] : undefined) ?? PRESET_EXT.original;\n}\n\n/** What the asset itself knows about where its `original` lives. */\ntype OriginalHints = {\n mime?: string;\n /** The extension the server actually stored it under. Authoritative. */\n oext?: string | null;\n /** Full variant list — carries the stored URL verbatim. Authoritative. */\n variants?: AssetVariant[];\n};\n\n/**\n * Build the public CDN URL for a specific variant of an asset. The variant\n * may not actually exist (regenerate may not have run, or video has no\n * `aiproxy`); call `hasPreset()` first or expect a 404.\n *\n * For the `original` preset, pass the whole `AssetDTO` — it carries `variants`\n * and `oext`, either of which gives the EXACT stored key. `mime` alone is only a\n * guess (the server keys the original off the uploaded filename), and with\n * nothing at all the original falls back to the `\"bin\"` sentinel, which 404s.\n *\n * @example Video — the tenant segment is base36, so let this build the path\n * ```ts\n * import { getAssetUrl, setTenantId } from \"@nitida/asset-client\";\n *\n * setTenantId(12); // 12 → \"c\"; a decimal \"/12/v/\" 404s\n * getAssetUrl({ sha }, \"video\"); // → https://8ok.uk/c/v/<sha16>-v.mp4\n * getAssetUrl({ sha }, \"poster\"); // → https://8ok.uk/c/v/<sha16>-p.webp\n * ```\n *\n * @example The `original` — pass the DTO, not just the sha\n * ```ts\n * const asset = await aq.assets.get(id);\n *\n * getAssetUrl({ sha }, \"original\"); // → …-o.bin ❌ 404, always\n * getAssetUrl({ sha, mime }, \"original\"); // → a guess from the mime table\n * getAssetUrl(asset, \"original\"); // ✓ the stored key, verbatim\n * ```\n *\n * @example Check before you link\n * ```ts\n * import { getAssetUrl, hasPreset } from \"@nitida/asset-client\";\n * const url = hasPreset(asset, \"thumb\") ? getAssetUrl(asset, \"thumb\") : null;\n * ```\n */\nexport function getAssetUrl(\n asset: Pick<AssetDTO, \"sha\"> & OriginalHints & VisibilityHint,\n preset: VariantPreset,\n): string {\n // Refuses rather than returning a URL that 404s. See `assertPublic`.\n assertSha(asset, \"getAssetUrl\");\n assertPublic(\n asset,\n \"getAssetUrl\",\n `getPrivateAssetUrl(asset, \"${preset}\", signingKey, { expiresInSeconds: 300 })`,\n );\n // ⭐ THE TWO DEFAULTS DID NOT COMPOSE, AND THIS IS THE SEAM\n //\n // `upload()` defaults to `presets: [\"original\"]` — deliberately, so a bare\n // upload never silently spends the storage budget. `urlFor()` defaults to\n // `lg`. Put together, the obvious two-line program a programmatic caller\n // writes —upload, then ask for a URL— produced a **404**, because `lg` was\n // never generated. Measured 2026-08-22: 1 290 assets are in exactly that\n // state, 228 of them in a production tenant.\n //\n // So when the DTO TELLS us the preset was never materialised, fall back to\n // the transform route, which generates it on demand and caches it. The\n // caller gets optimised bytes instead of a dead link, the raw stays\n // untouched, and no storage is spent on sizes nobody asked for — measured on\n // the same tenant, the stored ladder is 79% of raw, so pre-materialising\n // everything would be ~9 GB for sizes that may never be requested.\n //\n // Same discipline as `assertPublic`: this only fires when we were TOLD.\n // `Pick<AssetDTO,\"sha\">` carries no `presets`, so the common call is\n // untouched and no existing behaviour changes.\n const fallback = transformFallbackFor(asset, preset);\n if (fallback) return fallback;\n return buildPublicAssetUrl(asset, preset);\n}\n\n/**\n * The `/t/` URL that stands in for a preset the asset does not have, or `null`\n * when there is nothing to stand in for.\n *\n * ⚠️ It can be over-eager, and that is the honest trade. Measured 2026-08-22:\n * an asset whose row lists only `original` served `-l.webp` with a 200 — the\n * `variants` column under-reports (the contamination behind doc 240 §4.3b), so\n * the fallback sometimes pays for a transform of a rendition that already\n * exists. Both answers are correct bytes; one costs an encode. That is a much\n * smaller wrong than the 404 it replaces, and it heals itself as rows are\n * reconciled — but it is a reason to fix the rows, not to trust them more.\n *\n * Returns null — i.e. keeps the old behaviour — when the DTO does not say what\n * it has, when the preset IS present, or when the preset has no pixel ceiling\n * to translate into a width (`original`, `poster`, `video`, `hls`, `mp3`);\n * those are stored objects, not renditions, and inventing a transform for them\n * would trade a 404 for a wrong answer.\n */\nfunction transformFallbackFor(\n asset: Pick<AssetDTO, \"sha\"> & Partial<Pick<AssetDTO, \"presets\">>,\n preset: VariantPreset,\n): string | null {\n if (typeof asset.presets !== \"string\") return null;\n if (hasPreset({ presets: asset.presets }, preset)) return null;\n const maxDim = PRESET_MAX_DIM[preset];\n if (maxDim == null) return null;\n return `${cdnBaseUrl}/t/format=webp,width=${maxDim}/${asset.sha}.webp`;\n}\n\n/**\n * The public-tree URL, with no visibility check.\n *\n * Split out because `getPrivateAssetUrl` needs exactly this and must NOT be\n * refused by the guard: the private tree is the same path with `/a/` in front\n * and a signature behind, so the builder that mints a legitimate private URL\n * would otherwise be blocked by the check that exists to send callers to it.\n */\nfunction buildPublicAssetUrl(\n asset: Pick<AssetDTO, \"sha\"> & OriginalHints,\n preset: VariantPreset,\n): string {\n // The guard lives HERE and not only in `getAssetUrl`, because\n // `getPrivateAssetUrl` reaches this function directly. Without it, passing an\n // upload result produced `/a/5/v/undefined-l.webp?exp=…&sig=…` — a URL with a\n // **cryptographically valid signature over a path containing `undefined`**.\n // That is strictly worse than the public case: the signature makes it look\n // authoritative, and it passes shape checks at the edge before 404ing.\n assertSha(asset, \"getPrivateAssetUrl\");\n if (preset === \"original\") {\n // The stored URL beats every derivation, because it IS the key. Only fall\n // through to a guess when the caller gave us the sha and nothing else.\n const stored = asset.variants?.find((v) => v.preset === \"original\")?.url;\n if (stored) return stored;\n const ext = asset.oext || originalExtForMime(asset.mime);\n return `${cdnBaseUrl}/${variantPrefix()}${asset.sha}-${PRESET_SHORT.original}.${ext}`;\n }\n if (preset === \"hls\") {\n // A ladder is a PREFIX (`<sha16>-hls<dslHash>/master.m3u8`), not a\n // `<sha16>-h.m3u8` file, and `<dslHash>` is a server-side hash. Deriving a\n // key from the pattern below would produce a URL that 404s on every asset\n // — the same class of polite lie that once pointed a large share of assets\n // at a `-o.<ext>` that was never written. So: the stored URL when the DTO\n // carries it, else the\n // transform route, which 302s to the master and BUILDS the ladder if it is\n // missing. Both are real; neither is a guess.\n const stored = asset.variants?.find((v) => v.preset === \"hls\")?.url;\n if (stored) return stored;\n return `${cdnBaseUrl}/t/format=hls/${asset.sha}.m3u8`;\n }\n return `${cdnBaseUrl}/${variantPrefix()}${asset.sha}-${PRESET_SHORT[preset]}.${PRESET_EXT[preset]}`;\n}\n\n/**\n * The signed URL for one preset of a PRIVATE asset — what every refusal above\n * points at.\n *\n * ```ts\n * // On your BACKEND, once you have decided this viewer may see it:\n * const url = await getPrivateAssetUrl(asset, \"lg\", tenantSigningKey, {\n * expiresInSeconds: 300,\n * });\n * ```\n *\n * It works on a public asset too — `/a/` is a different door onto the same\n * object — but there is no reason to pay for it: a public URL is cacheable at\n * the edge and costs nothing, a signed one is neither.\n *\n * ⚠️ **Backend only.** Handing the signing key to a browser lets any visitor\n * mint URLs for every private asset the tenant owns, which is the whole\n * property the private tree exists to provide.\n *\n * ⚠️ Needs {@link setTenantId} (or a `NitidaClient` with `tenantId`), like\n * every variant URL builder: the tenant segment is base36 and part of what the\n * signature covers, so a missing tenant does not produce a wrong URL — it\n * produces an unsignable one.\n */\nexport async function getPrivateAssetUrl(\n asset: Pick<AssetDTO, \"sha\"> & OriginalHints,\n preset: VariantPreset,\n signingKey: string,\n opts: SignAccessOptions,\n): Promise<string> {\n return signAccessUrl(buildPublicAssetUrl(asset, preset), signingKey, opts);\n}\n\n/**\n * The signed URL for a TRANSFORM of a private asset — an arbitrary width, crop\n * or format, not just the sizes that happen to be materialised.\n *\n * ```ts\n * const url = await getPrivateTransformUrl(\n * asset,\n * { width: 1280, format: \"webp\" },\n * tenantSigningKey,\n * { expiresInSeconds: 300 },\n * );\n * // → https://8ok.uk/a/5/t/format=webp,width=1280/<sha>.webp?exp=…&sig=…\n * ```\n *\n * Why this exists at all: a private asset that can only be served at the sizes\n * someone already generated is barely a product. The signed tree mirrors the\n * public one, transforms included.\n *\n * Returns `null` when `opts` serialize to an empty DSL — same contract as\n * {@link getTransformUrl}, because \"no transform requested\" is not an error,\n * it just means you wanted {@link getPrivateAssetUrl}.\n *\n * ⚠️ **Backend only**, like every signer here. And note the width is a plain\n * `number`: a signed URL is a trusted caller, so the edge ladder does not\n * apply — the same rule `getSignedTransformUrl` already follows.\n */\nexport async function getPrivateTransformUrl(\n asset: Pick<AssetDTO, \"sha\">,\n opts: SignedTransformOptions,\n signingKey: string,\n signOpts: SignAccessOptions,\n): Promise<string | null> {\n const url = getTransformUrlUnchecked(asset, opts);\n if (!url) return null;\n // `/t/<dsl>/<file>` has no tenant in it — the private tree needs one, and it\n // is the same process-global the variant builders use.\n const tid = getTenantId();\n if (tid == null) {\n throw new Error(\n \"getPrivateTransformUrl: no tenant is configured. Call setTenantId(id) (or construct a NitidaClient with `tenantId`) — the tenant is part of what the signature covers, so this cannot be guessed.\",\n );\n }\n const u = new URL(url);\n return signAccessUrl(\n `${u.origin}/${tid.toString(36)}${u.pathname}`,\n signingKey,\n signOpts,\n );\n}\n\n/**\n * Did the processor actually generate this preset?\n *\n * Reads `dto.presets` — the compact 1-char code string the server sends on EVERY shape, including\n * the slim list/resolver one that carries no `variants` at all. That is why this exists and why it\n * stays the right existence check even now that `GET /assets/:id` really does send `variants`\n * (it did not until the 2026-08-17 deploy).\n *\n * @example\n * ```ts\n * import { hasPreset } from \"@nitida/asset-client\";\n *\n * hasPreset({ presets: \"oq\" }, \"original\"); // → true (\"o\" = original, \"q\" = thumb)\n * hasPreset({ presets: \"oq\" }, \"lg\"); // → false — do not link to it\n * hasPreset({ presets: \"pv\" }, \"aiproxy\"); // → false — video without the AI proxy\n * ```\n */\nexport function hasPreset(\n asset: Pick<AssetDTO, \"presets\">,\n preset: VariantPreset,\n): boolean {\n if (preset === \"mp3\") return asset.presets.includes(\"mp3\");\n return stripMultiCharTokens(asset.presets).includes(PRESET_SHORT[preset]);\n}\n\n/**\n * Remove every MULTI-character token from a `presets` string, leaving only the\n * 1-char codes that a `.includes` can safely be run against.\n *\n * `presets` is documented as a concatenation of 1-char codes, and membership is\n * a 1-char substring test — so any longer token is a false-positive generator.\n * Servers before the 2026-08-17 deploy emitted several:\n *\n * | token in the string | letters it donates | presets it falsely answers |\n * |---|---|---|\n * | `transform-<hex>` | t r a n s f o m + a–f | `sm` `md` `original` `aiproxy` |\n * | `pr` (probe) | p r | `poster` |\n * | `mp3` (audio) | m p | `md` `poster` — stripped here since forever |\n *\n * This is not theoretical: on a corpus written by pre-2026-08-17 servers the\n * majority of rows carried a polluted string, and a large minority of those\n * were told they have an `original` they do not — which builds a `-o.<ext>`\n * URL that 404s. `aiproxy` is affected at the same order of magnitude, `sm`\n * and `md` far less, and the `pr` → `poster` collision is real but rare.\n *\n * Current servers no longer emit these, but this stays: older server deploys\n * keep sending them, and this is the check every guide points at as the\n * reliable one. Order matters — strip the longest tokens first.\n */\nfunction stripMultiCharTokens(presets: string): string {\n return presets\n .replace(/transform-[0-9a-f]*/g, \"\")\n .replace(/upscale_[a-z0-9_]*/g, \"\")\n .replace(/mp3/g, \"\")\n .replace(/u[2-8]|t[1248ghij]/g, \"\")\n .replace(/pr/g, \"\");\n}\n\n/**\n * Build a srcSet string for responsive `<img>`. Walks the available image\n * presets in size order and only includes the ones the asset actually has.\n *\n * <img\n * src={getAssetUrl(asset, 'web')}\n * srcSet={getAssetSrcSet(asset)}\n * sizes=\"(max-width: 768px) 100vw, 800px\"\n * />\n */\nconst IMAGE_PRESETS: VariantPreset[] = [\"thumb\", \"sm\", \"md\", \"lg\", \"xl\"];\nexport function getAssetSrcSet(\n asset: Pick<AssetDTO, \"sha\" | \"presets\"> & VisibilityHint,\n): string {\n // ⚠️ NOT given the transform fallback that `getAssetUrl` has, on purpose.\n //\n // A srcSet is a set of PROMISES about pixel width, and the fallback cannot\n // keep them. `/t/width=3840/` on a 900 px source returns 900 px — sharp runs\n // `withoutEnlargement: true` — so the candidate would advertise 3840w and\n // deliver 900, and the browser would pick it for a large viewport and get\n // the small image. That is worse than the empty srcSet it replaces: an empty\n // srcSet degrades to `src`, which now resolves through the fallback and\n // works. A lying srcSet degrades to a wrong choice, silently.\n //\n // Doing this properly means capping the rungs by the asset's real width, and\n // this signature does not carry it (`Pick<AssetDTO,\"sha\"|\"presets\">`). Worth\n // doing; not worth guessing.\n assertSha(asset, \"getAssetSrcSet\");\n assertPublic(\n asset,\n \"getAssetSrcSet\",\n \"getPrivateAssetUrl(asset, preset, signingKey, { expiresInSeconds: 300 }) per preset\",\n );\n return IMAGE_PRESETS.filter(\n (p) => hasPreset(asset, p) && PRESET_MAX_DIM[p] != null,\n )\n .map((p) => `${getAssetUrl(asset, p)} ${PRESET_MAX_DIM[p]}w`)\n .join(\", \");\n}\n\n/**\n * Compute the dimensions a variant would have given the source asset's\n * width/height and the variant's bounding box. For thumbnails (square\n * smart-crop) the result is always 256×256; for the other presets, scales\n * the max side to the box dimension and the other side proportionally.\n */\nexport function computeVariantDimensions(\n asset: Pick<AssetDTO, \"w\" | \"h\">,\n preset: VariantPreset,\n): { width: number; height: number } | null {\n const cap = PRESET_MAX_DIM[preset];\n if (cap == null) return null;\n if (preset === \"thumb\") return { width: cap, height: cap };\n if (!asset.w || !asset.h) return null;\n const scale = Math.min(cap / asset.w, cap / asset.h, 1);\n return {\n width: Math.round(asset.w * scale),\n height: Math.round(asset.h * scale),\n };\n}\n\n/** Source dimensions, for aspect-ratio sizing. */\nexport function getAssetDimensions(\n asset: Pick<AssetDTO, \"w\" | \"h\">,\n): { width: number; height: number } | null {\n if (asset.w && asset.h) return { width: asset.w, height: asset.h };\n return null;\n}\n\n// ---------------------------------------------------------------------------\n// Slot system — tenant-named asset bindings.\n// See ./slots for full docs.\n// ---------------------------------------------------------------------------\n\nexport {\n configureSlotResolver,\n invalidateSlotCache,\n type ResolveSlotOptions,\n resolveSlot,\n resolveSlots,\n type SlotDTO,\n type SlotResolution,\n} from \"./slots\";\n\n// ---------------------------------------------------------------------------\n// On-the-fly transforms — see ./transform for docs.\n// ---------------------------------------------------------------------------\n\nexport {\n extractAssetSha,\n getHlsStreamingUrl,\n getSignedTransformUrl,\n getTransformSrcSet,\n getTransformUrl,\n getVideoTransformUrl,\n type SignedTransformOptions,\n serializeTransform,\n signTransformUrl,\n TRANSFORM_WIDTHS,\n type TransformEffect,\n type TransformFit,\n type TransformFormat,\n type TransformGravity,\n type TransformOptions,\n type TransformWidth,\n} from \"./transform\";\n"],"mappings":"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","names":["toHex"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nitida/asset-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.1",
|
|
4
4
|
"description": "nitida URL builders — construct image, video and HLS URLs for the nitida CDN. No network, no key, no config beyond a tenant id.",
|
|
5
5
|
"private": false,
|
|
6
6
|
"publishConfig": {
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
"provenance": false
|
|
9
9
|
},
|
|
10
10
|
"//repository": "REMOVED 2026-08-21, deliberately. It pointed at espaciofuturoio/aquienpz, which is PRIVATE, so npm rendered a `Repository` link that answers 404 to everyone who clicked it. No repository field renders no link, which is the honest outcome. Nothing depends on it: the publish workflow does NOT pass --provenance (npm rejects provenance from private repos with E422), so this field was rendering a broken link and nothing else. `homepage` and `bugs` below both point at the docs site, which is the real destination. Restore it if the repo is ever made public.",
|
|
11
|
-
"license": "
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"author": "Espacio Futuro LTD (https://espaciofuturo.io)",
|
|
12
13
|
"type": "module",
|
|
13
14
|
"sideEffects": false,
|
|
14
15
|
"main": "./dist/index.cjs",
|
|
@@ -27,6 +28,7 @@
|
|
|
27
28
|
}
|
|
28
29
|
},
|
|
29
30
|
"files": [
|
|
31
|
+
"SECURITY.md",
|
|
30
32
|
"dist/**",
|
|
31
33
|
"src/**",
|
|
32
34
|
"AGENTS.md"
|
package/src/access.ts
CHANGED
|
@@ -26,6 +26,20 @@
|
|
|
26
26
|
*
|
|
27
27
|
* A signed URL that never expires is a public URL as soon as someone forwards
|
|
28
28
|
* it. There is no "no expiry" option here, and there will not be one.
|
|
29
|
+
*
|
|
30
|
+
* ## Where `signingKey` comes from
|
|
31
|
+
*
|
|
32
|
+
* The response that created your project. `POST /admin/projects` returns
|
|
33
|
+
* `signingKey` next to the three API keys, and the console shows it in the
|
|
34
|
+
* same panel — **once**. Save it with the keys.
|
|
35
|
+
*
|
|
36
|
+
* If it is gone — or you never saw one, which is the case for every project
|
|
37
|
+
* created before 2026-08-23 — the only endpoint that returns a key is
|
|
38
|
+
* `POST /admin/projects/:code/rotate-signing-key`, and rotating invalidates
|
|
39
|
+
* every URL already signed. That is free for a tenant with nothing in flight
|
|
40
|
+
* and expensive for a live one, which is exactly why the key is handed over at
|
|
41
|
+
* creation, when rotating would be free anyway. With URLs already circulating,
|
|
42
|
+
* ask the platform operator for the current key rather than rotating.
|
|
29
43
|
*/
|
|
30
44
|
|
|
31
45
|
const ACCESS_KEY_INFO = "nitida/access/v1";
|
|
@@ -184,17 +198,23 @@ export function assertPublic(
|
|
|
184
198
|
/**
|
|
185
199
|
* The call to make instead — declared per call site, not guessed.
|
|
186
200
|
*
|
|
201
|
+
* Named `escapeHatch`, not `escape`: the bare name shadows the deprecated
|
|
202
|
+
* global `escape`, which biome flags as an error. Nothing here calls that
|
|
203
|
+
* global, so this was never a defect — but it is a lint error standing in a
|
|
204
|
+
* PUBLISHED package, and a parameter name is not part of the API, so the
|
|
205
|
+
* cost of clearing it is zero.
|
|
206
|
+
*
|
|
187
207
|
* It matters which one: `getPrivateAssetUrl` signs a STORED preset, and
|
|
188
208
|
* pointing a transform caller at it sends them to a function that cannot do
|
|
189
209
|
* what they asked for. The first version of this message named
|
|
190
210
|
* `getPrivateAssetUrl` for all seven builders; a test caught it.
|
|
191
211
|
*/
|
|
192
|
-
|
|
212
|
+
escapeHatch: string,
|
|
193
213
|
): void {
|
|
194
214
|
if (asset.visibility !== "private") return;
|
|
195
215
|
throw new Error(
|
|
196
216
|
`${fn}: this asset is private, so a public CDN URL for it will answer 404 — that is the feature, not a missing file. ` +
|
|
197
|
-
`Mint a signed URL on your BACKEND instead: await ${
|
|
217
|
+
`Mint a signed URL on your BACKEND instead: await ${escapeHatch}. ` +
|
|
198
218
|
"Never ship the signing key to a browser.",
|
|
199
219
|
);
|
|
200
220
|
}
|