@nitida/asset-client 0.20.4 → 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/dist/index.d.ts CHANGED
@@ -56,8 +56,22 @@ declare function deriveAccessKey(signingKey: string): Promise<Uint8Array>;
56
56
  * path.
57
57
  */
58
58
  declare function accessMessage(tenantPrefix: string, exp: number, resourcePath: string): string;
59
+ /**
60
+ * The longest life a signed URL may claim: **7 days**.
61
+ *
62
+ * ⚠️ Must equal `MAX_SIGNED_TRANSFORM_TTL_SECONDS` on the origin and
63
+ * `MAX_SIGNED_URL_TTL_SECONDS` in the CDN worker — one policy, three runtimes,
64
+ * pinned in all three suites. Both verifiers refuse anything longer, so a
65
+ * bigger number here would only mint a URL that 401s.
66
+ *
67
+ * Why there is a ceiling at all: `expiresInSeconds` was validated as
68
+ * "> 0" and nothing else, so `{ expiresInSeconds: 315_360_000 }` produced a
69
+ * ten-year link that every check called valid. "Has an expiry" and "expires"
70
+ * are different properties, and only the second makes a leaked link die.
71
+ */
72
+ declare const MAX_SIGNED_URL_TTL_SECONDS: number;
59
73
  type SignAccessOptions = {
60
- /** Lifetime in seconds. Required — see the header. */
74
+ /** Lifetime in seconds, 1 .. 604 800. Required — see the header. */
61
75
  expiresInSeconds: number;
62
76
  /** Injectable clock, for tests that need a URL already dead on arrival. */
63
77
  nowSeconds?: number;
@@ -248,13 +262,24 @@ declare function getPaletteBlurBackground(palette: AssetPalette | null | undefin
248
262
  * Requesting any OTHER width returns HTTP 400 at the edge (unsigned URLs). These are the
249
263
  * 1× base ladder values; DPR ×2/×3 multiples are applied + whitelisted server-side. Import
250
264
  * this instead of hardcoding magic widths so an unsupported size is caught in review/IDE.
265
+ *
266
+ * ⭐ **180 es el `apple-touch-icon`, y está acá por eso.** Apple pide 180×180 para
267
+ * el icono de pantalla de inicio en un iPhone 3×, y el monorepo de neo ya lo
268
+ * estandarizó: `webapp-storefront` commitea un `apple-icon.png` de 180×180 por
269
+ * tenant. Sin este escalón, una app que sirve el icono desde el CDN tiene que
270
+ * elegir entre 160 (iOS lo agranda, sale borroso) y 240 (lo achica, sale bien
271
+ * pero pesa de más). Medido 2026-08-29 sobre la foto de un agente: 240² en PNG
272
+ * son 57,5 KB contra 6,5 KB del WebP a 400², así que el tamaño de más no es
273
+ * gratis. El caso real: `apps/bio-web/src/lib/seo/favicon.ts`, que sirve **la
274
+ * foto del propio negocio** como icono de su página.
251
275
  */
252
- declare const TRANSFORM_WIDTHS: readonly [96, 128, 160, 240, 256, 320, 400, 480, 600, 640, 800, 960, 1080, 1200, 1280, 1440, 1600, 1920, 2560, 3840];
276
+ declare const TRANSFORM_WIDTHS: readonly [96, 128, 160, 180, 240, 256, 320, 400, 480, 600, 640, 800, 960, 1080, 1200, 1280, 1440, 1600, 1920, 2560, 3840];
253
277
  /**
254
278
  * A CDN-whitelisted transform width — the only widths `TransformOptions.width`
255
279
  * accepts. Off-ladder widths are a compile error; for signed URLs that need a
256
- * custom width, use {@link SignedTransformOptions} (number) via
257
- * {@link getSignedTransformUrl} / `aq.transform(asset, opts, { sign: true })`.
280
+ * signature, use {@link getSignedTransformUrl} /
281
+ * `aq.transform(asset, opts, { sign: true, expiresInSeconds })` — which names
282
+ * the caller but does NOT widen the ladder.
258
283
  */
259
284
  type TransformWidth = (typeof TRANSFORM_WIDTHS)[number];
260
285
  type TransformFit = "cover" | "contain" | "fill" | "inside" | "outside";
@@ -265,9 +290,9 @@ type TransformOptions = {
265
290
  /**
266
291
  * Target max-side width in CSS pixels (multiplied by `dpr` server-side).
267
292
  * MUST be a {@link TRANSFORM_WIDTHS} value — off-ladder widths are rejected
268
- * (HTTP 400) by the edge whitelist for unsigned URLs, so the type forbids
269
- * them at compile time. For SIGNED URLs with a custom width, use
270
- * {@link SignedTransformOptions} (which widens this to `number`).
293
+ * (HTTP 400) by the edge whitelist, so the type forbids them at compile
294
+ * time. Signing does NOT lift this: since doc blindaje WS-6 the edge
295
+ * validates signed and unsigned requests identically.
271
296
  */
272
297
  width?: TransformWidth;
273
298
  /** Target max-side height. Multiplied by `dpr` server-side. */
@@ -278,7 +303,43 @@ type TransformOptions = {
278
303
  gravity?: TransformGravity;
279
304
  /** Output format. `auto` → the platform's policy decides. */
280
305
  format?: TransformFormat;
281
- /** Output quality. `auto` → format-specific default. */
306
+ /**
307
+ * @deprecated **Do not set this.** Omit the key — that is the correct call for
308
+ * every surface this platform serves. Kept in the type for one narrow case
309
+ * (a hard, MEASURED byte budget), and struck through on purpose so reaching
310
+ * for it is a decision, not an accident.
311
+ *
312
+ * There are only two things you can pass, and neither is worth having:
313
+ *
314
+ * **`"auto"` is a no-op.** Byte-for-byte identical to omitting the key —
315
+ * measured 2026-08-31 on a production laddered asset at `width=1920`:
316
+ * both answered 136 680 B, sha256 `3a9ba57d…`, `x-transform-source:
317
+ * original`. It buys nothing but the illusion of having chosen.
318
+ *
319
+ * **A NUMBER costs you image quality.** It is not an encoder knob — it
320
+ * selects WHICH BYTES the server decodes. With a number in hand, `/t/`
321
+ * short-circuits to the smallest STORED variant that covers the request, and
322
+ * that variant already went through one lossy pass, so the output is a second
323
+ * generation. `"auto"` is a *string*, fails that `typeof` test, and therefore
324
+ * keeps the master path. Measured on two corpora:
325
+ *
326
+ * - 5 photographs, `width=800` → 7–9 % smaller **and worse 5 of 5**, down
327
+ * to −3.01 dB PSNR (`/guides/transform-benchmark/`).
328
+ * - 5056 px architectural renders at 1280 / 1920 / 3840 — the widths that
329
+ * MATCH `md`/`lg`/`xl` exactly, so no downscale hides the first pass →
330
+ * **+2…+8 % HEAVIER and −0.50…−0.85 dB, 3 of 3**. Not even a byte saving
331
+ * to trade for it.
332
+ *
333
+ * Raising the number does not undo it: doubly-compressed at 80 is worse than
334
+ * single-pass at 60, and 31 % heavier.
335
+ *
336
+ * If you truly have a byte budget, pin it AND upload that asset with
337
+ * `presets: ["original"]` — with no ladder there is nothing to short-circuit
338
+ * to, and the pin becomes an honest encoder setting again.
339
+ *
340
+ * The one-line check is on the response: `x-transform-source` names the
341
+ * variant the edge decoded. `original` = one pass; `md`/`lg`/`xl` = two.
342
+ */
282
343
  quality?: "auto" | number;
283
344
  /** Device pixel ratio. Width/height are multiplied by this before resize. */
284
345
  dpr?: 1 | 2 | 3;
@@ -312,20 +373,39 @@ type TransformOptions = {
312
373
  duration?: number;
313
374
  };
314
375
  /**
315
- * Like {@link TransformOptions} but with `width` widened to any `number` —
316
- * the escape hatch for SIGNED URLs that need an off-ladder custom width.
317
- *
318
- * The edge whitelist only rejects off-ladder widths on UNSIGNED URLs; a valid
319
- * `?sig=` earns the whitelist bypass at the edge (the server still
320
- * does the real HMAC check). So a custom width is ONLY safe when the URL is
321
- * signed hence this type is accepted exclusively by the signing helpers
322
- * ({@link getSignedTransformUrl} / `aq.transform(asset, opts, { sign: true })`),
323
- * never by the plain unsigned {@link getTransformUrl}.
376
+ * Options for a SIGNED transform URL.
377
+ *
378
+ * ⚠️ **`width` is the same ladder as unsigned**, since `@nitida/asset-client`
379
+ * 0.22.0. This type used to widen it to any `number`, because a valid `?sig=`
380
+ * made the CDN edge skip its whitelist entirely so a signature bought an
381
+ * off-ladder size. It does not any more: the edge validates every `/t/`
382
+ * request identically and answers 400 for an off-ladder width whether or not
383
+ * it is signed (doc blindaje WS-6). Keeping the wide type would only let
384
+ * TypeScript bless a URL that 400s.
385
+ *
386
+ * A signature now buys IDENTITY: which tenant asked, proven with its key, until
387
+ * `exp`. That is what a strict tenant and the paid-effect cost guard require.
388
+ */
389
+ type SignedTransformOptions = TransformOptions;
390
+ /**
391
+ * `width` widened to any `number` — the PRIVATE tree's option type.
392
+ *
393
+ * ⚠️ Not an escape hatch for the public tree: `/t/` answers 400 for an
394
+ * off-ladder width, signed or not (doc blindaje WS-6). What this is for is the
395
+ * `/a/<tenant>/t/…` tree, where the ladder deliberately does NOT apply — the
396
+ * request arrives HMAC-verified against the tenant's key, so only a key-holder
397
+ * can ask, and "any width/crop/format, not just the materialised ones" is the
398
+ * written promise of `getPrivateTransformUrl`.
399
+ *
400
+ * Also used by the two builders that take an explicit ladder the caller chose
401
+ * (`getTransformSrcSet`) or that are documented as unchecked
402
+ * (`getTransformUrlUnchecked`).
324
403
  */
325
- type SignedTransformOptions = Omit<TransformOptions, "width"> & {
326
- /** Off-ladder width — valid ONLY on signed URLs (edge whitelist bypass). */
404
+ type PrivateTransformOptions = Omit<TransformOptions, "width"> & {
327
405
  width?: number;
328
406
  };
407
+ /** @deprecated internal alias kept for the builders below. */
408
+ type AnyWidthTransformOptions = PrivateTransformOptions;
329
409
  /**
330
410
  * Serialize transform options into the canonical DSL path segment.
331
411
  * Empty options return an empty string (caller should fall back to a
@@ -352,7 +432,7 @@ type SignedTransformOptions = Omit<TransformOptions, "width"> & {
352
432
  * to be an aquienpz asset.
353
433
  */
354
434
  declare function extractAssetSha(url: string | null | undefined): string | null;
355
- declare function serializeTransform(opts: SignedTransformOptions): string;
435
+ declare function serializeTransform(opts: AnyWidthTransformOptions): string;
356
436
  /**
357
437
  * Build a transform URL for a VIDEO asset. Same DSL shape as image
358
438
  * transforms; the server branches on the asset's `kind` column. Video
@@ -426,20 +506,66 @@ declare function getHlsStreamingUrl(asset: Pick<AssetDTO, "sha"> & VisibilityHin
426
506
 
427
507
  declare function getTransformUrl(asset: Pick<AssetDTO, "sha"> & VisibilityHint, opts: TransformOptions): string | null;
428
508
  /**
429
- * Build AND sign a transform URL, allowing an off-ladder custom `width`.
509
+ * Build AND sign a transform URL.
430
510
  *
431
- * This is the escape hatch for {@link SignedTransformOptions}: off-ladder
432
- * widths only pass the edge whitelist when the URL is signed, so building one
433
- * and signing it must happen together. For on-ladder widths prefer the plain
434
- * {@link getTransformUrl} (+ {@link signTransformUrl} if you need a signature).
511
+ * A signature names the caller it is what a `strict_transforms` tenant
512
+ * requires and what the paid-effect (`effect=genfill`) cost guard requires. It
513
+ * does NOT widen the ladder; see {@link SignedTransformOptions}.
435
514
  *
436
515
  * Returns `null` only when `opts` serialize to an empty DSL (no transform
437
516
  * requested) — same contract as {@link getTransformUrl}.
438
517
  */
439
- declare function getSignedTransformUrl(asset: Pick<AssetDTO, "sha"> & VisibilityHint, opts: SignedTransformOptions, signingKey: string): Promise<string> | null;
518
+ declare function getSignedTransformUrl(asset: Pick<AssetDTO, "sha"> & VisibilityHint, opts: SignedTransformOptions, signingKey: string, signOpts: SignTransformOptions): Promise<string> | null;
519
+ /**
520
+ * The longest life a signed transform URL may claim: **7 days**.
521
+ *
522
+ * ⚠️ Must equal `MAX_SIGNED_TRANSFORM_TTL_SECONDS` on the origin
523
+ * (`transform.signing.ts`), which enforces it on every verify — asking for
524
+ * more here would only build a URL the platform answers 401 to, which is why
525
+ * this throws instead.
526
+ */
527
+ declare const MAX_SIGNED_TRANSFORM_TTL_SECONDS: number;
528
+ /**
529
+ * The shortest, so that the minute-rounding below can never mint a dead URL.
530
+ * 120 s = two rounding steps of headroom.
531
+ */
532
+ declare const MIN_SIGNED_TRANSFORM_TTL_SECONDS = 120;
533
+ type SignTransformOptions = {
534
+ /**
535
+ * Lifetime in seconds, 120 .. 604 800. Required — there is no "forever"
536
+ * option, for the same reason `signAccessUrl` has none: a signed URL that
537
+ * never expires is a public URL the moment someone forwards it.
538
+ */
539
+ expiresInSeconds: number;
540
+ /**
541
+ * The tenant this signature speaks for. Defaults to the process-global
542
+ * (`setTenantId`, or a `NitidaClient` with `tenantId`) — the tenant is part
543
+ * of the signed message, so it cannot be guessed from the asset.
544
+ */
545
+ tenantId?: number;
546
+ /** Injectable clock, for tests that need a URL already dead on arrival. */
547
+ nowSeconds?: number;
548
+ };
549
+ /**
550
+ * The public name of a signing key, derived from the key itself, so a signer
551
+ * never has to be told a second value. Byte-identical to the origin's
552
+ * `deriveKid`.
553
+ */
554
+ declare function deriveTransformKid(signingKey: string): Promise<string>;
555
+ /**
556
+ * The signed message. Byte-identical to the origin's `transformMessage`.
557
+ *
558
+ * nitida/transform/v2 \n <tenantPrefix> \n <exp> \n <dsl>/<filename>
559
+ */
560
+ declare function transformMessage(args: {
561
+ tenantPrefix: string;
562
+ exp: number;
563
+ canonicalDsl: string;
564
+ filename: string;
565
+ }): string;
440
566
  /**
441
567
  * Sign a transform URL with the tenant's HMAC signing key. Appends
442
- * `?sig=<hex>` where hex = HMAC-SHA256(signingKey, `<canonical-DSL>/<filename>`).
568
+ * `?kid=<8 hex>&exp=<unix seconds>&sig=<64 hex>`.
443
569
  *
444
570
  * Must agree byte-for-byte with the server's `verifyTransformSignature`.
445
571
  * Uses WebCrypto, so works in browsers, Node ≥ 16, Bun, and Workers.
@@ -447,8 +573,20 @@ declare function getSignedTransformUrl(asset: Pick<AssetDTO, "sha"> & Visibility
447
573
  * The canonical DSL is the one already produced by `serializeTransform`
448
574
  * (sort keys + lowercase strings), so signing a URL built by `getTransformUrl`
449
575
  * is automatic — the same canonical form is in the URL path.
576
+ *
577
+ * ## ⚠️ `exp` IS ROUNDED DOWN TO THE MINUTE, AND THAT IS LOAD-BEARING
578
+ *
579
+ * The edge cache key for `/t/` is the FULL URL. A per-request `exp` would make
580
+ * every render of the same image a distinct cache entry — turning a path that
581
+ * reaches the origin roughly never into one that reaches it on every view.
582
+ * Rounding down to the minute means every renderer signing the same URL in the
583
+ * same minute produces the same bytes, so the entry is shared. Down, never up,
584
+ * so the URL never outlives the lifetime the caller asked for.
585
+ *
586
+ * A build-time signer that wants ONE stable URL per deploy should pass a fixed
587
+ * `nowSeconds` (the build timestamp) rather than a longer lifetime.
450
588
  */
451
- declare function signTransformUrl(unsignedUrl: string, signingKey: string): Promise<string>;
589
+ declare function signTransformUrl(unsignedUrl: string, signingKey: string, opts: SignTransformOptions): Promise<string>;
452
590
  /**
453
591
  * Build a responsive `srcSet` string by generating one transform URL per
454
592
  * width. All other options apply to every URL.
@@ -583,19 +721,23 @@ type VariantPreset = "thumb" | "sm" | "md" | "lg" | "xl" | "original" | "poster"
583
721
  * **`probe` is requestable and was not on `VariantPreset` at all**, so the type
584
722
  * forbade a request the server has always accepted.
585
723
  *
586
- * Verified 2026-08-21 against the Elysia schemas of all four write routes —
587
- * `/assets/process`, the presign route, `/assets/:id/regenerate` and both
588
- * multipart routes. All four accept exactly this list and nothing else, with
589
- * no drift between them.
724
+ * Generated from the Elysia schemas of all four write routes — `/assets/process`,
725
+ * the presign route, `/assets/:id/regenerate` and both multipart routes —
726
+ * by `scripts/gen-docs.ts` (doc derivar WS-4). `bun run gen:docs` rewrites it;
727
+ * `--check` fails the build if a route's schema drifts and nobody reran it.
728
+ * `probe` is still frames at evenly spaced offsets, stored under indexed keys
729
+ * (`-pr0.jpg`, `-pr1.jpg`, …) — requestable, and deliberately absent from the
730
+ * compact `presets` wire string, so it is here and not on `VariantPreset`.
590
731
  */
591
- type RequestablePreset = Exclude<VariantPreset, "hls" | "mp3"> | "probe";
732
+ declare const REQUESTABLE_PRESETS: readonly ["aiproxy", "lg", "md", "original", "poster", "probe", "sm", "thumb", "video", "xl"];
733
+ type RequestablePreset = (typeof REQUESTABLE_PRESETS)[number];
592
734
  /**
593
- * The same set as {@link RequestablePreset}, at RUNTIME.
594
- *
595
- * The type stops the mistake in TypeScript. It cannot stop it anywhere else,
596
- * and "anywhere else" is where it keeps happening: a preset list assembled
597
- * from config, from a route body, from JSON, or from a script's argv arrives
598
- * as `string[]`, and the only way past the type was a cast.
735
+ * `REQUESTABLE_PRESETS` (above) is the same set as `RequestablePreset`, at
736
+ * RUNTIME. The type stops the mistake in TypeScript. It cannot stop it
737
+ * anywhere else, and "anywhere else" is where it keeps happening: a preset
738
+ * list assembled from config, from a route body, from JSON, or from a
739
+ * script's argv arrives as `string[]`, and the only way past the type was a
740
+ * cast.
599
741
  *
600
742
  * Measured in neo-real-estate on 2026-08-23, in THREE independent files:
601
743
  *
@@ -607,7 +749,6 @@ type RequestablePreset = Exclude<VariantPreset, "hls" | "mp3"> | "probe";
607
749
  *
608
750
  * So the narrowing lives here, once, instead of being re-invented per repo.
609
751
  */
610
- declare const REQUESTABLE_PRESETS: readonly RequestablePreset[];
611
752
  /** Type guard for a single value. */
612
753
  declare const isRequestablePreset: (v: string) => v is RequestablePreset;
613
754
  /**
@@ -770,6 +911,15 @@ declare function hlsLadderAlignment(rungs: HlsRung[]): {
770
911
  */
771
912
  type AssetDTO = {
772
913
  id: string;
914
+ /**
915
+ * ⭐ The same value as `id`, under the name the WRITE side uses.
916
+ *
917
+ * `upload()` returns `assetId`; the read endpoints returned only `id`, so
918
+ * what `assets.byHash()` handed back could not be fed to `assets.get()`
919
+ * without renaming a field. Optional here because a DTO produced by an older
920
+ * server will not carry it — read `dto.assetId ?? dto.id`.
921
+ */
922
+ assetId?: string;
773
923
  /** First 16 hex chars of sha256 — used to derive CDN URLs. */
774
924
  sha: string;
775
925
  kind: "image" | "video" | "document" | "audio" | "other";
@@ -841,6 +991,13 @@ type OriginalHints = {
841
991
  oext?: string | null;
842
992
  /** Full variant list — carries the stored URL verbatim. Authoritative. */
843
993
  variants?: AssetVariant[];
994
+ /**
995
+ * What the asset IS. Optional because these builders accept a minimal
996
+ * `{ sha }`, and absent means "we cannot know" — never "assume the worst".
997
+ * Used to refuse `hls` on something that can never have a ladder; see the
998
+ * `hls` branch of `getAssetUrl`.
999
+ */
1000
+ kind?: AssetDTO["kind"];
844
1001
  };
845
1002
  /**
846
1003
  * Build the public CDN URL for a specific variant of an asset. The variant
@@ -941,7 +1098,7 @@ declare function getPrivateAssetUrl(asset: Pick<AssetDTO, "sha"> & OriginalHints
941
1098
  * `number`: a signed URL is a trusted caller, so the edge ladder does not
942
1099
  * apply — the same rule `getSignedTransformUrl` already follows.
943
1100
  */
944
- declare function getPrivateTransformUrl(asset: Pick<AssetDTO, "sha">, opts: SignedTransformOptions, signingKey: string, signOpts: SignAccessOptions): Promise<string | null>;
1101
+ declare function getPrivateTransformUrl(asset: Pick<AssetDTO, "sha">, opts: PrivateTransformOptions, signingKey: string, signOpts: SignAccessOptions): Promise<string | null>;
945
1102
  /**
946
1103
  * Did the processor actually generate this preset?
947
1104
  *
@@ -979,4 +1136,4 @@ declare function getAssetDimensions(asset: Pick<AssetDTO, "w" | "h">): {
979
1136
  height: number;
980
1137
  } | null;
981
1138
 
982
- export { type AssetDTO, type AssetPalette, type AssetVariant, type HlsRung, PRESET_EXT, PRESET_LONG, PRESET_MAX_DIM, PRESET_SHORT, type PaletteSwatch, REQUESTABLE_PRESETS, type RequestablePreset, type ResolveSlotOptions, type SignAccessOptions, type SignedTransformOptions, type SlotDTO, type SlotResolution, TRANSFORM_WIDTHS, type TransformEffect, type TransformFit, type TransformFormat, type TransformGravity, type TransformOptions, type TransformWidth, type VariantEntryPreset, type VariantPreset, type VisibilityHint, accessMessage, assertPublic, assertSha, bestTextContrast, computeVariantDimensions, configureSlotResolver, contrastRatio, deriveAccessKey, extractAssetSha, getAmbientGradient, getAssetDimensions, getAssetSrcSet, getAssetUrl, getCdnBase, getHlsLadder, getHlsStreamingUrl, getPaletteBlurBackground, getPaletteCssVars, getPrivateAssetUrl, getPrivateTransformUrl, getSignedTransformUrl, getTenantId, getTextColorForBackground, getTransformSrcSet, getTransformUrl, getVideoTransformUrl, hasPreset, hlsLadderAlignment, invalidateSlotCache, isRequestablePreset, iteratePaletteSwatches, pickAmbientBackground, relativeLuminance, resolveSlot, resolveSlots, serializeTransform, setCdnBase, setTenantId, signAccessUrl, signTransformUrl, toRequestablePresets };
1139
+ export { type AssetDTO, type AssetPalette, type AssetVariant, type HlsRung, MAX_SIGNED_TRANSFORM_TTL_SECONDS, MAX_SIGNED_URL_TTL_SECONDS, MIN_SIGNED_TRANSFORM_TTL_SECONDS, PRESET_EXT, PRESET_LONG, PRESET_MAX_DIM, PRESET_SHORT, type PaletteSwatch, type PrivateTransformOptions, REQUESTABLE_PRESETS, type RequestablePreset, type ResolveSlotOptions, type SignAccessOptions, type SignTransformOptions, type SignedTransformOptions, type SlotDTO, type SlotResolution, TRANSFORM_WIDTHS, type TransformEffect, type TransformFit, type TransformFormat, type TransformGravity, type TransformOptions, type TransformWidth, type VariantEntryPreset, type VariantPreset, type VisibilityHint, accessMessage, assertPublic, assertSha, bestTextContrast, computeVariantDimensions, configureSlotResolver, contrastRatio, deriveAccessKey, deriveTransformKid, extractAssetSha, getAmbientGradient, getAssetDimensions, getAssetSrcSet, getAssetUrl, getCdnBase, getHlsLadder, getHlsStreamingUrl, getPaletteBlurBackground, getPaletteCssVars, getPrivateAssetUrl, getPrivateTransformUrl, getSignedTransformUrl, getTenantId, getTextColorForBackground, getTransformSrcSet, getTransformUrl, getVideoTransformUrl, hasPreset, hlsLadderAlignment, invalidateSlotCache, isRequestablePreset, iteratePaletteSwatches, pickAmbientBackground, relativeLuminance, resolveSlot, resolveSlots, serializeTransform, setCdnBase, setTenantId, signAccessUrl, signTransformUrl, toRequestablePresets, transformMessage };
package/dist/index.js CHANGED
@@ -25,12 +25,18 @@ function accessMessage(tenantPrefix, exp, resourcePath) {
25
25
  ${exp}
26
26
  ${resourcePath.replace(/^\/+/, "")}`;
27
27
  }
28
+ var MAX_SIGNED_URL_TTL_SECONDS = 7 * 24 * 60 * 60;
28
29
  async function signAccessUrl(publicUrl, signingKey, opts) {
29
30
  if (!Number.isFinite(opts.expiresInSeconds) || opts.expiresInSeconds <= 0) {
30
31
  throw new Error(
31
32
  "signAccessUrl: `expiresInSeconds` must be a positive number \u2014 a signed URL without an expiry is a public URL the moment it is forwarded."
32
33
  );
33
34
  }
35
+ if (opts.expiresInSeconds > MAX_SIGNED_URL_TTL_SECONDS) {
36
+ throw new Error(
37
+ `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.`
38
+ );
39
+ }
34
40
  const u = new URL(publicUrl);
35
41
  const segments = u.pathname.split("/").filter(Boolean);
36
42
  if (segments[0] === "a" && segments[2] && /^[vrt]$/.test(segments[2])) {
@@ -186,6 +192,7 @@ var TRANSFORM_WIDTHS = [
186
192
  96,
187
193
  128,
188
194
  160,
195
+ 180,
189
196
  240,
190
197
  256,
191
198
  320,
@@ -289,7 +296,7 @@ function getTransformUrl(asset, opts) {
289
296
  );
290
297
  return buildTransformUrl(asset, opts);
291
298
  }
292
- function getSignedTransformUrl(asset, opts, signingKey) {
299
+ function getSignedTransformUrl(asset, opts, signingKey, signOpts) {
293
300
  assertSha(asset, "getSignedTransformUrl");
294
301
  assertPublic(
295
302
  asset,
@@ -298,9 +305,34 @@ function getSignedTransformUrl(asset, opts, signingKey) {
298
305
  );
299
306
  const url = buildTransformUrl(asset, opts);
300
307
  if (!url) return null;
301
- return signTransformUrl(url, signingKey);
302
- }
303
- async function signTransformUrl(unsignedUrl, signingKey) {
308
+ return signTransformUrl(url, signingKey, signOpts);
309
+ }
310
+ var TRANSFORM_SIG_DOMAIN = "nitida/transform/v2";
311
+ var KID_INFO = "nitida/kid/v1";
312
+ var MAX_SIGNED_TRANSFORM_TTL_SECONDS = 7 * 24 * 60 * 60;
313
+ var MIN_SIGNED_TRANSFORM_TTL_SECONDS = 120;
314
+ async function deriveTransformKid(signingKey) {
315
+ return (await hmacSha256Hex(signingKey, KID_INFO)).slice(0, 8);
316
+ }
317
+ function transformMessage(args) {
318
+ return `${TRANSFORM_SIG_DOMAIN}
319
+ ${args.tenantPrefix}
320
+ ${args.exp}
321
+ ${args.canonicalDsl}/${args.filename}`;
322
+ }
323
+ async function signTransformUrl(unsignedUrl, signingKey, opts) {
324
+ const ttl = Math.floor(opts.expiresInSeconds);
325
+ if (!Number.isFinite(ttl) || ttl < MIN_SIGNED_TRANSFORM_TTL_SECONDS || ttl > MAX_SIGNED_TRANSFORM_TTL_SECONDS) {
326
+ throw new Error(
327
+ `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.`
328
+ );
329
+ }
330
+ const tid = opts.tenantId ?? getTenantId();
331
+ if (tid == null) {
332
+ throw new Error(
333
+ "signTransformUrl: no tenant is configured. Call setTenantId(id) (or construct a NitidaClient with `tenantId`) \u2014 the tenant is part of what the signature covers, so this cannot be guessed."
334
+ );
335
+ }
304
336
  const u = new URL(unsignedUrl);
305
337
  const parts = u.pathname.split("/").filter(Boolean);
306
338
  if (parts[0] !== "t" || parts.length < 3) {
@@ -308,8 +340,19 @@ async function signTransformUrl(unsignedUrl, signingKey) {
308
340
  }
309
341
  const filename = parts[parts.length - 1];
310
342
  const dsl = parts.slice(1, -1).join("/");
311
- const message = `${dsl}/${filename}`;
312
- const sig = await hmacSha256Hex(signingKey, message);
343
+ const now = opts.nowSeconds ?? Math.floor(Date.now() / 1e3);
344
+ const exp = Math.floor((now + ttl) / 60) * 60;
345
+ const sig = await hmacSha256Hex(
346
+ signingKey,
347
+ transformMessage({
348
+ tenantPrefix: tid.toString(36),
349
+ exp,
350
+ canonicalDsl: dsl,
351
+ filename
352
+ })
353
+ );
354
+ u.searchParams.set("kid", await deriveTransformKid(signingKey));
355
+ u.searchParams.set("exp", String(exp));
313
356
  u.searchParams.set("sig", sig);
314
357
  return u.toString();
315
358
  }
@@ -436,16 +479,16 @@ function materializeResolution(dto, overridePreset) {
436
479
 
437
480
  // src/index.ts
438
481
  var REQUESTABLE_PRESETS = [
439
- "thumb",
440
- "sm",
441
- "md",
482
+ "aiproxy",
442
483
  "lg",
443
- "xl",
484
+ "md",
444
485
  "original",
445
486
  "poster",
487
+ "probe",
488
+ "sm",
489
+ "thumb",
446
490
  "video",
447
- "aiproxy",
448
- "probe"
491
+ "xl"
449
492
  ];
450
493
  var isRequestablePreset = (v) => REQUESTABLE_PRESETS.includes(v);
451
494
  var toRequestablePresets = (input) => {
@@ -615,6 +658,11 @@ function buildPublicAssetUrl(asset, preset, caller) {
615
658
  if (preset === "hls") {
616
659
  const stored = asset.variants?.find((v) => v.preset === "hls")?.url;
617
660
  if (stored) return stored;
661
+ if (asset.kind != null && asset.kind !== "video") {
662
+ throw new Error(
663
+ `${caller}: this asset is \`${asset.kind}\`, and only a video has an HLS ladder. Asking for \`hls\` here would build \`/t/format=hls/<sha>.m3u8\`, which does NOT 404 \u2014 the edge answers 200 with the image bytes under an .m3u8 name, so a player fails with no way to see why. For an image use a size preset (\`${caller}(asset, "md")\`) or a transform (\`getTransformUrl\`); for audio use \`mp3\`.`
664
+ );
665
+ }
618
666
  return `${cdnBaseUrl}/t/format=hls/${asset.sha}.m3u8`;
619
667
  }
620
668
  return `${cdnBaseUrl}/${variantPrefix(caller)}${asset.sha}-${PRESET_SHORT[preset]}.${PRESET_EXT[preset]}`;
@@ -678,6 +726,9 @@ function getAssetDimensions(asset) {
678
726
  return null;
679
727
  }
680
728
  export {
729
+ MAX_SIGNED_TRANSFORM_TTL_SECONDS,
730
+ MAX_SIGNED_URL_TTL_SECONDS,
731
+ MIN_SIGNED_TRANSFORM_TTL_SECONDS,
681
732
  PRESET_EXT,
682
733
  PRESET_LONG,
683
734
  PRESET_MAX_DIM,
@@ -692,6 +743,7 @@ export {
692
743
  configureSlotResolver,
693
744
  contrastRatio,
694
745
  deriveAccessKey,
746
+ deriveTransformKid,
695
747
  extractAssetSha,
696
748
  getAmbientGradient,
697
749
  getAssetDimensions,
@@ -724,6 +776,7 @@ export {
724
776
  setTenantId,
725
777
  signAccessUrl,
726
778
  signTransformUrl,
727
- toRequestablePresets
779
+ toRequestablePresets,
780
+ transformMessage
728
781
  };
729
782
  //# sourceMappingURL=index.js.map