@nitida/sdk 0.32.3 → 0.35.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, 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 } from '@nitida/asset-client';
3
3
 
4
4
  /**
5
5
  * Which audio uploads already play everywhere — the single source of truth for
@@ -701,6 +701,22 @@ type UploadResult = {
701
701
  * objeto no lo llevaba. El resultado era una URL pública para un asset
702
702
  * privado, servida sin una queja, que después da 404.
703
703
  */
704
+ /**
705
+ * What the asset IS (`image` · `video` · `audio` · `document` · `other`).
706
+ *
707
+ * ⭐ It exists because it did not, and the guard caught it before anyone
708
+ * else did — the FIFTH member of this family, after `sha`, `mime`, `oext`
709
+ * and `presets`. When `getAssetUrl` learned to refuse `hls` on something
710
+ * that can never have a ladder, it started READING `kind`; an
711
+ * `UploadResult` without it silently opted out of the refusal and went
712
+ * right back to building `/t/format=hls/<sha>.m3u8` — which does not 404,
713
+ * it answers 200 with the image bytes.
714
+ *
715
+ * The rule this keeps re-teaching: a return type that omits what the next
716
+ * call reads is a silent 404 (or worse, a silent 200). The fix is always to
717
+ * carry the field, never to write a better error message about its absence.
718
+ */
719
+ kind: AssetDTO["kind"];
704
720
  visibility: "public" | "private";
705
721
  /**
706
722
  * La cadena compacta de variantes que EXISTEN (`"lmoqs"`, `"o"`, …).
@@ -894,7 +910,7 @@ declare class NitidaClient {
894
910
  transform(asset: Pick<AssetDTO, "sha">, opts?: TransformOptions): string;
895
911
  transform(asset: Pick<AssetDTO, "sha">, opts: SignedTransformOptions, signOpts: {
896
912
  sign: true;
897
- }): Promise<string>;
913
+ } & SignTransformOptions): Promise<string>;
898
914
  /**
899
915
  * Build a responsive `srcSet` string. One transform URL per width; all
900
916
  * other options apply to every URL.
@@ -903,9 +919,9 @@ declare class NitidaClient {
903
919
  * call stays synchronous as before.
904
920
  */
905
921
  transformSrcSet(asset: Pick<AssetDTO, "sha">, widths: number[], extraOpts?: Omit<TransformOptions, "width">): string;
906
- 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: {
907
923
  sign: true;
908
- }): Promise<string>;
924
+ } & SignTransformOptions): Promise<string>;
909
925
  /**
910
926
  * Build an on-the-fly VIDEO transform URL — Phase 4.
911
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,
@@ -55,6 +56,9 @@ import {
55
56
  invalidateSlotCache as invalidateSlotCache2,
56
57
  isRequestablePreset as isRequestablePreset2,
57
58
  iteratePaletteSwatches,
59
+ MAX_SIGNED_TRANSFORM_TTL_SECONDS,
60
+ MAX_SIGNED_URL_TTL_SECONDS,
61
+ MIN_SIGNED_TRANSFORM_TTL_SECONDS,
58
62
  PRESET_EXT,
59
63
  PRESET_LONG,
60
64
  PRESET_MAX_DIM,
@@ -70,7 +74,8 @@ import {
70
74
  signAccessUrl,
71
75
  signTransformUrl,
72
76
  TRANSFORM_WIDTHS,
73
- toRequestablePresets as toRequestablePresets2
77
+ toRequestablePresets as toRequestablePresets2,
78
+ transformMessage
74
79
  } from "@nitida/asset-client";
75
80
  function endpointUrl(opts, path, searchParams) {
76
81
  const endpoint = opts.endpoint.replace(/\/+$/, "");
@@ -508,6 +513,13 @@ function mimeFromFileName(fileName) {
508
513
  return ext ? MIME_BY_EXT[ext] ?? null : null;
509
514
  }
510
515
  var DEFAULT_UPLOAD_PRESETS = ["original"];
516
+ function kindForMime(mime) {
517
+ const m = (mime ?? "").toLowerCase();
518
+ if (m.startsWith("video/")) return "video";
519
+ if (m.startsWith("audio/")) return "audio";
520
+ if (m.startsWith("image/")) return "image";
521
+ return "other";
522
+ }
511
523
  async function computeSha256(bytes) {
512
524
  const buf = bytes instanceof Blob ? await bytes.arrayBuffer() : bytes instanceof Uint8Array ? bytes.buffer : bytes;
513
525
  const digest = await crypto.subtle.digest("SHA-256", buf);
@@ -593,7 +605,11 @@ var NitidaClient = class {
593
605
  "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."
594
606
  );
595
607
  }
596
- return getSignedTransformUrl(asset, opts, this.opts.signingKey) ?? Promise.resolve(this.urlFor(asset, "lg"));
608
+ return getSignedTransformUrl(asset, opts, this.opts.signingKey, {
609
+ expiresInSeconds: signOpts.expiresInSeconds,
610
+ tenantId: signOpts.tenantId ?? this.opts.tenantId,
611
+ nowSeconds: signOpts.nowSeconds
612
+ }) ?? Promise.resolve(this.urlFor(asset, "lg"));
597
613
  }
598
614
  transformSrcSet(asset, widths, extraOpts = {}, signOpts) {
599
615
  if (!signOpts?.sign) return getTransformSrcSet(asset, widths, extraOpts);
@@ -603,12 +619,18 @@ var NitidaClient = class {
603
619
  );
604
620
  }
605
621
  const key = this.opts.signingKey;
622
+ const sign = {
623
+ expiresInSeconds: signOpts.expiresInSeconds,
624
+ tenantId: signOpts.tenantId ?? this.opts.tenantId,
625
+ nowSeconds: signOpts.nowSeconds
626
+ };
606
627
  return Promise.all(
607
628
  widths.map(async (w) => {
608
629
  const signed = await getSignedTransformUrl(
609
630
  asset,
610
631
  { ...extraOpts, width: w },
611
- key
632
+ key,
633
+ sign
612
634
  );
613
635
  return signed ? `${signed} ${w}w` : null;
614
636
  })
@@ -777,6 +799,7 @@ var NitidaClient = class {
777
799
  // Same shape as the presign branch below, for the same reason. This
778
800
  // DTO does carry `sha` today — but relying on that is how the other
779
801
  // branch broke, and the value we hashed ourselves is authoritative.
802
+ kind: existing.kind ?? kindForMime(mime),
780
803
  visibility: existing.visibility ?? "public",
781
804
  presets: existing.presets ?? "",
782
805
  variants: existing.variants ?? [],
@@ -837,6 +860,7 @@ var NitidaClient = class {
837
860
  // ACTUALLY has, now that we waited for it — not from
838
861
  // `defaultPresetForMime`, which is a guess made before anything exists.
839
862
  // Guessing was safe only while this branch never ran.
863
+ kind: settled.kind ?? kindForMime(mime),
840
864
  visibility: settled.visibility ?? "public",
841
865
  presets: settled.presets ?? "",
842
866
  variants: settled.variants ?? [],
@@ -888,6 +912,7 @@ var NitidaClient = class {
888
912
  sha: sha.slice(0, 16),
889
913
  mime: final.mime ?? mime,
890
914
  oext: final.oext ?? null,
915
+ kind: final.kind ?? kindForMime(final.mime ?? mime),
891
916
  visibility: final.visibility ?? "public",
892
917
  presets: final.presets ?? "",
893
918
  variants: final.variants ?? [],
@@ -942,6 +967,9 @@ var NitidaClient = class {
942
967
  }
943
968
  };
944
969
  export {
970
+ MAX_SIGNED_TRANSFORM_TTL_SECONDS,
971
+ MAX_SIGNED_URL_TTL_SECONDS,
972
+ MIN_SIGNED_TRANSFORM_TTL_SECONDS,
945
973
  NitidaClient,
946
974
  PRESET_EXT,
947
975
  PRESET_LONG,
@@ -957,6 +985,7 @@ export {
957
985
  configureSlotResolver2 as configureSlotResolver,
958
986
  contrastRatio,
959
987
  deriveAccessKey,
988
+ deriveTransformKid,
960
989
  extractAssetSha,
961
990
  getAmbientGradient,
962
991
  getAssetDimensions,
@@ -991,6 +1020,7 @@ export {
991
1020
  setTenantId2 as setTenantId,
992
1021
  signAccessUrl,
993
1022
  signTransformUrl,
994
- toRequestablePresets2 as toRequestablePresets
1023
+ toRequestablePresets2 as toRequestablePresets,
1024
+ transformMessage
995
1025
  };
996
1026
  //# sourceMappingURL=index.js.map