@nitida/asset-client 0.21.0 → 0.23.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 +7 -0
- package/README.md +9 -4
- package/dist/index.cjs +67 -14
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +183 -42
- package/dist/index.d.ts +183 -42
- package/dist/index.js +61 -13
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/access.ts +21 -1
- package/src/index.ts +37 -30
- package/src/transform.ts +202 -32
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nitida/asset-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
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": {
|
package/src/access.ts
CHANGED
|
@@ -92,8 +92,23 @@ export function accessMessage(
|
|
|
92
92
|
return `${tenantPrefix}\n${exp}\n${resourcePath.replace(/^\/+/, "")}`;
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
/**
|
|
96
|
+
* The longest life a signed URL may claim: **7 days**.
|
|
97
|
+
*
|
|
98
|
+
* ⚠️ Must equal `MAX_SIGNED_TRANSFORM_TTL_SECONDS` on the origin and
|
|
99
|
+
* `MAX_SIGNED_URL_TTL_SECONDS` in the CDN worker — one policy, three runtimes,
|
|
100
|
+
* pinned in all three suites. Both verifiers refuse anything longer, so a
|
|
101
|
+
* bigger number here would only mint a URL that 401s.
|
|
102
|
+
*
|
|
103
|
+
* Why there is a ceiling at all: `expiresInSeconds` was validated as
|
|
104
|
+
* "> 0" and nothing else, so `{ expiresInSeconds: 315_360_000 }` produced a
|
|
105
|
+
* ten-year link that every check called valid. "Has an expiry" and "expires"
|
|
106
|
+
* are different properties, and only the second makes a leaked link die.
|
|
107
|
+
*/
|
|
108
|
+
export const MAX_SIGNED_URL_TTL_SECONDS = 7 * 24 * 60 * 60; // 604 800
|
|
109
|
+
|
|
95
110
|
export type SignAccessOptions = {
|
|
96
|
-
/** Lifetime in seconds. Required — see the header. */
|
|
111
|
+
/** Lifetime in seconds, 1 .. 604 800. Required — see the header. */
|
|
97
112
|
expiresInSeconds: number;
|
|
98
113
|
/** Injectable clock, for tests that need a URL already dead on arrival. */
|
|
99
114
|
nowSeconds?: number;
|
|
@@ -118,6 +133,11 @@ export async function signAccessUrl(
|
|
|
118
133
|
"signAccessUrl: `expiresInSeconds` must be a positive number — a signed URL without an expiry is a public URL the moment it is forwarded.",
|
|
119
134
|
);
|
|
120
135
|
}
|
|
136
|
+
if (opts.expiresInSeconds > MAX_SIGNED_URL_TTL_SECONDS) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
`signAccessUrl: \`expiresInSeconds\` may not exceed ${MAX_SIGNED_URL_TTL_SECONDS} (7 days). Both verifiers refuse a longer one, so this would build a URL that 401s. An expiry that never arrives is not an expiry.`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
121
141
|
const u = new URL(publicUrl);
|
|
122
142
|
const segments = u.pathname.split("/").filter(Boolean);
|
|
123
143
|
// ⚠️ `a` is BOTH the private tree's prefix and tenant 10 in base36, so
|
package/src/index.ts
CHANGED
|
@@ -68,25 +68,37 @@ export type VariantPreset =
|
|
|
68
68
|
* **`probe` is requestable and was not on `VariantPreset` at all**, so the type
|
|
69
69
|
* forbade a request the server has always accepted.
|
|
70
70
|
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
71
|
+
* Generated from the Elysia schemas of all four write routes — `/assets/process`,
|
|
72
|
+
* the presign route, `/assets/:id/regenerate` and both multipart routes —
|
|
73
|
+
* by `scripts/gen-docs.ts` (doc derivar WS-4). `bun run gen:docs` rewrites it;
|
|
74
|
+
* `--check` fails the build if a route's schema drifts and nobody reran it.
|
|
75
|
+
* `probe` is still frames at evenly spaced offsets, stored under indexed keys
|
|
76
|
+
* (`-pr0.jpg`, `-pr1.jpg`, …) — requestable, and deliberately absent from the
|
|
77
|
+
* compact `presets` wire string, so it is here and not on `VariantPreset`.
|
|
75
78
|
*/
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
79
|
+
// BEGIN GENERATED: requestable-preset · bun run gen:docs
|
|
80
|
+
export const REQUESTABLE_PRESETS = [
|
|
81
|
+
"aiproxy",
|
|
82
|
+
"lg",
|
|
83
|
+
"md",
|
|
84
|
+
"original",
|
|
85
|
+
"poster",
|
|
86
|
+
"probe",
|
|
87
|
+
"sm",
|
|
88
|
+
"thumb",
|
|
89
|
+
"video",
|
|
90
|
+
"xl",
|
|
91
|
+
] as const;
|
|
92
|
+
export type RequestablePreset = (typeof REQUESTABLE_PRESETS)[number];
|
|
93
|
+
// END GENERATED: requestable-preset
|
|
82
94
|
|
|
83
95
|
/**
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
96
|
+
* `REQUESTABLE_PRESETS` (above) is the same set as `RequestablePreset`, at
|
|
97
|
+
* RUNTIME. The type stops the mistake in TypeScript. It cannot stop it
|
|
98
|
+
* anywhere else, and "anywhere else" is where it keeps happening: a preset
|
|
99
|
+
* list assembled from config, from a route body, from JSON, or from a
|
|
100
|
+
* script's argv arrives as `string[]`, and the only way past the type was a
|
|
101
|
+
* cast.
|
|
90
102
|
*
|
|
91
103
|
* Measured in neo-real-estate on 2026-08-23, in THREE independent files:
|
|
92
104
|
*
|
|
@@ -98,18 +110,6 @@ export type RequestablePreset =
|
|
|
98
110
|
*
|
|
99
111
|
* So the narrowing lives here, once, instead of being re-invented per repo.
|
|
100
112
|
*/
|
|
101
|
-
export const REQUESTABLE_PRESETS: readonly RequestablePreset[] = [
|
|
102
|
-
"thumb",
|
|
103
|
-
"sm",
|
|
104
|
-
"md",
|
|
105
|
-
"lg",
|
|
106
|
-
"xl",
|
|
107
|
-
"original",
|
|
108
|
-
"poster",
|
|
109
|
-
"video",
|
|
110
|
-
"aiproxy",
|
|
111
|
-
"probe",
|
|
112
|
-
] as const;
|
|
113
113
|
|
|
114
114
|
/** Type guard for a single value. */
|
|
115
115
|
export const isRequestablePreset = (v: string): v is RequestablePreset =>
|
|
@@ -446,6 +446,7 @@ export {
|
|
|
446
446
|
assertPublic,
|
|
447
447
|
assertSha,
|
|
448
448
|
deriveAccessKey,
|
|
449
|
+
MAX_SIGNED_URL_TTL_SECONDS,
|
|
449
450
|
type SignAccessOptions,
|
|
450
451
|
signAccessUrl,
|
|
451
452
|
type VisibilityHint,
|
|
@@ -477,7 +478,7 @@ import {
|
|
|
477
478
|
import type { AssetPalette } from "./palette";
|
|
478
479
|
import {
|
|
479
480
|
getTransformUrlUnchecked,
|
|
480
|
-
type
|
|
481
|
+
type PrivateTransformOptions,
|
|
481
482
|
} from "./transform";
|
|
482
483
|
|
|
483
484
|
// ---------------------------------------------------------------------------
|
|
@@ -881,7 +882,7 @@ export async function getPrivateAssetUrl(
|
|
|
881
882
|
*/
|
|
882
883
|
export async function getPrivateTransformUrl(
|
|
883
884
|
asset: Pick<AssetDTO, "sha">,
|
|
884
|
-
opts:
|
|
885
|
+
opts: PrivateTransformOptions,
|
|
885
886
|
signingKey: string,
|
|
886
887
|
signOpts: SignAccessOptions,
|
|
887
888
|
): Promise<string | null> {
|
|
@@ -1069,13 +1070,18 @@ export {
|
|
|
1069
1070
|
// ---------------------------------------------------------------------------
|
|
1070
1071
|
|
|
1071
1072
|
export {
|
|
1073
|
+
deriveTransformKid,
|
|
1072
1074
|
extractAssetSha,
|
|
1073
1075
|
getHlsStreamingUrl,
|
|
1074
1076
|
getSignedTransformUrl,
|
|
1075
1077
|
getTransformSrcSet,
|
|
1076
1078
|
getTransformUrl,
|
|
1077
1079
|
getVideoTransformUrl,
|
|
1080
|
+
MAX_SIGNED_TRANSFORM_TTL_SECONDS,
|
|
1081
|
+
MIN_SIGNED_TRANSFORM_TTL_SECONDS,
|
|
1082
|
+
type PrivateTransformOptions,
|
|
1078
1083
|
type SignedTransformOptions,
|
|
1084
|
+
type SignTransformOptions,
|
|
1079
1085
|
serializeTransform,
|
|
1080
1086
|
signTransformUrl,
|
|
1081
1087
|
TRANSFORM_WIDTHS,
|
|
@@ -1085,4 +1091,5 @@ export {
|
|
|
1085
1091
|
type TransformGravity,
|
|
1086
1092
|
type TransformOptions,
|
|
1087
1093
|
type TransformWidth,
|
|
1094
|
+
transformMessage,
|
|
1088
1095
|
} from "./transform";
|
package/src/transform.ts
CHANGED
|
@@ -20,23 +20,34 @@
|
|
|
20
20
|
|
|
21
21
|
import { assertPublic, assertSha, type VisibilityHint } from "./access";
|
|
22
22
|
import type { AssetDTO } from "./index";
|
|
23
|
-
import { getCdnBase } from "./index";
|
|
23
|
+
import { getCdnBase, getTenantId } from "./index";
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* Widths the CDN edge whitelists (DoS guard).
|
|
27
27
|
* Requesting any OTHER width returns HTTP 400 at the edge (unsigned URLs). These are the
|
|
28
28
|
* 1× base ladder values; DPR ×2/×3 multiples are applied + whitelisted server-side. Import
|
|
29
29
|
* this instead of hardcoding magic widths so an unsupported size is caught in review/IDE.
|
|
30
|
+
*
|
|
31
|
+
* ⭐ **180 es el `apple-touch-icon`, y está acá por eso.** Apple pide 180×180 para
|
|
32
|
+
* el icono de pantalla de inicio en un iPhone 3×, y el monorepo de neo ya lo
|
|
33
|
+
* estandarizó: `webapp-storefront` commitea un `apple-icon.png` de 180×180 por
|
|
34
|
+
* tenant. Sin este escalón, una app que sirve el icono desde el CDN tiene que
|
|
35
|
+
* elegir entre 160 (iOS lo agranda, sale borroso) y 240 (lo achica, sale bien
|
|
36
|
+
* pero pesa de más). Medido 2026-08-29 sobre la foto de un agente: 240² en PNG
|
|
37
|
+
* son 57,5 KB contra 6,5 KB del WebP a 400², así que el tamaño de más no es
|
|
38
|
+
* gratis. El caso real: `apps/bio-web/src/lib/seo/favicon.ts`, que sirve **la
|
|
39
|
+
* foto del propio negocio** como icono de su página.
|
|
30
40
|
*/
|
|
31
41
|
export const TRANSFORM_WIDTHS = [
|
|
32
|
-
96, 128, 160, 240, 256, 320, 400, 480, 600, 640, 800, 960, 1080, 1200,
|
|
33
|
-
1440, 1600, 1920, 2560, 3840,
|
|
42
|
+
96, 128, 160, 180, 240, 256, 320, 400, 480, 600, 640, 800, 960, 1080, 1200,
|
|
43
|
+
1280, 1440, 1600, 1920, 2560, 3840,
|
|
34
44
|
] as const;
|
|
35
45
|
/**
|
|
36
46
|
* A CDN-whitelisted transform width — the only widths `TransformOptions.width`
|
|
37
47
|
* accepts. Off-ladder widths are a compile error; for signed URLs that need a
|
|
38
|
-
*
|
|
39
|
-
*
|
|
48
|
+
* signature, use {@link getSignedTransformUrl} /
|
|
49
|
+
* `aq.transform(asset, opts, { sign: true, expiresInSeconds })` — which names
|
|
50
|
+
* the caller but does NOT widen the ladder.
|
|
40
51
|
*/
|
|
41
52
|
export type TransformWidth = (typeof TRANSFORM_WIDTHS)[number];
|
|
42
53
|
|
|
@@ -68,9 +79,9 @@ export type TransformOptions = {
|
|
|
68
79
|
/**
|
|
69
80
|
* Target max-side width in CSS pixels (multiplied by `dpr` server-side).
|
|
70
81
|
* MUST be a {@link TRANSFORM_WIDTHS} value — off-ladder widths are rejected
|
|
71
|
-
* (HTTP 400) by the edge whitelist
|
|
72
|
-
*
|
|
73
|
-
*
|
|
82
|
+
* (HTTP 400) by the edge whitelist, so the type forbids them at compile
|
|
83
|
+
* time. Signing does NOT lift this: since doc blindaje WS-6 the edge
|
|
84
|
+
* validates signed and unsigned requests identically.
|
|
74
85
|
*/
|
|
75
86
|
width?: TransformWidth;
|
|
76
87
|
/** Target max-side height. Multiplied by `dpr` server-side. */
|
|
@@ -81,7 +92,43 @@ export type TransformOptions = {
|
|
|
81
92
|
gravity?: TransformGravity;
|
|
82
93
|
/** Output format. `auto` → the platform's policy decides. */
|
|
83
94
|
format?: TransformFormat;
|
|
84
|
-
/**
|
|
95
|
+
/**
|
|
96
|
+
* @deprecated **Do not set this.** Omit the key — that is the correct call for
|
|
97
|
+
* every surface this platform serves. Kept in the type for one narrow case
|
|
98
|
+
* (a hard, MEASURED byte budget), and struck through on purpose so reaching
|
|
99
|
+
* for it is a decision, not an accident.
|
|
100
|
+
*
|
|
101
|
+
* There are only two things you can pass, and neither is worth having:
|
|
102
|
+
*
|
|
103
|
+
* **`"auto"` is a no-op.** Byte-for-byte identical to omitting the key —
|
|
104
|
+
* measured 2026-08-31 on a production laddered asset at `width=1920`:
|
|
105
|
+
* both answered 136 680 B, sha256 `3a9ba57d…`, `x-transform-source:
|
|
106
|
+
* original`. It buys nothing but the illusion of having chosen.
|
|
107
|
+
*
|
|
108
|
+
* **A NUMBER costs you image quality.** It is not an encoder knob — it
|
|
109
|
+
* selects WHICH BYTES the server decodes. With a number in hand, `/t/`
|
|
110
|
+
* short-circuits to the smallest STORED variant that covers the request, and
|
|
111
|
+
* that variant already went through one lossy pass, so the output is a second
|
|
112
|
+
* generation. `"auto"` is a *string*, fails that `typeof` test, and therefore
|
|
113
|
+
* keeps the master path. Measured on two corpora:
|
|
114
|
+
*
|
|
115
|
+
* - 5 photographs, `width=800` → 7–9 % smaller **and worse 5 of 5**, down
|
|
116
|
+
* to −3.01 dB PSNR (`/guides/transform-benchmark/`).
|
|
117
|
+
* - 5056 px architectural renders at 1280 / 1920 / 3840 — the widths that
|
|
118
|
+
* MATCH `md`/`lg`/`xl` exactly, so no downscale hides the first pass →
|
|
119
|
+
* **+2…+8 % HEAVIER and −0.50…−0.85 dB, 3 of 3**. Not even a byte saving
|
|
120
|
+
* to trade for it.
|
|
121
|
+
*
|
|
122
|
+
* Raising the number does not undo it: doubly-compressed at 80 is worse than
|
|
123
|
+
* single-pass at 60, and 31 % heavier.
|
|
124
|
+
*
|
|
125
|
+
* If you truly have a byte budget, pin it AND upload that asset with
|
|
126
|
+
* `presets: ["original"]` — with no ladder there is nothing to short-circuit
|
|
127
|
+
* to, and the pin becomes an honest encoder setting again.
|
|
128
|
+
*
|
|
129
|
+
* The one-line check is on the response: `x-transform-source` names the
|
|
130
|
+
* variant the edge decoded. `original` = one pass; `md`/`lg`/`xl` = two.
|
|
131
|
+
*/
|
|
85
132
|
quality?: "auto" | number;
|
|
86
133
|
/** Device pixel ratio. Width/height are multiplied by this before resize. */
|
|
87
134
|
dpr?: 1 | 2 | 3;
|
|
@@ -116,20 +163,40 @@ export type TransformOptions = {
|
|
|
116
163
|
};
|
|
117
164
|
|
|
118
165
|
/**
|
|
119
|
-
*
|
|
120
|
-
*
|
|
166
|
+
* Options for a SIGNED transform URL.
|
|
167
|
+
*
|
|
168
|
+
* ⚠️ **`width` is the same ladder as unsigned**, since `@nitida/asset-client`
|
|
169
|
+
* 0.22.0. This type used to widen it to any `number`, because a valid `?sig=`
|
|
170
|
+
* made the CDN edge skip its whitelist entirely — so a signature bought an
|
|
171
|
+
* off-ladder size. It does not any more: the edge validates every `/t/`
|
|
172
|
+
* request identically and answers 400 for an off-ladder width whether or not
|
|
173
|
+
* it is signed (doc blindaje WS-6). Keeping the wide type would only let
|
|
174
|
+
* TypeScript bless a URL that 400s.
|
|
121
175
|
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
* does the real HMAC check). So a custom width is ONLY safe when the URL is
|
|
125
|
-
* signed — hence this type is accepted exclusively by the signing helpers
|
|
126
|
-
* ({@link getSignedTransformUrl} / `aq.transform(asset, opts, { sign: true })`),
|
|
127
|
-
* never by the plain unsigned {@link getTransformUrl}.
|
|
176
|
+
* A signature now buys IDENTITY: which tenant asked, proven with its key, until
|
|
177
|
+
* `exp`. That is what a strict tenant and the paid-effect cost guard require.
|
|
128
178
|
*/
|
|
129
|
-
export type SignedTransformOptions =
|
|
130
|
-
|
|
179
|
+
export type SignedTransformOptions = TransformOptions;
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* `width` widened to any `number` — the PRIVATE tree's option type.
|
|
183
|
+
*
|
|
184
|
+
* ⚠️ Not an escape hatch for the public tree: `/t/` answers 400 for an
|
|
185
|
+
* off-ladder width, signed or not (doc blindaje WS-6). What this is for is the
|
|
186
|
+
* `/a/<tenant>/t/…` tree, where the ladder deliberately does NOT apply — the
|
|
187
|
+
* request arrives HMAC-verified against the tenant's key, so only a key-holder
|
|
188
|
+
* can ask, and "any width/crop/format, not just the materialised ones" is the
|
|
189
|
+
* written promise of `getPrivateTransformUrl`.
|
|
190
|
+
*
|
|
191
|
+
* Also used by the two builders that take an explicit ladder the caller chose
|
|
192
|
+
* (`getTransformSrcSet`) or that are documented as unchecked
|
|
193
|
+
* (`getTransformUrlUnchecked`).
|
|
194
|
+
*/
|
|
195
|
+
export type PrivateTransformOptions = Omit<TransformOptions, "width"> & {
|
|
131
196
|
width?: number;
|
|
132
197
|
};
|
|
198
|
+
/** @deprecated internal alias kept for the builders below. */
|
|
199
|
+
type AnyWidthTransformOptions = PrivateTransformOptions;
|
|
133
200
|
|
|
134
201
|
/**
|
|
135
202
|
* Serialize transform options into the canonical DSL path segment.
|
|
@@ -165,9 +232,11 @@ export function extractAssetSha(url: string | null | undefined): string | null {
|
|
|
165
232
|
return m ? m[1]! : null;
|
|
166
233
|
}
|
|
167
234
|
|
|
168
|
-
export function serializeTransform(opts:
|
|
235
|
+
export function serializeTransform(opts: AnyWidthTransformOptions): string {
|
|
169
236
|
const entries: Array<[string, string]> = [];
|
|
170
|
-
const keys = Object.keys(opts).sort() as Array<
|
|
237
|
+
const keys = Object.keys(opts).sort() as Array<
|
|
238
|
+
keyof AnyWidthTransformOptions
|
|
239
|
+
>;
|
|
171
240
|
for (const k of keys) {
|
|
172
241
|
const v = opts[k];
|
|
173
242
|
if (v == null) continue;
|
|
@@ -177,7 +246,7 @@ export function serializeTransform(opts: SignedTransformOptions): string {
|
|
|
177
246
|
return entries.map(([k, v]) => `${k}=${v}`).join(",");
|
|
178
247
|
}
|
|
179
248
|
|
|
180
|
-
function extForOptions(opts:
|
|
249
|
+
function extForOptions(opts: AnyWidthTransformOptions): string {
|
|
181
250
|
// effect=removebg forces PNG output server-side (needs alpha).
|
|
182
251
|
if (opts.effect === "removebg") return "png";
|
|
183
252
|
// effect=genfill defaults to WebP (12× lighter than the raw generated
|
|
@@ -326,7 +395,7 @@ export { buildTransformUrl as getTransformUrlUnchecked };
|
|
|
326
395
|
|
|
327
396
|
function buildTransformUrl(
|
|
328
397
|
asset: Pick<AssetDTO, "sha">,
|
|
329
|
-
opts:
|
|
398
|
+
opts: AnyWidthTransformOptions,
|
|
330
399
|
): string | null {
|
|
331
400
|
const dsl = serializeTransform(opts);
|
|
332
401
|
if (!dsl) return null;
|
|
@@ -348,12 +417,11 @@ export function getTransformUrl(
|
|
|
348
417
|
}
|
|
349
418
|
|
|
350
419
|
/**
|
|
351
|
-
* Build AND sign a transform URL
|
|
420
|
+
* Build AND sign a transform URL.
|
|
352
421
|
*
|
|
353
|
-
*
|
|
354
|
-
*
|
|
355
|
-
*
|
|
356
|
-
* {@link getTransformUrl} (+ {@link signTransformUrl} if you need a signature).
|
|
422
|
+
* A signature names the caller — it is what a `strict_transforms` tenant
|
|
423
|
+
* requires and what the paid-effect (`effect=genfill`) cost guard requires. It
|
|
424
|
+
* does NOT widen the ladder; see {@link SignedTransformOptions}.
|
|
357
425
|
*
|
|
358
426
|
* Returns `null` only when `opts` serialize to an empty DSL (no transform
|
|
359
427
|
* requested) — same contract as {@link getTransformUrl}.
|
|
@@ -362,6 +430,7 @@ export function getSignedTransformUrl(
|
|
|
362
430
|
asset: Pick<AssetDTO, "sha"> & VisibilityHint,
|
|
363
431
|
opts: SignedTransformOptions,
|
|
364
432
|
signingKey: string,
|
|
433
|
+
signOpts: SignTransformOptions,
|
|
365
434
|
): Promise<string> | null {
|
|
366
435
|
// ⭐ The guard belongs here MOST of all, and it was the one place it was
|
|
367
436
|
// missing. A `?sig=` on `/t/` is a WIDTH permit, not access: on a private
|
|
@@ -377,12 +446,73 @@ export function getSignedTransformUrl(
|
|
|
377
446
|
);
|
|
378
447
|
const url = buildTransformUrl(asset, opts);
|
|
379
448
|
if (!url) return null;
|
|
380
|
-
return signTransformUrl(url, signingKey);
|
|
449
|
+
return signTransformUrl(url, signingKey, signOpts);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** Domain separator for the v2 transform message. */
|
|
453
|
+
const TRANSFORM_SIG_DOMAIN = "nitida/transform/v2";
|
|
454
|
+
/** Domain separator for `kid` derivation. */
|
|
455
|
+
const KID_INFO = "nitida/kid/v1";
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* The longest life a signed transform URL may claim: **7 days**.
|
|
459
|
+
*
|
|
460
|
+
* ⚠️ Must equal `MAX_SIGNED_TRANSFORM_TTL_SECONDS` on the origin
|
|
461
|
+
* (`transform.signing.ts`), which enforces it on every verify — asking for
|
|
462
|
+
* more here would only build a URL the platform answers 401 to, which is why
|
|
463
|
+
* this throws instead.
|
|
464
|
+
*/
|
|
465
|
+
export const MAX_SIGNED_TRANSFORM_TTL_SECONDS = 7 * 24 * 60 * 60; // 604 800
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* The shortest, so that the minute-rounding below can never mint a dead URL.
|
|
469
|
+
* 120 s = two rounding steps of headroom.
|
|
470
|
+
*/
|
|
471
|
+
export const MIN_SIGNED_TRANSFORM_TTL_SECONDS = 120;
|
|
472
|
+
|
|
473
|
+
export type SignTransformOptions = {
|
|
474
|
+
/**
|
|
475
|
+
* Lifetime in seconds, 120 .. 604 800. Required — there is no "forever"
|
|
476
|
+
* option, for the same reason `signAccessUrl` has none: a signed URL that
|
|
477
|
+
* never expires is a public URL the moment someone forwards it.
|
|
478
|
+
*/
|
|
479
|
+
expiresInSeconds: number;
|
|
480
|
+
/**
|
|
481
|
+
* The tenant this signature speaks for. Defaults to the process-global
|
|
482
|
+
* (`setTenantId`, or a `NitidaClient` with `tenantId`) — the tenant is part
|
|
483
|
+
* of the signed message, so it cannot be guessed from the asset.
|
|
484
|
+
*/
|
|
485
|
+
tenantId?: number;
|
|
486
|
+
/** Injectable clock, for tests that need a URL already dead on arrival. */
|
|
487
|
+
nowSeconds?: number;
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* The public name of a signing key, derived from the key itself, so a signer
|
|
492
|
+
* never has to be told a second value. Byte-identical to the origin's
|
|
493
|
+
* `deriveKid`.
|
|
494
|
+
*/
|
|
495
|
+
export async function deriveTransformKid(signingKey: string): Promise<string> {
|
|
496
|
+
return (await hmacSha256Hex(signingKey, KID_INFO)).slice(0, 8);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* The signed message. Byte-identical to the origin's `transformMessage`.
|
|
501
|
+
*
|
|
502
|
+
* nitida/transform/v2 \n <tenantPrefix> \n <exp> \n <dsl>/<filename>
|
|
503
|
+
*/
|
|
504
|
+
export function transformMessage(args: {
|
|
505
|
+
tenantPrefix: string;
|
|
506
|
+
exp: number;
|
|
507
|
+
canonicalDsl: string;
|
|
508
|
+
filename: string;
|
|
509
|
+
}): string {
|
|
510
|
+
return `${TRANSFORM_SIG_DOMAIN}\n${args.tenantPrefix}\n${args.exp}\n${args.canonicalDsl}/${args.filename}`;
|
|
381
511
|
}
|
|
382
512
|
|
|
383
513
|
/**
|
|
384
514
|
* Sign a transform URL with the tenant's HMAC signing key. Appends
|
|
385
|
-
* `?
|
|
515
|
+
* `?kid=<8 hex>&exp=<unix seconds>&sig=<64 hex>`.
|
|
386
516
|
*
|
|
387
517
|
* Must agree byte-for-byte with the server's `verifyTransformSignature`.
|
|
388
518
|
* Uses WebCrypto, so works in browsers, Node ≥ 16, Bun, and Workers.
|
|
@@ -390,11 +520,40 @@ export function getSignedTransformUrl(
|
|
|
390
520
|
* The canonical DSL is the one already produced by `serializeTransform`
|
|
391
521
|
* (sort keys + lowercase strings), so signing a URL built by `getTransformUrl`
|
|
392
522
|
* is automatic — the same canonical form is in the URL path.
|
|
523
|
+
*
|
|
524
|
+
* ## ⚠️ `exp` IS ROUNDED DOWN TO THE MINUTE, AND THAT IS LOAD-BEARING
|
|
525
|
+
*
|
|
526
|
+
* The edge cache key for `/t/` is the FULL URL. A per-request `exp` would make
|
|
527
|
+
* every render of the same image a distinct cache entry — turning a path that
|
|
528
|
+
* reaches the origin roughly never into one that reaches it on every view.
|
|
529
|
+
* Rounding down to the minute means every renderer signing the same URL in the
|
|
530
|
+
* same minute produces the same bytes, so the entry is shared. Down, never up,
|
|
531
|
+
* so the URL never outlives the lifetime the caller asked for.
|
|
532
|
+
*
|
|
533
|
+
* A build-time signer that wants ONE stable URL per deploy should pass a fixed
|
|
534
|
+
* `nowSeconds` (the build timestamp) rather than a longer lifetime.
|
|
393
535
|
*/
|
|
394
536
|
export async function signTransformUrl(
|
|
395
537
|
unsignedUrl: string,
|
|
396
538
|
signingKey: string,
|
|
539
|
+
opts: SignTransformOptions,
|
|
397
540
|
): Promise<string> {
|
|
541
|
+
const ttl = Math.floor(opts.expiresInSeconds);
|
|
542
|
+
if (
|
|
543
|
+
!Number.isFinite(ttl) ||
|
|
544
|
+
ttl < MIN_SIGNED_TRANSFORM_TTL_SECONDS ||
|
|
545
|
+
ttl > MAX_SIGNED_TRANSFORM_TTL_SECONDS
|
|
546
|
+
) {
|
|
547
|
+
throw new Error(
|
|
548
|
+
`signTransformUrl: \`expiresInSeconds\` must be between ${MIN_SIGNED_TRANSFORM_TTL_SECONDS} and ${MAX_SIGNED_TRANSFORM_TTL_SECONDS}. The platform refuses a longer one on every verify, so a bigger number here just builds a URL that 401s.`,
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
const tid = opts.tenantId ?? getTenantId();
|
|
552
|
+
if (tid == null) {
|
|
553
|
+
throw new Error(
|
|
554
|
+
"signTransformUrl: 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.",
|
|
555
|
+
);
|
|
556
|
+
}
|
|
398
557
|
const u = new URL(unsignedUrl);
|
|
399
558
|
// Path shape: /t/<dsl>/<filename>
|
|
400
559
|
const parts = u.pathname.split("/").filter(Boolean);
|
|
@@ -406,8 +565,19 @@ export async function signTransformUrl(
|
|
|
406
565
|
}
|
|
407
566
|
const filename = parts[parts.length - 1]!;
|
|
408
567
|
const dsl = parts.slice(1, -1).join("/");
|
|
409
|
-
const
|
|
410
|
-
const
|
|
568
|
+
const now = opts.nowSeconds ?? Math.floor(Date.now() / 1000);
|
|
569
|
+
const exp = Math.floor((now + ttl) / 60) * 60;
|
|
570
|
+
const sig = await hmacSha256Hex(
|
|
571
|
+
signingKey,
|
|
572
|
+
transformMessage({
|
|
573
|
+
tenantPrefix: tid.toString(36),
|
|
574
|
+
exp,
|
|
575
|
+
canonicalDsl: dsl,
|
|
576
|
+
filename,
|
|
577
|
+
}),
|
|
578
|
+
);
|
|
579
|
+
u.searchParams.set("kid", await deriveTransformKid(signingKey));
|
|
580
|
+
u.searchParams.set("exp", String(exp));
|
|
411
581
|
u.searchParams.set("sig", sig);
|
|
412
582
|
return u.toString();
|
|
413
583
|
}
|