@nitida/asset-client 0.16.4 → 0.18.0

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/src/access.ts ADDED
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Signed URLs for PRIVATE assets — the `/a/{tenant}/…?exp&sig` tree.
3
+ *
4
+ * ## Who calls this, and who must not
5
+ *
6
+ * The tenant's BACKEND, which knows who the viewer is and holds the signing
7
+ * key. Never a browser: shipping the signing key to the client would let any
8
+ * visitor mint URLs for any private asset of that tenant, which is the whole
9
+ * property the tree exists to provide. This module is deliberately importable
10
+ * from anywhere — it runs on WebCrypto, so browsers, Node, Bun and Workers all
11
+ * work — and that convenience is exactly why the warning is here rather than
12
+ * in a doc nobody reads at the call site.
13
+ *
14
+ * ## Not the same signature as `signTransformUrl`
15
+ *
16
+ * `signTransformUrl` vouches for a WIDTH; this vouches for a VIEWER, until
17
+ * `exp`. They come from the same `signing_key` but not the same key material:
18
+ * the access key is derived (`HMAC(signing_key, "nitida/access/v1")`) so that
19
+ * no crafted transform path can be replayed as an access signature. The full
20
+ * argument lives beside the server implementation in `access-signing.ts`; the
21
+ * short version is that a shared payload with a prefix separator IS
22
+ * collidable, because both fields of the transform message are
23
+ * attacker-influenced path segments.
24
+ *
25
+ * ## `exp` is mandatory
26
+ *
27
+ * A signed URL that never expires is a public URL as soon as someone forwards
28
+ * it. There is no "no expiry" option here, and there will not be one.
29
+ */
30
+
31
+ const ACCESS_KEY_INFO = "nitida/access/v1";
32
+
33
+ async function hmac(
34
+ key: ArrayBuffer | Uint8Array,
35
+ message: string,
36
+ ): Promise<Uint8Array> {
37
+ const cryptoKey = await crypto.subtle.importKey(
38
+ "raw",
39
+ key as unknown as ArrayBuffer,
40
+ { name: "HMAC", hash: "SHA-256" },
41
+ false,
42
+ ["sign"],
43
+ );
44
+ return new Uint8Array(
45
+ await crypto.subtle.sign(
46
+ "HMAC",
47
+ cryptoKey,
48
+ new TextEncoder().encode(message),
49
+ ),
50
+ );
51
+ }
52
+
53
+ const toHex = (b: Uint8Array) =>
54
+ [...b].map((x) => x.toString(16).padStart(2, "0")).join("");
55
+
56
+ /**
57
+ * The per-tenant access key, derived from `signing_key`. Same value the origin
58
+ * computes and hands the edge — nothing needs to be stored or synchronised.
59
+ */
60
+ export async function deriveAccessKey(signingKey: string): Promise<Uint8Array> {
61
+ return hmac(new TextEncoder().encode(signingKey), ACCESS_KEY_INFO);
62
+ }
63
+
64
+ /**
65
+ * The signed message. Byte-identical to the server's `accessMessage` and the
66
+ * worker's — three implementations of one string, which is why all three pin
67
+ * the exact bytes in a test.
68
+ *
69
+ * `tenantPrefix` is the base36 segment as it appears in the URL, not the
70
+ * decimal id: tenant 10 lives at `/a/a/`, and the thing being vouched for is a
71
+ * path.
72
+ */
73
+ export function accessMessage(
74
+ tenantPrefix: string,
75
+ exp: number,
76
+ resourcePath: string,
77
+ ): string {
78
+ return `${tenantPrefix}\n${exp}\n${resourcePath.replace(/^\/+/, "")}`;
79
+ }
80
+
81
+ export type SignAccessOptions = {
82
+ /** Lifetime in seconds. Required — see the header. */
83
+ expiresInSeconds: number;
84
+ /** Injectable clock, for tests that need a URL already dead on arrival. */
85
+ nowSeconds?: number;
86
+ };
87
+
88
+ /**
89
+ * Turn a PUBLIC-tree URL into a signed PRIVATE-tree URL.
90
+ *
91
+ * https://8ok.uk/5/v/<sha16>-lg.webp
92
+ * → https://8ok.uk/a/5/v/<sha16>-lg.webp?exp=…&sig=…
93
+ *
94
+ * Accepts a URL that is already under `/a/` and re-signs it, so calling twice
95
+ * is not an error and does not produce `/a/a/`.
96
+ */
97
+ export async function signAccessUrl(
98
+ publicUrl: string,
99
+ signingKey: string,
100
+ opts: SignAccessOptions,
101
+ ): Promise<string> {
102
+ if (!Number.isFinite(opts.expiresInSeconds) || opts.expiresInSeconds <= 0) {
103
+ throw new Error(
104
+ "signAccessUrl: `expiresInSeconds` must be a positive number — a signed URL without an expiry is a public URL the moment it is forwarded.",
105
+ );
106
+ }
107
+ const u = new URL(publicUrl);
108
+ const segments = u.pathname.split("/").filter(Boolean);
109
+ // ⚠️ `a` is BOTH the private tree's prefix and tenant 10 in base36, so
110
+ // "starts with /a/ ⇒ already signed" would eat tenant 10's own segment and
111
+ // sign `/a/v/…` as if `v` were the tenant. What tells them apart is the
112
+ // resource kind, always `v` or `r` directly after the tenant: the private
113
+ // tree is `/a/<tenant>/<v|r>/…`, and tenant 10's public `/a/v/<sha>-lg.webp`
114
+ // is not. Same base36 trap that makes `/10/` the platform's favourite 404.
115
+ // `[vrt]`, not `[vr]`: `t` joined the private tree when transforms did, and
116
+ // this line was left behind — so re-signing `/a/5/t/<dsl>/<sha>.webp` read
117
+ // `a` as the tenant and `5` as the resource kind, and threw. The check two
118
+ // dozen lines below already said `[vrt]`; a regex that disagrees with its own
119
+ // file is the shape this bug always takes.
120
+ if (segments[0] === "a" && segments[2] && /^[vrt]$/.test(segments[2])) {
121
+ segments.shift();
122
+ }
123
+ const tenantPrefix = segments.shift();
124
+ if (!tenantPrefix || segments.length === 0) {
125
+ throw new Error(
126
+ `signAccessUrl: expected a tenant-prefixed CDN path like /<tenant>/v/<sha>-<preset>.<ext>, got ${u.pathname}`,
127
+ );
128
+ }
129
+ // ⭐ The segment after the tenant is ALWAYS the resource kind. Checking it is
130
+ // not pedantry — it catches the one mistake this signature shape invites.
131
+ //
132
+ // `/t/<dsl>/<sha>.webp` is a real, valid public transform URL, and it is the
133
+ // obvious thing to hand this function. Without this check `t` is read as the
134
+ // TENANT (29 in base36) and the result is `/a/t/<dsl>/…`: a URL that is
135
+ // perfectly signed, structurally plausible, and 404s for a reason nobody can
136
+ // see. Found by using it, 2026-08-22, on the very first private transform.
137
+ //
138
+ // A transform needs its tenant prepended first — which is exactly what
139
+ // `getPrivateTransformUrl` does, so the fix is almost always to call that.
140
+ if (!/^[vrt]$/.test(segments[0]!)) {
141
+ throw new Error(
142
+ `signAccessUrl: expected /<tenant>/<v|r|t>/… but the segment after the tenant is "${segments[0]}". ` +
143
+ (segments[0]?.includes("=")
144
+ ? `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.`
145
+ : `Got ${u.pathname}.`),
146
+ );
147
+ }
148
+ const resourcePath = segments.join("/");
149
+ const now = opts.nowSeconds ?? Math.floor(Date.now() / 1000);
150
+ const exp = now + Math.floor(opts.expiresInSeconds);
151
+
152
+ const sig = toHex(
153
+ await hmac(
154
+ await deriveAccessKey(signingKey),
155
+ accessMessage(tenantPrefix, exp, resourcePath),
156
+ ),
157
+ );
158
+
159
+ u.pathname = `/a/${tenantPrefix}/${resourcePath}`;
160
+ u.searchParams.set("exp", String(exp));
161
+ u.searchParams.set("sig", sig);
162
+ return u.toString();
163
+ }
164
+
165
+ /** What every URL builder accepts so it can refuse a doomed URL. */
166
+ export type VisibilityHint = { visibility?: "public" | "private" };
167
+
168
+ /**
169
+ * Refuse to build a public URL for a private asset.
170
+ *
171
+ * Doctrine of the house: **a silence reads as "you can't"**. Returning
172
+ * `https://8ok.uk/5/v/<sha>-lg.webp` for a private asset is not a smaller
173
+ * failure than throwing — it is a URL that answers 404, in a platform where a
174
+ * 404 has always meant "that file does not exist". The caller then debugs the
175
+ * wrong thing.
176
+ *
177
+ * Only refuses when it was actually TOLD. A caller passing `{ sha }` carries no
178
+ * visibility, and guessing would break every existing call site to protect
179
+ * assets that are not there.
180
+ */
181
+ export function assertPublic(
182
+ asset: VisibilityHint,
183
+ fn: string,
184
+ /**
185
+ * The call to make instead — declared per call site, not guessed.
186
+ *
187
+ * It matters which one: `getPrivateAssetUrl` signs a STORED preset, and
188
+ * pointing a transform caller at it sends them to a function that cannot do
189
+ * what they asked for. The first version of this message named
190
+ * `getPrivateAssetUrl` for all seven builders; a test caught it.
191
+ */
192
+ escape: string,
193
+ ): void {
194
+ if (asset.visibility !== "private") return;
195
+ throw new Error(
196
+ `${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 ${escape}. ` +
198
+ "Never ship the signing key to a browser.",
199
+ );
200
+ }
package/src/index.ts CHANGED
@@ -49,6 +49,37 @@ export type VariantPreset =
49
49
  // both Chrome/Android (webm/opus) and iOS Safari (which can't decode opus).
50
50
  | "mp3";
51
51
 
52
+ /**
53
+ * What you may ASK the server to produce.
54
+ *
55
+ * NOT the same set as {@link VariantPreset}, and conflating the two is the
56
+ * single most expensive type error this package has shipped. Three unknown
57
+ * agents evaluating the SDK all hit it, independently, in the same afternoon:
58
+ *
59
+ * regenerate(id, { presets: ["hls"] }) // compiled → HTTP 400
60
+ * upload(file, { presets: ["mp3"] }) // compiled → HTTP 400
61
+ *
62
+ * Both symbols are perfectly real — they are things a variant CAN BE. Neither
63
+ * is something you can ASK FOR. `hls` is built by the video pipeline when a
64
+ * video is transcoded; `mp3` is emitted automatically alongside any audio
65
+ * original so iOS Safari can play it. You do not order either one.
66
+ *
67
+ * And it was wrong in the other direction too, which nobody had noticed:
68
+ * **`probe` is requestable and was not on `VariantPreset` at all**, so the type
69
+ * forbade a request the server has always accepted.
70
+ *
71
+ * Verified 2026-08-21 against the Elysia schemas of all four write routes —
72
+ * `/assets/process`, the presign route, `/assets/:id/regenerate` and both
73
+ * multipart routes. All four accept exactly this list and nothing else, with
74
+ * no drift between them.
75
+ */
76
+ export type RequestablePreset =
77
+ | Exclude<VariantPreset, "hls" | "mp3">
78
+ // Still frames at evenly spaced offsets, stored under indexed keys
79
+ // (`-pr0.jpg`, `-pr1.jpg`, …). Requestable, and deliberately absent from the
80
+ // compact `presets` wire string — so it is here and not on VariantPreset.
81
+ | "probe";
82
+
52
83
  /** 1-char alias used in storage keys / wire `presets` string. */
53
84
  export const PRESET_SHORT: Record<VariantPreset, string> = {
54
85
  thumb: "q",
@@ -116,9 +147,46 @@ export const PRESET_MAX_DIM: Record<VariantPreset, number | null> = {
116
147
  * One generated variant of an asset. Returned by the admin endpoints
117
148
  * (`GET /assets/:id`, `POST /assets/:id/regenerate`).
118
149
  */
150
+ /**
151
+ * What `AssetVariant.preset` can actually hold.
152
+ *
153
+ * ⚠️ NOT `VariantPreset`, and the difference is a real bug the type used to
154
+ * hide. Measured against the live API on 2026-08-21, one asset came back with
155
+ * **29 variants, 25 of them `transform-<hash>`** — 86 % of the array — while the
156
+ * type said every entry was one of eleven known presets. So this compiles:
157
+ *
158
+ * ```ts
159
+ * for (const v of asset.variants ?? []) getAssetUrl(asset, v.preset);
160
+ * ```
161
+ *
162
+ * `tsc` exits 0, and at runtime 25 of those 29 URLs come out as
163
+ * `<sha>-undefined.undefined` and answer 404, because `PRESET_SHORT[preset]`
164
+ * and `PRESET_EXT[preset]` are `undefined` for a hash that is not a preset.
165
+ *
166
+ * The `transform-*` entries are NOT junk and are not being removed: they are
167
+ * the materialised cache of past on-demand requests — still ready, still free
168
+ * to fetch — and the inventory model treats them as a first-class
169
+ * `transform-cache` family, which is exactly the question a composer asks
170
+ * ("what can I fetch cheaply right now?"). Deleting them would destroy that.
171
+ *
172
+ * So the type tells the truth instead. `(string & {})` keeps autocomplete on
173
+ * the known presets while admitting the rest, and a caller that wants to build
174
+ * a URL now has to narrow first — which is the whole point.
175
+ */
176
+ export type VariantEntryPreset =
177
+ | VariantPreset
178
+ /** Indexed stills (`-pr0.jpg`, …). Requestable, never on the compact string. */
179
+ | "probe"
180
+ /** `transform-<dslHash>` and `upscale_*` — materialised cache, not a rung. */
181
+ | (string & {});
182
+
119
183
  export type AssetVariant = {
120
- /** Long name (`thumb` / `sm` / … / `original`) — see {@link VariantPreset}. */
121
- preset: VariantPreset;
184
+ /**
185
+ * What this entry IS. Usually a named preset; can also be a
186
+ * `transform-<hash>` cache artifact — see {@link VariantEntryPreset} before
187
+ * passing it to {@link getAssetUrl}.
188
+ */
189
+ preset: VariantEntryPreset;
122
190
  /** Public CDN URL of this variant. */
123
191
  url: string;
124
192
  /** Pixel width. Absent for `original`-only assets where image processing was skipped, or for video presets. */
@@ -269,6 +337,23 @@ export type AssetDTO = {
269
337
  */
270
338
  presets: string;
271
339
  status: "processing" | "ready" | "failed";
340
+ /**
341
+ * Who may fetch the bytes.
342
+ *
343
+ * - `"public"` — the CDN serves it to anyone with the URL. The default,
344
+ * and what all 27 484 assets were until this field existed.
345
+ * - `"private"` — every public door answers **404**: the stored variants,
346
+ * the raw original, the HLS ladder and `/t/`. The bytes are reachable
347
+ * only through a signed URL under `/a/{tenant}/…?exp&sig`, which your
348
+ * BACKEND mints with {@link getPrivateAssetUrl}.
349
+ *
350
+ * Optional so an older server that does not send it is read as `"public"` —
351
+ * which is what such a server means.
352
+ *
353
+ * ⚠️ A 404 on a private asset is not a missing file. It is the feature
354
+ * working. See {@link getPrivateAssetUrl}.
355
+ */
356
+ visibility?: "public" | "private";
272
357
  /** Soft-delete timestamp (ISO). Hidden from catalog when set. */
273
358
  deletedAt?: string | null;
274
359
  /**
@@ -286,6 +371,14 @@ export type AssetDTO = {
286
371
  oext?: string | null;
287
372
  };
288
373
 
374
+ export {
375
+ accessMessage,
376
+ assertPublic,
377
+ deriveAccessKey,
378
+ type SignAccessOptions,
379
+ signAccessUrl,
380
+ type VisibilityHint,
381
+ } from "./access";
289
382
  export type {
290
383
  AssetPalette,
291
384
  PaletteSwatch,
@@ -303,7 +396,17 @@ export {
303
396
  relativeLuminance,
304
397
  } from "./palette";
305
398
 
399
+ import {
400
+ assertPublic,
401
+ type SignAccessOptions,
402
+ signAccessUrl,
403
+ type VisibilityHint,
404
+ } from "./access";
306
405
  import type { AssetPalette } from "./palette";
406
+ import {
407
+ getTransformUrlUnchecked,
408
+ type SignedTransformOptions,
409
+ } from "./transform";
307
410
 
308
411
  // ---------------------------------------------------------------------------
309
412
  // CDN base
@@ -449,6 +552,77 @@ type OriginalHints = {
449
552
  * ```
450
553
  */
451
554
  export function getAssetUrl(
555
+ asset: Pick<AssetDTO, "sha"> & OriginalHints & VisibilityHint,
556
+ preset: VariantPreset,
557
+ ): string {
558
+ // Refuses rather than returning a URL that 404s. See `assertPublic`.
559
+ assertPublic(
560
+ asset,
561
+ "getAssetUrl",
562
+ `getPrivateAssetUrl(asset, "${preset}", signingKey, { expiresInSeconds: 300 })`,
563
+ );
564
+ // ⭐ THE TWO DEFAULTS DID NOT COMPOSE, AND THIS IS THE SEAM
565
+ //
566
+ // `upload()` defaults to `presets: ["original"]` — deliberately, so a bare
567
+ // upload never silently spends the storage budget. `urlFor()` defaults to
568
+ // `lg`. Put together, the obvious two-line program a programmatic caller
569
+ // writes —upload, then ask for a URL— produced a **404**, because `lg` was
570
+ // never generated. Measured 2026-08-22: 1 290 assets are in exactly that
571
+ // state, 228 of them in a production tenant.
572
+ //
573
+ // So when the DTO TELLS us the preset was never materialised, fall back to
574
+ // the transform route, which generates it on demand and caches it. The
575
+ // caller gets optimised bytes instead of a dead link, the raw stays
576
+ // untouched, and no storage is spent on sizes nobody asked for — measured on
577
+ // the same tenant, the stored ladder is 79% of raw, so pre-materialising
578
+ // everything would be ~9 GB for sizes that may never be requested.
579
+ //
580
+ // Same discipline as `assertPublic`: this only fires when we were TOLD.
581
+ // `Pick<AssetDTO,"sha">` carries no `presets`, so the common call is
582
+ // untouched and no existing behaviour changes.
583
+ const fallback = transformFallbackFor(asset, preset);
584
+ if (fallback) return fallback;
585
+ return buildPublicAssetUrl(asset, preset);
586
+ }
587
+
588
+ /**
589
+ * The `/t/` URL that stands in for a preset the asset does not have, or `null`
590
+ * when there is nothing to stand in for.
591
+ *
592
+ * ⚠️ It can be over-eager, and that is the honest trade. Measured 2026-08-22:
593
+ * an asset whose row lists only `original` served `-l.webp` with a 200 — the
594
+ * `variants` column under-reports (the contamination behind doc 240 §4.3b), so
595
+ * the fallback sometimes pays for a transform of a rendition that already
596
+ * exists. Both answers are correct bytes; one costs an encode. That is a much
597
+ * smaller wrong than the 404 it replaces, and it heals itself as rows are
598
+ * reconciled — but it is a reason to fix the rows, not to trust them more.
599
+ *
600
+ * Returns null — i.e. keeps the old behaviour — when the DTO does not say what
601
+ * it has, when the preset IS present, or when the preset has no pixel ceiling
602
+ * to translate into a width (`original`, `poster`, `video`, `hls`, `mp3`);
603
+ * those are stored objects, not renditions, and inventing a transform for them
604
+ * would trade a 404 for a wrong answer.
605
+ */
606
+ function transformFallbackFor(
607
+ asset: Pick<AssetDTO, "sha"> & Partial<Pick<AssetDTO, "presets">>,
608
+ preset: VariantPreset,
609
+ ): string | null {
610
+ if (typeof asset.presets !== "string") return null;
611
+ if (hasPreset({ presets: asset.presets }, preset)) return null;
612
+ const maxDim = PRESET_MAX_DIM[preset];
613
+ if (maxDim == null) return null;
614
+ return `${cdnBaseUrl}/t/format=webp,width=${maxDim}/${asset.sha}.webp`;
615
+ }
616
+
617
+ /**
618
+ * The public-tree URL, with no visibility check.
619
+ *
620
+ * Split out because `getPrivateAssetUrl` needs exactly this and must NOT be
621
+ * refused by the guard: the private tree is the same path with `/a/` in front
622
+ * and a signature behind, so the builder that mints a legitimate private URL
623
+ * would otherwise be blocked by the check that exists to send callers to it.
624
+ */
625
+ function buildPublicAssetUrl(
452
626
  asset: Pick<AssetDTO, "sha"> & OriginalHints,
453
627
  preset: VariantPreset,
454
628
  ): string {
@@ -476,6 +650,89 @@ export function getAssetUrl(
476
650
  return `${cdnBaseUrl}/${variantPrefix()}${asset.sha}-${PRESET_SHORT[preset]}.${PRESET_EXT[preset]}`;
477
651
  }
478
652
 
653
+ /**
654
+ * The signed URL for one preset of a PRIVATE asset — what every refusal above
655
+ * points at.
656
+ *
657
+ * ```ts
658
+ * // On your BACKEND, once you have decided this viewer may see it:
659
+ * const url = await getPrivateAssetUrl(asset, "lg", tenantSigningKey, {
660
+ * expiresInSeconds: 300,
661
+ * });
662
+ * ```
663
+ *
664
+ * It works on a public asset too — `/a/` is a different door onto the same
665
+ * object — but there is no reason to pay for it: a public URL is cacheable at
666
+ * the edge and costs nothing, a signed one is neither.
667
+ *
668
+ * ⚠️ **Backend only.** Handing the signing key to a browser lets any visitor
669
+ * mint URLs for every private asset the tenant owns, which is the whole
670
+ * property the private tree exists to provide.
671
+ *
672
+ * ⚠️ Needs {@link setTenantId} (or a `NitidaClient` with `tenantId`), like
673
+ * every variant URL builder: the tenant segment is base36 and part of what the
674
+ * signature covers, so a missing tenant does not produce a wrong URL — it
675
+ * produces an unsignable one.
676
+ */
677
+ export async function getPrivateAssetUrl(
678
+ asset: Pick<AssetDTO, "sha"> & OriginalHints,
679
+ preset: VariantPreset,
680
+ signingKey: string,
681
+ opts: SignAccessOptions,
682
+ ): Promise<string> {
683
+ return signAccessUrl(buildPublicAssetUrl(asset, preset), signingKey, opts);
684
+ }
685
+
686
+ /**
687
+ * The signed URL for a TRANSFORM of a private asset — an arbitrary width, crop
688
+ * or format, not just the sizes that happen to be materialised.
689
+ *
690
+ * ```ts
691
+ * const url = await getPrivateTransformUrl(
692
+ * asset,
693
+ * { width: 1280, format: "webp" },
694
+ * tenantSigningKey,
695
+ * { expiresInSeconds: 300 },
696
+ * );
697
+ * // → https://8ok.uk/a/5/t/format=webp,width=1280/<sha>.webp?exp=…&sig=…
698
+ * ```
699
+ *
700
+ * Why this exists at all: a private asset that can only be served at the sizes
701
+ * someone already generated is barely a product. The signed tree mirrors the
702
+ * public one, transforms included.
703
+ *
704
+ * Returns `null` when `opts` serialize to an empty DSL — same contract as
705
+ * {@link getTransformUrl}, because "no transform requested" is not an error,
706
+ * it just means you wanted {@link getPrivateAssetUrl}.
707
+ *
708
+ * ⚠️ **Backend only**, like every signer here. And note the width is a plain
709
+ * `number`: a signed URL is a trusted caller, so the edge ladder does not
710
+ * apply — the same rule `getSignedTransformUrl` already follows.
711
+ */
712
+ export async function getPrivateTransformUrl(
713
+ asset: Pick<AssetDTO, "sha">,
714
+ opts: SignedTransformOptions,
715
+ signingKey: string,
716
+ signOpts: SignAccessOptions,
717
+ ): Promise<string | null> {
718
+ const url = getTransformUrlUnchecked(asset, opts);
719
+ if (!url) return null;
720
+ // `/t/<dsl>/<file>` has no tenant in it — the private tree needs one, and it
721
+ // is the same process-global the variant builders use.
722
+ const tid = getTenantId();
723
+ if (tid == null) {
724
+ throw new Error(
725
+ "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.",
726
+ );
727
+ }
728
+ const u = new URL(url);
729
+ return signAccessUrl(
730
+ `${u.origin}/${tid.toString(36)}${u.pathname}`,
731
+ signingKey,
732
+ signOpts,
733
+ );
734
+ }
735
+
479
736
  /**
480
737
  * Did the processor actually generate this preset?
481
738
  *
@@ -546,8 +803,26 @@ function stripMultiCharTokens(presets: string): string {
546
803
  */
547
804
  const IMAGE_PRESETS: VariantPreset[] = ["thumb", "sm", "md", "lg", "xl"];
548
805
  export function getAssetSrcSet(
549
- asset: Pick<AssetDTO, "sha" | "presets">,
806
+ asset: Pick<AssetDTO, "sha" | "presets"> & VisibilityHint,
550
807
  ): string {
808
+ // ⚠️ NOT given the transform fallback that `getAssetUrl` has, on purpose.
809
+ //
810
+ // A srcSet is a set of PROMISES about pixel width, and the fallback cannot
811
+ // keep them. `/t/width=3840/` on a 900 px source returns 900 px — sharp runs
812
+ // `withoutEnlargement: true` — so the candidate would advertise 3840w and
813
+ // deliver 900, and the browser would pick it for a large viewport and get
814
+ // the small image. That is worse than the empty srcSet it replaces: an empty
815
+ // srcSet degrades to `src`, which now resolves through the fallback and
816
+ // works. A lying srcSet degrades to a wrong choice, silently.
817
+ //
818
+ // Doing this properly means capping the rungs by the asset's real width, and
819
+ // this signature does not carry it (`Pick<AssetDTO,"sha"|"presets">`). Worth
820
+ // doing; not worth guessing.
821
+ assertPublic(
822
+ asset,
823
+ "getAssetSrcSet",
824
+ "getPrivateAssetUrl(asset, preset, signingKey, { expiresInSeconds: 300 }) per preset",
825
+ );
551
826
  return IMAGE_PRESETS.filter(
552
827
  (p) => hasPreset(asset, p) && PRESET_MAX_DIM[p] != null,
553
828
  )
package/src/transform.ts CHANGED
@@ -18,6 +18,7 @@
18
18
  * request's Accept header.
19
19
  */
20
20
 
21
+ import { assertPublic, type VisibilityHint } from "./access";
21
22
  import type { AssetDTO } from "./index";
22
23
  import { getCdnBase } from "./index";
23
24
 
@@ -227,9 +228,14 @@ function extForOptions(opts: SignedTransformOptions): string {
227
228
  * autoPlay muted loop playsInline />
228
229
  */
229
230
  export function getVideoTransformUrl(
230
- asset: Pick<AssetDTO, "sha">,
231
+ asset: Pick<AssetDTO, "sha"> & VisibilityHint,
231
232
  opts: TransformOptions,
232
233
  ): string | null {
234
+ assertPublic(
235
+ asset,
236
+ "getVideoTransformUrl",
237
+ "getPrivateTransformUrl(asset, opts, signingKey, { expiresInSeconds: 300 })",
238
+ );
233
239
  const dsl = serializeTransform(opts);
234
240
  if (!dsl) return null;
235
241
  const ext = opts.format === "webm" ? "webm" : "mp4";
@@ -295,9 +301,14 @@ export function getVideoTransformUrl(
295
301
  * capped at 1920 wide. `getAssetUrl(sha, "video")` is always <= 1080p.
296
302
  */
297
303
  export function getHlsStreamingUrl(
298
- asset: Pick<AssetDTO, "sha">,
304
+ asset: Pick<AssetDTO, "sha"> & VisibilityHint,
299
305
  opts: Omit<TransformOptions, "format"> = {},
300
306
  ): string {
307
+ assertPublic(
308
+ asset,
309
+ "getHlsStreamingUrl",
310
+ 'getPrivateAssetUrl(asset, "hls", signingKey, { expiresInSeconds: 300 }) — the worker re-signs the playlist children',
311
+ );
301
312
  // Always serialize with format=hls so the server routes correctly.
302
313
  const merged: TransformOptions = { ...opts, format: "hls" };
303
314
  const dsl = serializeTransform(merged);
@@ -309,6 +320,8 @@ export function getHlsStreamingUrl(
309
320
  * callers should prefer the existing variant URL builder in that case so
310
321
  * the request hits a pre-generated variant instead of an on-the-fly encode.
311
322
  */
323
+ export { buildTransformUrl as getTransformUrlUnchecked };
324
+
312
325
  function buildTransformUrl(
313
326
  asset: Pick<AssetDTO, "sha">,
314
327
  opts: SignedTransformOptions,
@@ -320,9 +333,14 @@ function buildTransformUrl(
320
333
  }
321
334
 
322
335
  export function getTransformUrl(
323
- asset: Pick<AssetDTO, "sha">,
336
+ asset: Pick<AssetDTO, "sha"> & VisibilityHint,
324
337
  opts: TransformOptions,
325
338
  ): string | null {
339
+ assertPublic(
340
+ asset,
341
+ "getTransformUrl",
342
+ "getPrivateTransformUrl(asset, opts, signingKey, { expiresInSeconds: 300 })",
343
+ );
326
344
  return buildTransformUrl(asset, opts);
327
345
  }
328
346
 
@@ -338,10 +356,21 @@ export function getTransformUrl(
338
356
  * requested) — same contract as {@link getTransformUrl}.
339
357
  */
340
358
  export function getSignedTransformUrl(
341
- asset: Pick<AssetDTO, "sha">,
359
+ asset: Pick<AssetDTO, "sha"> & VisibilityHint,
342
360
  opts: SignedTransformOptions,
343
361
  signingKey: string,
344
362
  ): Promise<string> | null {
363
+ // ⭐ The guard belongs here MOST of all, and it was the one place it was
364
+ // missing. A `?sig=` on `/t/` is a WIDTH permit, not access: on a private
365
+ // asset the URL it produces is a perfectly signed 404. And this is exactly
366
+ // where a backend developer holding a signing key and a private asset ends
367
+ // up — so without this, the same call site throws when unsigned and returns
368
+ // a doomed URL when signed, which is the worst of both.
369
+ assertPublic(
370
+ asset,
371
+ "getSignedTransformUrl",
372
+ "getPrivateTransformUrl(asset, opts, signingKey, { expiresInSeconds: 300 })",
373
+ );
345
374
  const url = buildTransformUrl(asset, opts);
346
375
  if (!url) return null;
347
376
  return signTransformUrl(url, signingKey);
@@ -405,10 +434,15 @@ async function hmacSha256Hex(key: string, message: string): Promise<string> {
405
434
  * />
406
435
  */
407
436
  export function getTransformSrcSet(
408
- asset: Pick<AssetDTO, "sha">,
437
+ asset: Pick<AssetDTO, "sha"> & VisibilityHint,
409
438
  widths: number[],
410
439
  extraOpts: Omit<TransformOptions, "width"> = {},
411
440
  ): string {
441
+ assertPublic(
442
+ asset,
443
+ "getTransformSrcSet",
444
+ "getPrivateTransformUrl(asset, opts, signingKey, { expiresInSeconds: 300 }) per width",
445
+ );
412
446
  return widths
413
447
  .map((w) => {
414
448
  // Build via the internal (number-width) builder: `widths` is an explicit