@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/AGENTS.md +54 -0
- package/README.md +31 -3
- package/dist/index.cjs +247 -99
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +305 -87
- package/dist/index.d.ts +305 -87
- package/dist/index.js +241 -99
- package/dist/index.js.map +1 -1
- package/package.json +2 -6
- package/src/access.ts +200 -0
- package/src/index.ts +278 -3
- package/src/transform.ts +39 -5
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
|
-
/**
|
|
121
|
-
|
|
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
|