@nitida/sdk 0.33.0 → 0.36.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
@@ -118,6 +118,32 @@ await getPrivateTransformUrl(asset, { width: 1280 }, signingKey, { expiresInSeco
118
118
  instead. This is the wall the T5 agent hit, and the sentence that was
119
119
  missing.
120
120
 
121
+ ## ⚠️ EXIF orientation — two dimension pairs, and mixing them stretches the photo
122
+
123
+ A phone taking a portrait photo does not store portrait pixels: it writes the
124
+ sensor buffer landscape and tags it `Orientation` 5–8. Every such file has two
125
+ pairs, transposes of each other — `4032×3024` stored, `3024×4032` displayed.
126
+
127
+ Stored pair → only orientation-invariant quantities (area, a megapixel budget).
128
+ Displayed pair → anything geometric: a resize target, an aspect ratio, a layout
129
+ box, a coordinate you denormalise.
130
+
131
+ Mixing them shipped twice: `@nitida/asset-compressor-web` 0.6.0–0.6.2 stretched
132
+ every affected photo **1.78×** (fixed in **0.6.3**), and `asset-manager` recorded
133
+ the stored pair as the asset's `w`/`h` until 2026-08-28. Both needed orientation
134
+ 5–8 plus a long side above the box, and **HEIC was immune** — which is why it
135
+ looked random. The damage is pre-upload, so affected photos must be re-uploaded,
136
+ not repaired.
137
+
138
+ Two traps worth naming:
139
+
140
+ - **`createImageBitmap` with BOTH resize axes does not preserve the ratio.** Pass
141
+ one axis; the spec derives the other and the proportions survive by
142
+ construction.
143
+ - **`sharp(x).rotate().metadata()` does NOT apply the rotation** — `.rotate()`
144
+ queues an operation, `.metadata()` reads the input. Measured on sharp 0.34.5:
145
+ it returns `4032×3024` while `.rotate().toBuffer()` returns `3024×4032`.
146
+
121
147
  ## Two ways an image gets smaller, and only one of them is yours to call
122
148
 
123
149
  This is the question every programmatic caller gets wrong, so it is stated flat:
package/README.md CHANGED
@@ -568,6 +568,28 @@ generated server-side by the asset-manager's variant pipeline. A tenant
568
568
  cannot define custom dimensions through the SDK; they pick which preset a
569
569
  slot defaults to and emit responsive `srcSet` for browser-side resizing.
570
570
 
571
+ ### Preset short codes — generated, do not edit by hand
572
+
573
+ A variant URL carries the **code**, not the preset name (`/v/<sha>-l.webp`,
574
+ not `-lg.webp`). Copy the letter from here — `bun run gen:docs` regenerates
575
+ this table from the source, so it can't have the wrong one.
576
+
577
+ <!-- BEGIN GENERATED: preset-codes · bun run gen:docs -->
578
+ | Preset | Code |
579
+ |---|---|
580
+ | `thumb` | `q` |
581
+ | `sm` | `s` |
582
+ | `md` | `m` |
583
+ | `lg` | `l` |
584
+ | `xl` | `x` |
585
+ | `original` | `o` |
586
+ | `poster` | `p` |
587
+ | `video` | `v` |
588
+ | `aiproxy` | `a` |
589
+ | `hls` | `h` |
590
+ | `mp3` | `mp3` |
591
+ <!-- END GENERATED: preset-codes -->
592
+
571
593
  ### Image presets
572
594
 
573
595
  | Preset | Code | Max-side | Typical use |
@@ -875,10 +897,13 @@ const asset = await aq.assets.byHash(sha256);
875
897
  | `dpr` | `1` / `2` / `3` | `1` |
876
898
 
877
899
  > **`width` is strongly typed.** `TransformOptions.width` is a **`TransformWidth`** — the
878
- > predefined CDN ladder (`96, 128, 160, 240, 256, 320, 400, 480, 600, 640, 800, 960,
879
- > 1080, 1200, 1280, 1440, 1600, 1920, 2560, 3840` — 20 widths, exported as
880
- > `TRANSFORM_WIDTHS`). An off-ladder width is a **compile error**.
881
- >
900
+ > predefined CDN ladder below, exported as `TRANSFORM_WIDTHS`. An off-ladder width is a
901
+ > **compile error**.
902
+
903
+ <!-- BEGIN GENERATED: transform-widths · bun run gen:docs -->
904
+ `96, 128, 160, 180, 240, 256, 320, 400, 480, 600, 640, 800, 960, 1080, 1200, 1280, 1440, 1600, 1920, 2560, 3840` — 21 widths.
905
+ <!-- END GENERATED: transform-widths -->
906
+
882
907
  > ⚠️ **The type is narrower than the edge, on purpose.** The edge whitelists a
883
908
  > longer list (28 today — its own 400 response enumerates them, and `512`, `768`,
884
909
  > `1800`, `2160`, `2400`, `2700`, `2880` and `3600` all serve 200 unsigned). So a
@@ -1001,22 +1026,49 @@ const aq = new NitidaClient({
1001
1026
  });
1002
1027
 
1003
1028
  // Async when { sign: true } is set — overload returns Promise<string>.
1004
- const signed = await aq.transform(asset, { width: 1280 }, { sign: true });
1005
- // https://8ok.uk/t/width=1280/<sha>.webp?sig=<64-hex>
1029
+ // `expiresInSeconds` is REQUIRED: a signature with no expiry is a bearer
1030
+ // token with no expiry. 120 s minimum, 7 days maximum.
1031
+ const signed = await aq.transform(
1032
+ asset,
1033
+ { width: 1280 },
1034
+ { sign: true, expiresInSeconds: 3600 },
1035
+ );
1036
+ // → https://8ok.uk/t/width=1280/<sha>.webp?kid=<8-hex>&exp=<unix>&sig=<64-hex>
1006
1037
 
1007
1038
  // Responsive
1008
1039
  const srcset = await aq.transformSrcSet(
1009
1040
  asset,
1010
1041
  [640, 960, 1280, 1920],
1011
1042
  {},
1012
- { sign: true },
1043
+ { sign: true, expiresInSeconds: 3600 },
1013
1044
  );
1014
1045
  ```
1015
1046
 
1016
- Signature shape: `HMAC-SHA256(signingKey, "<canonical-DSL>/<filename>")`,
1017
- hex-encoded. The server canonicalizes the URL the same way the SDK does
1018
- (sort keys, lowercase strings), so two URLs with the same params in
1019
- different order accept the same signature.
1047
+ Signature shape:
1048
+
1049
+ ```
1050
+ message = "nitida/transform/v2\n<tenantId base36>\n<exp>\n<canonical-DSL>/<filename>"
1051
+ sig = HMAC-SHA256(signingKey, message) // hex
1052
+ kid = HMAC-SHA256(signingKey, "nitida/kid/v1").slice(0, 8)
1053
+ ```
1054
+
1055
+ The server canonicalizes the URL the same way the SDK does (sort keys,
1056
+ lowercase strings), so two URLs with the same params in different order accept
1057
+ the same signature.
1058
+
1059
+ `kid` names the key so a rotation does not kill URLs already in flight: the
1060
+ outgoing key keeps verifying for 14 days
1061
+ (`POST /admin/projects/:code/rotate-signing-key` returns the new key, its `kid`,
1062
+ and when the previous one retires). See
1063
+ [the rotation runbook](https://github.com/espaciofuturoio/aquienpz/blob/main/docs/RUNBOOKS/signing-key-rotation.md).
1064
+
1065
+ > ⚠️ **A signature does NOT widen the ladder.** Off-ladder widths are 400 at
1066
+ > the edge whether or not the URL is signed. Until `@nitida/asset-client`
1067
+ > 0.22.0 a valid-looking `?sig=` skipped the edge whitelist entirely, so a
1068
+ > signature bought an arbitrary width up to 7680 px — one request measured at
1069
+ > 1 850 MB. What a signature buys now is IDENTITY: which tenant asked, proven
1070
+ > with its key, until `exp`. That is what `strict_transforms` and the
1071
+ > `effect=genfill` cost guard require.
1020
1072
 
1021
1073
  ## Private assets — `visibility`
1022
1074
 
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { ResolveSlotOptions, SlotResolution, SlotDTO, VariantPreset, AssetDTO, AssetVariant, RequestablePreset, TransformOptions, SignedTransformOptions } from '@nitida/asset-client';
2
- export { AssetDTO, AssetPalette, AssetVariant, HlsRung, PRESET_EXT, PRESET_LONG, PRESET_MAX_DIM, PRESET_SHORT, PaletteSwatch, REQUESTABLE_PRESETS, RequestablePreset, ResolveSlotOptions, SignAccessOptions, SignedTransformOptions, SlotDTO, SlotResolution, TRANSFORM_WIDTHS, TransformEffect, TransformFit, TransformFormat, TransformGravity, TransformOptions, TransformWidth, VariantEntryPreset, VariantPreset, 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 } from '@nitida/asset-client';
1
+ import { ResolveSlotOptions, SlotResolution, SlotDTO, VariantPreset, AssetDTO, AssetVariant, RequestablePreset, TransformOptions, SignedTransformOptions, SignTransformOptions, TransformWidth } from '@nitida/asset-client';
2
+ export { AssetDTO, AssetPalette, AssetVariant, 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, PaletteSwatch, PrivateTransformOptions, REQUESTABLE_PRESETS, RequestablePreset, ResolveSlotOptions, SignAccessOptions, SignTransformOptions, SignedTransformOptions, SlotDTO, SlotResolution, TRANSFORM_WIDTHS, TransformEffect, TransformFit, TransformFormat, TransformGravity, TransformOptions, TransformWidth, VariantEntryPreset, VariantPreset, VisibilityHint, accessMessage, assertPublic, assertSha, bestTextContrast, computeVariantDimensions, configureSlotResolver, contrastRatio, deriveAccessKey, deriveTransformKid, extractAssetSha, getAmbientGradient, getAssetDimensions, getAssetSrcSet, getAssetUrl, getByteBudgetTransformUrl, getCdnBase, getHlsLadder, getHlsStreamingUrl, getPaletteBlurBackground, getPaletteCssVars, getPrivateAssetUrl, getPrivateTransformUrl, getSignedTransformUrl, getTenantId, getTextColorForBackground, getTransformSrcSet, getTransformUrl, getVideoTransformUrl, hasPreset, hasSizeLadder, hlsLadderAlignment, invalidateSlotCache, isRequestablePreset, iteratePaletteSwatches, pickAmbientBackground, relativeLuminance, resolveSlot, resolveSlots, serializeTransform, setCdnBase, setTenantId, signAccessUrl, signTransformUrl, toRequestablePresets, transformMessage } from '@nitida/asset-client';
3
3
 
4
4
  /**
5
5
  * Which audio uploads already play everywhere — the single source of truth for
@@ -910,7 +910,7 @@ declare class NitidaClient {
910
910
  transform(asset: Pick<AssetDTO, "sha">, opts?: TransformOptions): string;
911
911
  transform(asset: Pick<AssetDTO, "sha">, opts: SignedTransformOptions, signOpts: {
912
912
  sign: true;
913
- }): Promise<string>;
913
+ } & SignTransformOptions): Promise<string>;
914
914
  /**
915
915
  * Build a responsive `srcSet` string. One transform URL per width; all
916
916
  * other options apply to every URL.
@@ -919,9 +919,9 @@ declare class NitidaClient {
919
919
  * call stays synchronous as before.
920
920
  */
921
921
  transformSrcSet(asset: Pick<AssetDTO, "sha">, widths: number[], extraOpts?: Omit<TransformOptions, "width">): string;
922
- transformSrcSet(asset: Pick<AssetDTO, "sha">, widths: number[], extraOpts: Omit<TransformOptions, "width">, signOpts: {
922
+ transformSrcSet(asset: Pick<AssetDTO, "sha">, widths: TransformWidth[], extraOpts: Omit<TransformOptions, "width">, signOpts: {
923
923
  sign: true;
924
- }): Promise<string>;
924
+ } & SignTransformOptions): Promise<string>;
925
925
  /**
926
926
  * Build an on-the-fly VIDEO transform URL — Phase 4.
927
927
  *
package/dist/index.js CHANGED
@@ -32,6 +32,7 @@ import {
32
32
  configureSlotResolver as configureSlotResolver2,
33
33
  contrastRatio,
34
34
  deriveAccessKey,
35
+ deriveTransformKid,
35
36
  extractAssetSha,
36
37
  getAmbientGradient,
37
38
  getAssetDimensions,
@@ -42,6 +43,7 @@ import {
42
43
  getHlsStreamingUrl as getHlsStreamingUrl2,
43
44
  getPaletteBlurBackground,
44
45
  getPaletteCssVars,
46
+ getByteBudgetTransformUrl,
45
47
  getPrivateAssetUrl,
46
48
  getPrivateTransformUrl,
47
49
  getSignedTransformUrl as getSignedTransformUrl2,
@@ -51,10 +53,14 @@ import {
51
53
  getTransformUrl as getTransformUrl2,
52
54
  getVideoTransformUrl as getVideoTransformUrl2,
53
55
  hasPreset as hasPreset2,
56
+ hasSizeLadder,
54
57
  hlsLadderAlignment,
55
58
  invalidateSlotCache as invalidateSlotCache2,
56
59
  isRequestablePreset as isRequestablePreset2,
57
60
  iteratePaletteSwatches,
61
+ MAX_SIGNED_TRANSFORM_TTL_SECONDS,
62
+ MAX_SIGNED_URL_TTL_SECONDS,
63
+ MIN_SIGNED_TRANSFORM_TTL_SECONDS,
58
64
  PRESET_EXT,
59
65
  PRESET_LONG,
60
66
  PRESET_MAX_DIM,
@@ -70,7 +76,8 @@ import {
70
76
  signAccessUrl,
71
77
  signTransformUrl,
72
78
  TRANSFORM_WIDTHS,
73
- toRequestablePresets as toRequestablePresets2
79
+ toRequestablePresets as toRequestablePresets2,
80
+ transformMessage
74
81
  } from "@nitida/asset-client";
75
82
  function endpointUrl(opts, path, searchParams) {
76
83
  const endpoint = opts.endpoint.replace(/\/+$/, "");
@@ -600,7 +607,11 @@ var NitidaClient = class {
600
607
  "aq.transform({ sign: true }) requires `signingKey` in NitidaClientOptions. No signingKey on this client. It is returned ONCE, in the response that creates your project (POST /admin/projects \u2192 `signingKey`, next to the three API keys; the console shows it in the same panel). If you never saw one \u2014 projects created before 2026-08-23 were not handed it \u2014 ask the platform operator to rotate: with no signed URLs in flight that invalidates nothing and is free. If you DO have signed URLs circulating, ask for the current key instead, because rotating would kill them. Then pass it to the SDK constructor on a SERVER-side instance only."
601
608
  );
602
609
  }
603
- return getSignedTransformUrl(asset, opts, this.opts.signingKey) ?? Promise.resolve(this.urlFor(asset, "lg"));
610
+ return getSignedTransformUrl(asset, opts, this.opts.signingKey, {
611
+ expiresInSeconds: signOpts.expiresInSeconds,
612
+ tenantId: signOpts.tenantId ?? this.opts.tenantId,
613
+ nowSeconds: signOpts.nowSeconds
614
+ }) ?? Promise.resolve(this.urlFor(asset, "lg"));
604
615
  }
605
616
  transformSrcSet(asset, widths, extraOpts = {}, signOpts) {
606
617
  if (!signOpts?.sign) return getTransformSrcSet(asset, widths, extraOpts);
@@ -610,12 +621,18 @@ var NitidaClient = class {
610
621
  );
611
622
  }
612
623
  const key = this.opts.signingKey;
624
+ const sign = {
625
+ expiresInSeconds: signOpts.expiresInSeconds,
626
+ tenantId: signOpts.tenantId ?? this.opts.tenantId,
627
+ nowSeconds: signOpts.nowSeconds
628
+ };
613
629
  return Promise.all(
614
630
  widths.map(async (w) => {
615
631
  const signed = await getSignedTransformUrl(
616
632
  asset,
617
633
  { ...extraOpts, width: w },
618
- key
634
+ key,
635
+ sign
619
636
  );
620
637
  return signed ? `${signed} ${w}w` : null;
621
638
  })
@@ -952,6 +969,9 @@ var NitidaClient = class {
952
969
  }
953
970
  };
954
971
  export {
972
+ MAX_SIGNED_TRANSFORM_TTL_SECONDS,
973
+ MAX_SIGNED_URL_TTL_SECONDS,
974
+ MIN_SIGNED_TRANSFORM_TTL_SECONDS,
955
975
  NitidaClient,
956
976
  PRESET_EXT,
957
977
  PRESET_LONG,
@@ -967,11 +987,13 @@ export {
967
987
  configureSlotResolver2 as configureSlotResolver,
968
988
  contrastRatio,
969
989
  deriveAccessKey,
990
+ deriveTransformKid,
970
991
  extractAssetSha,
971
992
  getAmbientGradient,
972
993
  getAssetDimensions,
973
994
  getAssetSrcSet2 as getAssetSrcSet,
974
995
  getAssetUrl2 as getAssetUrl,
996
+ getByteBudgetTransformUrl,
975
997
  getCdnBase,
976
998
  getHlsLadder,
977
999
  getHlsStreamingUrl2 as getHlsStreamingUrl,
@@ -986,6 +1008,7 @@ export {
986
1008
  getTransformUrl2 as getTransformUrl,
987
1009
  getVideoTransformUrl2 as getVideoTransformUrl,
988
1010
  hasPreset2 as hasPreset,
1011
+ hasSizeLadder,
989
1012
  hlsLadderAlignment,
990
1013
  invalidateSlotCache2 as invalidateSlotCache,
991
1014
  isRequestablePreset2 as isRequestablePreset,
@@ -1001,6 +1024,7 @@ export {
1001
1024
  setTenantId2 as setTenantId,
1002
1025
  signAccessUrl,
1003
1026
  signTransformUrl,
1004
- toRequestablePresets2 as toRequestablePresets
1027
+ toRequestablePresets2 as toRequestablePresets,
1028
+ transformMessage
1005
1029
  };
1006
1030
  //# sourceMappingURL=index.js.map