@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/AGENTS.md CHANGED
@@ -47,6 +47,13 @@ schemas, not against a hand-kept list.
47
47
  *guesses* — the server keys the original off the **uploaded filename**, which the mime does not
48
48
  determine — and with neither it emits the `-o.bin` sentinel and 404s forever.
49
49
 
50
+ The ladder itself — **generated from `TRANSFORM_WIDTHS`, do not edit by hand.**
51
+ `bun run gen:docs` rewrites it; `--check` fails the build if it drifts:
52
+
53
+ <!-- BEGIN GENERATED: transform-widths · bun run gen:docs -->
54
+ `96, 128, 160, 180, 240, 256, 320, 400, 480, 600, 640, 800, 960, 1080, 1200, 1280, 1440, 1600, 1920, 2560, 3840` — 21 widths.
55
+ <!-- END GENERATED: transform-widths -->
56
+
50
57
  ## Existence checks
51
58
 
52
59
  Use `hasPreset(asset, preset)` against `dto.presets` (the compact code string). It is on every
package/README.md CHANGED
@@ -43,10 +43,15 @@ getTransformUrl({ sha }, { format: "webp", width: 640 });
43
43
  getTransformSrcSet({ sha }, [640, 960, 1280], { format: "webp" });
44
44
  ```
45
45
 
46
- ⚠️ **Widths must be on the unsigned ladder** `TRANSFORM_WIDTHS`
47
- (`96,128,160,240,256,320,400,480,600,640,800,960,1080,1200,1280,1440,1600,1920,2560,3840` 20 widths). Anything else
48
- is **HTTP 400** at the edge — it is a DoS guard, not a bug. Import the `TransformWidth` type and an
49
- off-ladder number becomes a compile error instead of a runtime 400.
46
+ ⚠️ **Widths must be on the unsigned ladder** `TRANSFORM_WIDTHS` — generated from the source, do not
47
+ edit by hand:
48
+
49
+ <!-- BEGIN GENERATED: transform-widths · bun run gen:docs -->
50
+ `96, 128, 160, 180, 240, 256, 320, 400, 480, 600, 640, 800, 960, 1080, 1200, 1280, 1440, 1600, 1920, 2560, 3840` — 21 widths.
51
+ <!-- END GENERATED: transform-widths -->
52
+
53
+ Anything else is **HTTP 400** at the edge — it is a DoS guard, not a bug. Import the `TransformWidth`
54
+ type and an off-ladder number becomes a compile error instead of a runtime 400.
50
55
 
51
56
  ## Video — the stored variant, not a transform
52
57
 
package/dist/index.cjs CHANGED
@@ -20,6 +20,9 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
+ MAX_SIGNED_TRANSFORM_TTL_SECONDS: () => MAX_SIGNED_TRANSFORM_TTL_SECONDS,
24
+ MAX_SIGNED_URL_TTL_SECONDS: () => MAX_SIGNED_URL_TTL_SECONDS,
25
+ MIN_SIGNED_TRANSFORM_TTL_SECONDS: () => MIN_SIGNED_TRANSFORM_TTL_SECONDS,
23
26
  PRESET_EXT: () => PRESET_EXT,
24
27
  PRESET_LONG: () => PRESET_LONG,
25
28
  PRESET_MAX_DIM: () => PRESET_MAX_DIM,
@@ -34,11 +37,13 @@ __export(index_exports, {
34
37
  configureSlotResolver: () => configureSlotResolver,
35
38
  contrastRatio: () => contrastRatio,
36
39
  deriveAccessKey: () => deriveAccessKey,
40
+ deriveTransformKid: () => deriveTransformKid,
37
41
  extractAssetSha: () => extractAssetSha,
38
42
  getAmbientGradient: () => getAmbientGradient,
39
43
  getAssetDimensions: () => getAssetDimensions,
40
44
  getAssetSrcSet: () => getAssetSrcSet,
41
45
  getAssetUrl: () => getAssetUrl,
46
+ getByteBudgetTransformUrl: () => getByteBudgetTransformUrl,
42
47
  getCdnBase: () => getCdnBase,
43
48
  getHlsLadder: () => getHlsLadder,
44
49
  getHlsStreamingUrl: () => getHlsStreamingUrl,
@@ -53,6 +58,7 @@ __export(index_exports, {
53
58
  getTransformUrl: () => getTransformUrl,
54
59
  getVideoTransformUrl: () => getVideoTransformUrl,
55
60
  hasPreset: () => hasPreset,
61
+ hasSizeLadder: () => hasSizeLadder,
56
62
  hlsLadderAlignment: () => hlsLadderAlignment,
57
63
  invalidateSlotCache: () => invalidateSlotCache,
58
64
  isRequestablePreset: () => isRequestablePreset,
@@ -66,7 +72,8 @@ __export(index_exports, {
66
72
  setTenantId: () => setTenantId,
67
73
  signAccessUrl: () => signAccessUrl,
68
74
  signTransformUrl: () => signTransformUrl,
69
- toRequestablePresets: () => toRequestablePresets
75
+ toRequestablePresets: () => toRequestablePresets,
76
+ transformMessage: () => transformMessage
70
77
  });
71
78
  module.exports = __toCommonJS(index_exports);
72
79
 
@@ -97,12 +104,18 @@ function accessMessage(tenantPrefix, exp, resourcePath) {
97
104
  ${exp}
98
105
  ${resourcePath.replace(/^\/+/, "")}`;
99
106
  }
107
+ var MAX_SIGNED_URL_TTL_SECONDS = 7 * 24 * 60 * 60;
100
108
  async function signAccessUrl(publicUrl, signingKey, opts) {
101
109
  if (!Number.isFinite(opts.expiresInSeconds) || opts.expiresInSeconds <= 0) {
102
110
  throw new Error(
103
111
  "signAccessUrl: `expiresInSeconds` must be a positive number \u2014 a signed URL without an expiry is a public URL the moment it is forwarded."
104
112
  );
105
113
  }
114
+ if (opts.expiresInSeconds > MAX_SIGNED_URL_TTL_SECONDS) {
115
+ throw new Error(
116
+ `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.`
117
+ );
118
+ }
106
119
  const u = new URL(publicUrl);
107
120
  const segments = u.pathname.split("/").filter(Boolean);
108
121
  if (segments[0] === "a" && segments[2] && /^[vrt]$/.test(segments[2])) {
@@ -258,6 +271,7 @@ var TRANSFORM_WIDTHS = [
258
271
  96,
259
272
  128,
260
273
  160,
274
+ 180,
261
275
  240,
262
276
  256,
263
277
  320,
@@ -361,7 +375,38 @@ function getTransformUrl(asset, opts) {
361
375
  );
362
376
  return buildTransformUrl(asset, opts);
363
377
  }
364
- function getSignedTransformUrl(asset, opts, signingKey) {
378
+ var LADDER_PRESETS = ["thumb", "sm", "md", "lg", "xl"];
379
+ function hasSizeLadder(asset) {
380
+ const raw = asset.presets;
381
+ if (raw == null || raw.trim() === "") return null;
382
+ return LADDER_PRESETS.some((preset) => hasPreset(asset, preset));
383
+ }
384
+ function getByteBudgetTransformUrl(asset, opts) {
385
+ assertSha(asset, "getByteBudgetTransformUrl");
386
+ assertPublic(
387
+ asset,
388
+ "getByteBudgetTransformUrl",
389
+ "getPrivateTransformUrl(asset, opts, signingKey, { expiresInSeconds: 300 })"
390
+ );
391
+ if (!Number.isInteger(opts.quality) || opts.quality < 1 || opts.quality > 100) {
392
+ throw new Error(
393
+ `getByteBudgetTransformUrl: quality must be an integer 1..100, got ${String(opts.quality)}. If you do not have a measured byte budget, use getTransformUrl and omit quality entirely.`
394
+ );
395
+ }
396
+ const ladder = hasSizeLadder(asset);
397
+ if (ladder === null) {
398
+ throw new Error(
399
+ `getByteBudgetTransformUrl: asset ${asset.sha} carries no 'presets', so whether a pinned quality would re-compress it is UNKNOWN \u2014 and unknown is not permission. Fetch the full DTO (assets.get / assets.byHash) and pass it, or use getTransformUrl with no quality.`
400
+ );
401
+ }
402
+ if (ladder) {
403
+ throw new Error(
404
+ `getByteBudgetTransformUrl: asset ${asset.sha} has stored size variants (presets="${asset.presets}"). A pinned quality there does not set the encoder \u2014 it makes /t/ decode one of those already-compressed variants, so the output is a SECOND lossy generation: measured +2..+8% HEAVIER and -0.50..-0.85 dB. Use getTransformUrl with no quality (one pass from the master), or upload this asset with presets: ["original"] if the byte budget is real.`
405
+ );
406
+ }
407
+ return buildTransformUrl(asset, opts);
408
+ }
409
+ function getSignedTransformUrl(asset, opts, signingKey, signOpts) {
365
410
  assertSha(asset, "getSignedTransformUrl");
366
411
  assertPublic(
367
412
  asset,
@@ -370,9 +415,34 @@ function getSignedTransformUrl(asset, opts, signingKey) {
370
415
  );
371
416
  const url = buildTransformUrl(asset, opts);
372
417
  if (!url) return null;
373
- return signTransformUrl(url, signingKey);
374
- }
375
- async function signTransformUrl(unsignedUrl, signingKey) {
418
+ return signTransformUrl(url, signingKey, signOpts);
419
+ }
420
+ var TRANSFORM_SIG_DOMAIN = "nitida/transform/v2";
421
+ var KID_INFO = "nitida/kid/v1";
422
+ var MAX_SIGNED_TRANSFORM_TTL_SECONDS = 7 * 24 * 60 * 60;
423
+ var MIN_SIGNED_TRANSFORM_TTL_SECONDS = 120;
424
+ async function deriveTransformKid(signingKey) {
425
+ return (await hmacSha256Hex(signingKey, KID_INFO)).slice(0, 8);
426
+ }
427
+ function transformMessage(args) {
428
+ return `${TRANSFORM_SIG_DOMAIN}
429
+ ${args.tenantPrefix}
430
+ ${args.exp}
431
+ ${args.canonicalDsl}/${args.filename}`;
432
+ }
433
+ async function signTransformUrl(unsignedUrl, signingKey, opts) {
434
+ const ttl = Math.floor(opts.expiresInSeconds);
435
+ if (!Number.isFinite(ttl) || ttl < MIN_SIGNED_TRANSFORM_TTL_SECONDS || ttl > MAX_SIGNED_TRANSFORM_TTL_SECONDS) {
436
+ throw new Error(
437
+ `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.`
438
+ );
439
+ }
440
+ const tid = opts.tenantId ?? getTenantId();
441
+ if (tid == null) {
442
+ throw new Error(
443
+ "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."
444
+ );
445
+ }
376
446
  const u = new URL(unsignedUrl);
377
447
  const parts = u.pathname.split("/").filter(Boolean);
378
448
  if (parts[0] !== "t" || parts.length < 3) {
@@ -380,8 +450,19 @@ async function signTransformUrl(unsignedUrl, signingKey) {
380
450
  }
381
451
  const filename = parts[parts.length - 1];
382
452
  const dsl = parts.slice(1, -1).join("/");
383
- const message = `${dsl}/${filename}`;
384
- const sig = await hmacSha256Hex(signingKey, message);
453
+ const now = opts.nowSeconds ?? Math.floor(Date.now() / 1e3);
454
+ const exp = Math.floor((now + ttl) / 60) * 60;
455
+ const sig = await hmacSha256Hex(
456
+ signingKey,
457
+ transformMessage({
458
+ tenantPrefix: tid.toString(36),
459
+ exp,
460
+ canonicalDsl: dsl,
461
+ filename
462
+ })
463
+ );
464
+ u.searchParams.set("kid", await deriveTransformKid(signingKey));
465
+ u.searchParams.set("exp", String(exp));
385
466
  u.searchParams.set("sig", sig);
386
467
  return u.toString();
387
468
  }
@@ -508,16 +589,16 @@ function materializeResolution(dto, overridePreset) {
508
589
 
509
590
  // src/index.ts
510
591
  var REQUESTABLE_PRESETS = [
511
- "thumb",
512
- "sm",
513
- "md",
592
+ "aiproxy",
514
593
  "lg",
515
- "xl",
594
+ "md",
516
595
  "original",
517
596
  "poster",
597
+ "probe",
598
+ "sm",
599
+ "thumb",
518
600
  "video",
519
- "aiproxy",
520
- "probe"
601
+ "xl"
521
602
  ];
522
603
  var isRequestablePreset = (v) => REQUESTABLE_PRESETS.includes(v);
523
604
  var toRequestablePresets = (input) => {
@@ -756,6 +837,9 @@ function getAssetDimensions(asset) {
756
837
  }
757
838
  // Annotate the CommonJS export names for ESM import in node:
758
839
  0 && (module.exports = {
840
+ MAX_SIGNED_TRANSFORM_TTL_SECONDS,
841
+ MAX_SIGNED_URL_TTL_SECONDS,
842
+ MIN_SIGNED_TRANSFORM_TTL_SECONDS,
759
843
  PRESET_EXT,
760
844
  PRESET_LONG,
761
845
  PRESET_MAX_DIM,
@@ -770,11 +854,13 @@ function getAssetDimensions(asset) {
770
854
  configureSlotResolver,
771
855
  contrastRatio,
772
856
  deriveAccessKey,
857
+ deriveTransformKid,
773
858
  extractAssetSha,
774
859
  getAmbientGradient,
775
860
  getAssetDimensions,
776
861
  getAssetSrcSet,
777
862
  getAssetUrl,
863
+ getByteBudgetTransformUrl,
778
864
  getCdnBase,
779
865
  getHlsLadder,
780
866
  getHlsStreamingUrl,
@@ -789,6 +875,7 @@ function getAssetDimensions(asset) {
789
875
  getTransformUrl,
790
876
  getVideoTransformUrl,
791
877
  hasPreset,
878
+ hasSizeLadder,
792
879
  hlsLadderAlignment,
793
880
  invalidateSlotCache,
794
881
  isRequestablePreset,
@@ -802,6 +889,7 @@ function getAssetDimensions(asset) {
802
889
  setTenantId,
803
890
  signAccessUrl,
804
891
  signTransformUrl,
805
- toRequestablePresets
892
+ toRequestablePresets,
893
+ transformMessage
806
894
  });
807
895
  //# sourceMappingURL=index.cjs.map