@nitida/asset-client 0.21.0 → 0.24.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitida/asset-client",
3
- "version": "0.21.0",
3
+ "version": "0.24.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
- * 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.
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
- 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";
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
- * The same set as {@link RequestablePreset}, at RUNTIME.
85
- *
86
- * The type stops the mistake in TypeScript. It cannot stop it anywhere else,
87
- * and "anywhere else" is where it keeps happening: a preset list assembled
88
- * from config, from a route body, from JSON, or from a script's argv arrives
89
- * as `string[]`, and the only way past the type was a cast.
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 SignedTransformOptions,
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: SignedTransformOptions,
885
+ opts: PrivateTransformOptions,
885
886
  signingKey: string,
886
887
  signOpts: SignAccessOptions,
887
888
  ): Promise<string | null> {
@@ -1069,13 +1070,20 @@ 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,
1085
+ getByteBudgetTransformUrl,
1086
+ hasSizeLadder,
1079
1087
  serializeTransform,
1080
1088
  signTransformUrl,
1081
1089
  TRANSFORM_WIDTHS,
@@ -1085,4 +1093,5 @@ export {
1085
1093
  type TransformGravity,
1086
1094
  type TransformOptions,
1087
1095
  type TransformWidth,
1096
+ transformMessage,
1088
1097
  } 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, hasPreset } 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, 1280,
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
- * custom width, use {@link SignedTransformOptions} (number) via
39
- * {@link getSignedTransformUrl} / `aq.transform(asset, opts, { sign: true })`.
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 for unsigned URLs, so the type forbids
72
- * them at compile time. For SIGNED URLs with a custom width, use
73
- * {@link SignedTransformOptions} (which widens this to `number`).
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,8 +92,37 @@ export type TransformOptions = {
81
92
  gravity?: TransformGravity;
82
93
  /** Output format. `auto` → the platform's policy decides. */
83
94
  format?: TransformFormat;
84
- /** Output quality. `auto` → format-specific default. */
85
- quality?: "auto" | number;
95
+ /**
96
+ * ⛔ **`quality` no existe en este tipo, y eso es deliberado.**
97
+ *
98
+ * No es un dial del encoder: elige QUÉ BYTES decodifica el servidor. Con un
99
+ * número, `/t/` salta a la variante almacenada más chica que cubra el pedido
100
+ * — que ya pasó por una compresión — y la salida es una segunda generación.
101
+ * Sobre un asset CON escalera eso es pérdida pura (medido: +2…+8 % de peso y
102
+ * −0,50…−0,85 dB). Y `"auto"` era un no-op: byte a byte idéntico a omitir la
103
+ * clave, mismo sha256.
104
+ *
105
+ * El único uso legítimo —un presupuesto de bytes medido, sobre un asset SIN
106
+ * escalera— tiene su propia puerta, que no se puede llamar a ciegas:
107
+ * {@link getByteBudgetTransformUrl}.
108
+ *
109
+ * ## Por qué no alcanzaba con documentarlo
110
+ *
111
+ * Estuvo `@deprecated` con la medición al lado durante exactamente una
112
+ * versión, y eso ya era mejor que nada. Pero un aviso **avisa**; no impide.
113
+ * `getTransformUrl` recibe un `Pick<AssetDTO, "sha">` —un sha y nada más—,
114
+ * así que un `quality: 75` en el call site era **ciego**: nadie ahí, humano o
115
+ * modelo, podía saber si ese asset tenía escalera. La misma línea era
116
+ * correcta o dañina según un dato que no estaba en la llamada.
117
+ *
118
+ * Y hay un llamador que no lee tildados: **un modelo generando código.**
119
+ * `quality` es el parámetro que todo el mundo espera encontrar en una API de
120
+ * imágenes, así que se escribe solo. Next.js llegó a la misma conclusión por
121
+ * el mismo camino y dejó de recomendar `quality` por imagen: la configuración
122
+ * ya decide, y un valor por llamada sólo agrega formas de equivocarse.
123
+ *
124
+ * ⇒ El parámetro no se documenta como peligroso. **No se puede escribir.**
125
+ */
86
126
  /** Device pixel ratio. Width/height are multiplied by this before resize. */
87
127
  dpr?: 1 | 2 | 3;
88
128
  /**
@@ -116,21 +156,49 @@ export type TransformOptions = {
116
156
  };
117
157
 
118
158
  /**
119
- * Like {@link TransformOptions} but with `width` widened to any `number` —
120
- * the escape hatch for SIGNED URLs that need an off-ladder custom width.
121
- *
122
- * The edge whitelist only rejects off-ladder widths on UNSIGNED URLs; a valid
123
- * `?sig=` earns the whitelist bypass at the edge (the server still
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}.
159
+ * Options for a SIGNED transform URL.
160
+ *
161
+ * ⚠️ **`width` is the same ladder as unsigned**, since `@nitida/asset-client`
162
+ * 0.22.0. This type used to widen it to any `number`, because a valid `?sig=`
163
+ * made the CDN edge skip its whitelist entirely so a signature bought an
164
+ * off-ladder size. It does not any more: the edge validates every `/t/`
165
+ * request identically and answers 400 for an off-ladder width whether or not
166
+ * it is signed (doc blindaje WS-6). Keeping the wide type would only let
167
+ * TypeScript bless a URL that 400s.
168
+ *
169
+ * A signature now buys IDENTITY: which tenant asked, proven with its key, until
170
+ * `exp`. That is what a strict tenant and the paid-effect cost guard require.
171
+ */
172
+ export type SignedTransformOptions = TransformOptions;
173
+
174
+ /**
175
+ * `width` widened to any `number` — the PRIVATE tree's option type.
176
+ *
177
+ * ⚠️ Not an escape hatch for the public tree: `/t/` answers 400 for an
178
+ * off-ladder width, signed or not (doc blindaje WS-6). What this is for is the
179
+ * `/a/<tenant>/t/…` tree, where the ladder deliberately does NOT apply — the
180
+ * request arrives HMAC-verified against the tenant's key, so only a key-holder
181
+ * can ask, and "any width/crop/format, not just the materialised ones" is the
182
+ * written promise of `getPrivateTransformUrl`.
183
+ *
184
+ * Also used by the two builders that take an explicit ladder the caller chose
185
+ * (`getTransformSrcSet`) or that are documented as unchecked
186
+ * (`getTransformUrlUnchecked`).
128
187
  */
129
- export type SignedTransformOptions = Omit<TransformOptions, "width"> & {
130
- /** Off-ladder width — valid ONLY on signed URLs (edge whitelist bypass). */
188
+ export type PrivateTransformOptions = Omit<TransformOptions, "width"> & {
131
189
  width?: number;
132
190
  };
133
191
 
192
+ /**
193
+ * `TransformOptions` + el `quality` que el tipo público ya no admite.
194
+ *
195
+ * INTERNO. Existe porque el serializador tiene que poder emitir `quality=` para
196
+ * {@link getByteBudgetTransformUrl}; no porque un llamador deba construirlo.
197
+ */
198
+ type WithPinnedQuality<T> = T & { quality: number };
199
+ /** @deprecated internal alias kept for the builders below. */
200
+ type AnyWidthTransformOptions = PrivateTransformOptions;
201
+
134
202
  /**
135
203
  * Serialize transform options into the canonical DSL path segment.
136
204
  * Empty options return an empty string (caller should fall back to a
@@ -165,11 +233,13 @@ export function extractAssetSha(url: string | null | undefined): string | null {
165
233
  return m ? m[1]! : null;
166
234
  }
167
235
 
168
- export function serializeTransform(opts: SignedTransformOptions): string {
236
+ export function serializeTransform(
237
+ opts: AnyWidthTransformOptions | WithPinnedQuality<AnyWidthTransformOptions>,
238
+ ): string {
169
239
  const entries: Array<[string, string]> = [];
170
- const keys = Object.keys(opts).sort() as Array<keyof SignedTransformOptions>;
240
+ const keys = Object.keys(opts).sort();
171
241
  for (const k of keys) {
172
- const v = opts[k];
242
+ const v = (opts as Record<string, unknown>)[k];
173
243
  if (v == null) continue;
174
244
  const serialized = typeof v === "string" ? v.toLowerCase() : String(v);
175
245
  entries.push([k, serialized]);
@@ -177,7 +247,9 @@ export function serializeTransform(opts: SignedTransformOptions): string {
177
247
  return entries.map(([k, v]) => `${k}=${v}`).join(",");
178
248
  }
179
249
 
180
- function extForOptions(opts: SignedTransformOptions): string {
250
+ function extForOptions(
251
+ opts: AnyWidthTransformOptions | WithPinnedQuality<AnyWidthTransformOptions>,
252
+ ): string {
181
253
  // effect=removebg forces PNG output server-side (needs alpha).
182
254
  if (opts.effect === "removebg") return "png";
183
255
  // effect=genfill defaults to WebP (12× lighter than the raw generated
@@ -326,7 +398,7 @@ export { buildTransformUrl as getTransformUrlUnchecked };
326
398
 
327
399
  function buildTransformUrl(
328
400
  asset: Pick<AssetDTO, "sha">,
329
- opts: SignedTransformOptions,
401
+ opts: AnyWidthTransformOptions | WithPinnedQuality<AnyWidthTransformOptions>,
330
402
  ): string | null {
331
403
  const dsl = serializeTransform(opts);
332
404
  if (!dsl) return null;
@@ -348,20 +420,133 @@ export function getTransformUrl(
348
420
  }
349
421
 
350
422
  /**
351
- * Build AND sign a transform URL, allowing an off-ladder custom `width`.
423
+ * Build AND sign a transform URL.
352
424
  *
353
- * This is the escape hatch for {@link SignedTransformOptions}: off-ladder
354
- * widths only pass the edge whitelist when the URL is signed, so building one
355
- * and signing it must happen together. For on-ladder widths prefer the plain
356
- * {@link getTransformUrl} (+ {@link signTransformUrl} if you need a signature).
425
+ * A signature names the caller it is what a `strict_transforms` tenant
426
+ * requires and what the paid-effect (`effect=genfill`) cost guard requires. It
427
+ * does NOT widen the ladder; see {@link SignedTransformOptions}.
357
428
  *
358
429
  * Returns `null` only when `opts` serialize to an empty DSL (no transform
359
430
  * requested) — same contract as {@link getTransformUrl}.
360
431
  */
432
+ /**
433
+ * Los presets de TAMAÑO — los únicos cuya existencia convierte un `quality`
434
+ * pinneado en una segunda compresión.
435
+ *
436
+ * `original`, `poster`, `video`, `aiproxy`, `hls` y `mp3` NO cuentan: `/t/`
437
+ * nunca los usa como fuente de una imagen redimensionada.
438
+ */
439
+ const LADDER_PRESETS = ["thumb", "sm", "md", "lg", "xl"] as const;
440
+
441
+ /**
442
+ * ¿Este asset tiene variantes de tamaño almacenadas, o sea algo a lo que `/t/`
443
+ * pueda saltar?
444
+ *
445
+ * `null` cuando **no se sabe** — un DTO sin `presets` no dice que no las tenga,
446
+ * dice que no lo trae. Tres estados, tres respuestas: un booleano acá mentiría
447
+ * en un tercio de los casos, y mentiría hacia el lado inseguro.
448
+ *
449
+ * ⚠️ **Se pregunta con `hasPreset`, JAMÁS con `presets.includes("s")`.** La
450
+ * cadena lleva tokens de varias letras —`transform-<hex>`, `upscale_…`, `pr`—
451
+ * que donan `s`, `m`, `l` y `o` de regalo. Ese es el falso positivo que
452
+ * `test/presets-false-positives.test.ts` existe para cazar.
453
+ */
454
+ export function hasSizeLadder(asset: {
455
+ presets?: string | null;
456
+ }): boolean | null {
457
+ const raw = asset.presets;
458
+ if (raw == null || raw.trim() === "") return null;
459
+ return LADDER_PRESETS.some((preset) => hasPreset(asset, preset));
460
+ }
461
+
462
+ /**
463
+ * ⭐ **La ÚNICA puerta por la que se puede pinnear un `quality` numérico** —
464
+ * y está construida para que no se pueda usar mal.
465
+ *
466
+ * ## El problema que resuelve
467
+ *
468
+ * `getTransformUrl` recibe un `Pick<AssetDTO, "sha">`: **un sha y nada más.**
469
+ * Con eso, un `quality: 75` en el call site es CIEGO — la misma línea es un
470
+ * ajuste honesto del encoder sobre un asset sin escalera, y una segunda
471
+ * compresión silenciosa sobre uno con escalera. Nada en el tipo, en el nombre
472
+ * ni en el editor distinguía los dos casos. Un aviso avisa; esto impide.
473
+ *
474
+ * ## Las dos barreras
475
+ *
476
+ * 1. **En COMPILACIÓN**: el parámetro exige `presets: string`. Un
477
+ * `{ sha }` pelado —la llamada ciega— ya no compila. Para pasar por acá hay
478
+ * que tener el DTO en la mano, y tener el DTO es saber la respuesta.
479
+ * 2. **En EJECUCIÓN**: si el asset tiene cualquier preset de tamaño, **tira**.
480
+ * Si `presets` no vino, **tira** — «no sé» nunca se resuelve como «dale».
481
+ *
482
+ * ## Cuándo es legítimo, con el número
483
+ *
484
+ * Un presupuesto de bytes que alguien MIDIÓ, sobre un asset subido con
485
+ * `presets: ["original"]`. Ahí no hay a qué saltar y el pin hace lo que su
486
+ * nombre dice. Medido 2026-08-31 contra producción, ancho 1 920, las seis
487
+ * respuestas `x-transform-source: original`:
488
+ *
489
+ * | quality | bytes | vs auto | PSNR |
490
+ * |---|---|---|---|
491
+ * | 40 | 82 112 | **−33,6 %** | 35,32 dB |
492
+ * | 60 | 106 892 | −13,5 % | 36,85 dB |
493
+ * | *(auto)* | 123 614 | — | 37,65 dB |
494
+ * | 90 | 269 026 | +117,6 % | 41,46 dB |
495
+ *
496
+ * Monótona y sin sorpresas: −33,6 % de peso por −2,32 dB. **Eso sí es un
497
+ * intercambio**, y es la razón por la que el parámetro no se borró del todo.
498
+ *
499
+ * @throws si el asset tiene escalera, si no se sabe si la tiene, o si
500
+ * `quality` no está en 1..100.
501
+ */
502
+ export function getByteBudgetTransformUrl(
503
+ asset: Pick<AssetDTO, "sha"> & { presets: string } & VisibilityHint,
504
+ opts: WithPinnedQuality<TransformOptions>,
505
+ ): string | null {
506
+ assertSha(asset, "getByteBudgetTransformUrl");
507
+ assertPublic(
508
+ asset,
509
+ "getByteBudgetTransformUrl",
510
+ "getPrivateTransformUrl(asset, opts, signingKey, { expiresInSeconds: 300 })",
511
+ );
512
+
513
+ if (
514
+ !Number.isInteger(opts.quality) ||
515
+ opts.quality < 1 ||
516
+ opts.quality > 100
517
+ ) {
518
+ throw new Error(
519
+ `getByteBudgetTransformUrl: quality must be an integer 1..100, got ${String(opts.quality)}. ` +
520
+ "If you do not have a measured byte budget, use getTransformUrl and omit quality entirely.",
521
+ );
522
+ }
523
+
524
+ const ladder = hasSizeLadder(asset);
525
+ if (ladder === null) {
526
+ throw new Error(
527
+ `getByteBudgetTransformUrl: asset ${asset.sha} carries no 'presets', so whether a pinned ` +
528
+ "quality would re-compress it is UNKNOWN — and unknown is not permission. Fetch the full " +
529
+ "DTO (assets.get / assets.byHash) and pass it, or use getTransformUrl with no quality.",
530
+ );
531
+ }
532
+ if (ladder) {
533
+ throw new Error(
534
+ `getByteBudgetTransformUrl: asset ${asset.sha} has stored size variants (presets="${asset.presets}"). ` +
535
+ "A pinned quality there does not set the encoder — it makes /t/ decode one of those " +
536
+ "already-compressed variants, so the output is a SECOND lossy generation: measured " +
537
+ "+2..+8% HEAVIER and -0.50..-0.85 dB. Use getTransformUrl with no quality (one pass from " +
538
+ "the master), or upload this asset with presets: [\"original\"] if the byte budget is real.",
539
+ );
540
+ }
541
+
542
+ return buildTransformUrl(asset, opts);
543
+ }
544
+
361
545
  export function getSignedTransformUrl(
362
546
  asset: Pick<AssetDTO, "sha"> & VisibilityHint,
363
547
  opts: SignedTransformOptions,
364
548
  signingKey: string,
549
+ signOpts: SignTransformOptions,
365
550
  ): Promise<string> | null {
366
551
  // ⭐ The guard belongs here MOST of all, and it was the one place it was
367
552
  // missing. A `?sig=` on `/t/` is a WIDTH permit, not access: on a private
@@ -377,12 +562,73 @@ export function getSignedTransformUrl(
377
562
  );
378
563
  const url = buildTransformUrl(asset, opts);
379
564
  if (!url) return null;
380
- return signTransformUrl(url, signingKey);
565
+ return signTransformUrl(url, signingKey, signOpts);
566
+ }
567
+
568
+ /** Domain separator for the v2 transform message. */
569
+ const TRANSFORM_SIG_DOMAIN = "nitida/transform/v2";
570
+ /** Domain separator for `kid` derivation. */
571
+ const KID_INFO = "nitida/kid/v1";
572
+
573
+ /**
574
+ * The longest life a signed transform URL may claim: **7 days**.
575
+ *
576
+ * ⚠️ Must equal `MAX_SIGNED_TRANSFORM_TTL_SECONDS` on the origin
577
+ * (`transform.signing.ts`), which enforces it on every verify — asking for
578
+ * more here would only build a URL the platform answers 401 to, which is why
579
+ * this throws instead.
580
+ */
581
+ export const MAX_SIGNED_TRANSFORM_TTL_SECONDS = 7 * 24 * 60 * 60; // 604 800
582
+
583
+ /**
584
+ * The shortest, so that the minute-rounding below can never mint a dead URL.
585
+ * 120 s = two rounding steps of headroom.
586
+ */
587
+ export const MIN_SIGNED_TRANSFORM_TTL_SECONDS = 120;
588
+
589
+ export type SignTransformOptions = {
590
+ /**
591
+ * Lifetime in seconds, 120 .. 604 800. Required — there is no "forever"
592
+ * option, for the same reason `signAccessUrl` has none: a signed URL that
593
+ * never expires is a public URL the moment someone forwards it.
594
+ */
595
+ expiresInSeconds: number;
596
+ /**
597
+ * The tenant this signature speaks for. Defaults to the process-global
598
+ * (`setTenantId`, or a `NitidaClient` with `tenantId`) — the tenant is part
599
+ * of the signed message, so it cannot be guessed from the asset.
600
+ */
601
+ tenantId?: number;
602
+ /** Injectable clock, for tests that need a URL already dead on arrival. */
603
+ nowSeconds?: number;
604
+ };
605
+
606
+ /**
607
+ * The public name of a signing key, derived from the key itself, so a signer
608
+ * never has to be told a second value. Byte-identical to the origin's
609
+ * `deriveKid`.
610
+ */
611
+ export async function deriveTransformKid(signingKey: string): Promise<string> {
612
+ return (await hmacSha256Hex(signingKey, KID_INFO)).slice(0, 8);
613
+ }
614
+
615
+ /**
616
+ * The signed message. Byte-identical to the origin's `transformMessage`.
617
+ *
618
+ * nitida/transform/v2 \n <tenantPrefix> \n <exp> \n <dsl>/<filename>
619
+ */
620
+ export function transformMessage(args: {
621
+ tenantPrefix: string;
622
+ exp: number;
623
+ canonicalDsl: string;
624
+ filename: string;
625
+ }): string {
626
+ return `${TRANSFORM_SIG_DOMAIN}\n${args.tenantPrefix}\n${args.exp}\n${args.canonicalDsl}/${args.filename}`;
381
627
  }
382
628
 
383
629
  /**
384
630
  * Sign a transform URL with the tenant's HMAC signing key. Appends
385
- * `?sig=<hex>` where hex = HMAC-SHA256(signingKey, `<canonical-DSL>/<filename>`).
631
+ * `?kid=<8 hex>&exp=<unix seconds>&sig=<64 hex>`.
386
632
  *
387
633
  * Must agree byte-for-byte with the server's `verifyTransformSignature`.
388
634
  * Uses WebCrypto, so works in browsers, Node ≥ 16, Bun, and Workers.
@@ -390,11 +636,40 @@ export function getSignedTransformUrl(
390
636
  * The canonical DSL is the one already produced by `serializeTransform`
391
637
  * (sort keys + lowercase strings), so signing a URL built by `getTransformUrl`
392
638
  * is automatic — the same canonical form is in the URL path.
639
+ *
640
+ * ## ⚠️ `exp` IS ROUNDED DOWN TO THE MINUTE, AND THAT IS LOAD-BEARING
641
+ *
642
+ * The edge cache key for `/t/` is the FULL URL. A per-request `exp` would make
643
+ * every render of the same image a distinct cache entry — turning a path that
644
+ * reaches the origin roughly never into one that reaches it on every view.
645
+ * Rounding down to the minute means every renderer signing the same URL in the
646
+ * same minute produces the same bytes, so the entry is shared. Down, never up,
647
+ * so the URL never outlives the lifetime the caller asked for.
648
+ *
649
+ * A build-time signer that wants ONE stable URL per deploy should pass a fixed
650
+ * `nowSeconds` (the build timestamp) rather than a longer lifetime.
393
651
  */
394
652
  export async function signTransformUrl(
395
653
  unsignedUrl: string,
396
654
  signingKey: string,
655
+ opts: SignTransformOptions,
397
656
  ): Promise<string> {
657
+ const ttl = Math.floor(opts.expiresInSeconds);
658
+ if (
659
+ !Number.isFinite(ttl) ||
660
+ ttl < MIN_SIGNED_TRANSFORM_TTL_SECONDS ||
661
+ ttl > MAX_SIGNED_TRANSFORM_TTL_SECONDS
662
+ ) {
663
+ throw new Error(
664
+ `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.`,
665
+ );
666
+ }
667
+ const tid = opts.tenantId ?? getTenantId();
668
+ if (tid == null) {
669
+ throw new Error(
670
+ "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.",
671
+ );
672
+ }
398
673
  const u = new URL(unsignedUrl);
399
674
  // Path shape: /t/<dsl>/<filename>
400
675
  const parts = u.pathname.split("/").filter(Boolean);
@@ -406,8 +681,19 @@ export async function signTransformUrl(
406
681
  }
407
682
  const filename = parts[parts.length - 1]!;
408
683
  const dsl = parts.slice(1, -1).join("/");
409
- const message = `${dsl}/${filename}`;
410
- const sig = await hmacSha256Hex(signingKey, message);
684
+ const now = opts.nowSeconds ?? Math.floor(Date.now() / 1000);
685
+ const exp = Math.floor((now + ttl) / 60) * 60;
686
+ const sig = await hmacSha256Hex(
687
+ signingKey,
688
+ transformMessage({
689
+ tenantPrefix: tid.toString(36),
690
+ exp,
691
+ canonicalDsl: dsl,
692
+ filename,
693
+ }),
694
+ );
695
+ u.searchParams.set("kid", await deriveTransformKid(signingKey));
696
+ u.searchParams.set("exp", String(exp));
411
697
  u.searchParams.set("sig", sig);
412
698
  return u.toString();
413
699
  }