@nitida/sdk 0.27.0 → 0.28.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
@@ -94,11 +94,29 @@ await getPrivateTransformUrl(asset, { width: 1280 }, signingKey, { expiresInSeco
94
94
  - **Seven URL builders throw** rather than hand you a doomed URL — `getAssetUrl`,
95
95
  `getAssetSrcSet`, `getTransformUrl`, `getTransformSrcSet`,
96
96
  `getVideoTransformUrl`, `getHlsStreamingUrl`, `getSignedTransformUrl` — but
97
- only when the value you pass carries `visibility`. `{ sha }` alone is never
98
- refused.
97
+ only when the value you pass says `visibility: "private"`. A DTO that says
98
+ `"public"`, or a bare `{ sha }`, is never refused — and this is a DIFFERENT
99
+ rule from the missing-preset fallback below, which is about presets, not
100
+ privacy.
99
101
  - **`exp` is mandatory**; revocation is *"within a minute"* (60 s TTL at the edge).
100
102
  - **The signing key is a backend secret** — it mints URLs for every private
101
103
  asset the tenant owns.
104
+ - **⭐ Where the signing key comes from: the response that CREATED your
105
+ project, once.** `POST /admin/projects` returns `signingKey` next to the
106
+ three API keys, and the console shows it in the same panel. Nothing else
107
+ hands it out — `GET /admin/projects/:code` does **not** include it. If it is
108
+ lost, the only endpoint that returns a key is
109
+ `POST /admin/projects/:code/rotate-signing-key`, which **invalidates every
110
+ URL already signed** and needs the system-scope key the platform operator
111
+ holds. A tenant with nothing signed yet can rotate for free; a live one
112
+ cannot, which is why you save it at creation.
113
+ - **⭐ Already have a project and never saw a signing key?** Then you never got
114
+ one — projects created before 2026-08-23 were not handed it, and no endpoint
115
+ shows you the current one. **If you have not signed any URLs yet, ask us to
116
+ rotate: with nothing in flight, rotation invalidates nothing and is free.**
117
+ If you already have signed URLs circulating, ask us for the current key
118
+ instead. This is the wall the T5 agent hit, and the sentence that was
119
+ missing.
102
120
 
103
121
  ## Two ways an image gets smaller, and only one of them is yours to call
104
122
 
@@ -118,18 +136,25 @@ So for an API/agent upload there is nothing to "turn on": you were never going
118
136
  to compress the raw, and delivery is already optimised two ways.
119
137
 
120
138
  ```ts
121
- // Ask for the rungs you will actually render…
122
- await aq.upload(file, { presets: ["original", "thumb", "md", "lg"] });
139
+ await aq.upload(file);
140
+ aq.transform(asset, { width: 1280, format: "webp" }); // → /t/…, generated once, cached after
123
141
 
124
- // …or upload bare and let the transform route make them on demand:
125
- aq.transform(asset, { width: 1280, format: "webp" }); // → /t/…, cached after the first hit
142
+ // Only for a rung you KNOW will be rendered over and over:
143
+ await aq.upload(file, { presets: ["original", "thumb"] });
126
144
  ```
127
145
 
128
146
  **`presets` defaults to `["original"]`** — a bare upload stores the raw and no
129
147
  rendition. Since 2026-08-22 that is no longer a trap: when the DTO says a preset
130
148
  was never materialised, `getAssetUrl` / `urlFor` **fall back to `/t/`** instead
131
149
  of returning a URL that 404s. You still get optimised bytes; you just pay the
132
- first encode. Prefer naming the presets when you know what you will render.
150
+ first encode.
151
+
152
+ **On demand is the default, and it is the right one.** A variant exists because
153
+ somebody asked for it; we do not manufacture sizes on the chance that they might
154
+ be wanted. Name presets at upload only for a rung you *know* will be rendered
155
+ over and over — a card thumbnail on every listing page — where paying the encode
156
+ once up front beats paying it once lazily. For everything else, upload bare and
157
+ let `/t/` do it.
133
158
 
134
159
  ⚠️ `getAssetSrcSet` deliberately does **not** fall back — a srcSet promises
135
160
  pixel widths and a transform cannot keep that promise on a source smaller than
package/README.md CHANGED
@@ -259,8 +259,11 @@ const heroes = await aq.slots.resolveMany([
259
259
  "storefront.home.tile-2",
260
260
  ]);
261
261
 
262
- // Lower-level operations.
262
+ // Lower-level operations. `byHash` takes the full 64-hex sha256 that
263
+ // `upload()` returns as `.sha256`, OR its 16-char prefix — the short form
264
+ // that appears inside every CDN URL. Both resolve to the same asset.
263
265
  const asset = await aq.assets.byHash("3c…<64 hex>…");
266
+ const same = await aq.assets.byHash("3c8f1a20b7d94e05"); // 16-char prefix ✓
264
267
  const { assets, nextCursor } = await aq.assets.list({ limit: 50 });
265
268
 
266
269
  // Uploads — hash-deduped; returns the canonical v2 URL immediately.
@@ -894,6 +897,29 @@ as they specify the same params.
894
897
 
895
898
  Every tenant has an HMAC-SHA256 signing key — 32 random bytes, generated
896
899
  on tenant creation.
900
+
901
+ > **Where you get it: the response that created your project, once.**
902
+ > `POST /admin/projects` returns `signingKey` next to the three API keys, and
903
+ > the console shows it in the same panel. Save it with the keys — nothing else
904
+ > hands it out. In particular `GET /admin/projects/:code` does **not** return
905
+ > it, and neither does any tenant-scoped endpoint.
906
+ >
907
+ > **Your project already exists and you never saw a signing key?** Then you
908
+ > never got one: projects created before 2026-08-23 were not handed it, and
909
+ > there is no endpoint that shows you the current one. This is the exact wall
910
+ > to hit, so here is the way through it:
911
+ >
912
+ > - **If you have not signed any URLs yet** — which is true of every project
913
+ > that has not shipped private assets — ask us to rotate. Rotation returns a
914
+ > key, and with nothing in flight it invalidates nothing. It is free.
915
+ > - **If you already have signed URLs in circulation**, rotation kills them.
916
+ > Ask us for the current key instead; we can read it.
917
+ >
918
+ > Either way it is one request to us, because
919
+ > `POST /admin/projects/:code/rotate-signing-key` needs the system-scope key
920
+ > the platform operator holds — your own admin key answers
921
+ > `403 SYSTEM_KEY_REQUIRED`.
922
+
897
923
  Optionally enable `strict_transforms = true` to reject unsigned URLs
898
924
  with a 401 — useful when transform URLs leak from a private surface
899
925
  (internal admin, b2b portal) and you don't want third parties
@@ -905,7 +931,8 @@ const aq = new NitidaClient({
905
931
  apiKey: process.env.AQUIENPZ_API_KEY!,
906
932
  tenantCode: "your-tenant",
907
933
  tenantId: 42,
908
- // Fetch via GET /admin/projects/your-tenant; do NOT ship to the browser.
934
+ // Handed to you once, by the response that created the project.
935
+ // do NOT ship to the browser.
909
936
  signingKey: process.env.AQUIENPZ_SIGNING_KEY!,
910
937
  });
911
938
 
@@ -1304,7 +1331,7 @@ API key tiers:
1304
1331
  | `aq.slots` | `bind(key, {assetId, preset})` | Admin rebind |
1305
1332
  | `aq.slots` | `unbind(key)` | Remove binding |
1306
1333
  | `aq.slots` | `invalidateCache(key?)` | After admin rebind |
1307
- | `aq.assets` | `byHash(sha)` / `byHashes([])` | Lookup |
1334
+ | `aq.assets` | `byHash(sha)` / `byHashes([])` | Lookup — full 64-hex sha **or** its 16-char prefix |
1308
1335
  | `aq.assets` | `list({limit, cursor})` | Paginated |
1309
1336
  | `aq.assets` | `get(id)` | Full DTO |
1310
1337
  | `aq.assets` | `patchMetadata(id, {…})` | Merge JSON |
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
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, RequestablePreset, ResolveSlotOptions, SignAccessOptions, SignedTransformOptions, SlotDTO, SlotResolution, TRANSFORM_WIDTHS, TransformEffect, TransformFit, TransformFormat, TransformGravity, TransformOptions, TransformWidth, VariantEntryPreset, VariantPreset, VisibilityHint, accessMessage, assertPublic, 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, iteratePaletteSwatches, pickAmbientBackground, relativeLuminance, resolveSlot, resolveSlots, serializeTransform, setCdnBase, setTenantId, signAccessUrl, signTransformUrl } 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';
3
3
 
4
4
  /**
5
5
  * Which audio uploads already play everywhere — the single source of truth for
@@ -119,12 +119,25 @@ type NitidaClientOptions = {
119
119
  * Tenant's HMAC signing key for transform URLs (Phase 3). Required
120
120
  * only when calling `aq.transform(asset, opts, { sign: true })`.
121
121
  *
122
- * 32 random bytes, generated server-side on tenant creation; fetch
123
- * via `POST /admin/projects/:code/rotate-signing-key`, which needs a
124
- * SYSTEM-scope credential the platform operator holds your own admin key
125
- * answers `403 SYSTEM_KEY_REQUIRED`. Ask for it. **Keep it
126
- * server-side only** do not ship in `NEXT_PUBLIC_*` env vars. Sign
127
- * URLs from a BFF route handler, or pre-sign at build time.
122
+ * 32 random bytes, generated server-side on tenant creation.
123
+ *
124
+ * **Where you get it: the response to your project's creation, once.**
125
+ * `POST /admin/projects` returns `signingKey` next to the three API keys,
126
+ * and the console shows it in the same panel. It is shown there and nowhere
127
+ * else save it with the keys.
128
+ *
129
+ * **Never saw one?** Projects created before 2026-08-23 were not handed it,
130
+ * and no endpoint shows you the current one. If you have not signed any URLs
131
+ * yet — true of every project that has not shipped private assets — ask for
132
+ * a rotation: with nothing in flight it invalidates nothing and is free. If
133
+ * you already have signed URLs circulating, ask for the current key instead.
134
+ *
135
+ * Either way it is one request to the platform operator:
136
+ * `POST /admin/projects/:code/rotate-signing-key` needs a SYSTEM-scope
137
+ * credential, and your own admin key answers `403 SYSTEM_KEY_REQUIRED`.
138
+ *
139
+ * **Keep it server-side only** — do not ship in `NEXT_PUBLIC_*` env vars.
140
+ * Sign URLs from a BFF route handler, or pre-sign at build time.
128
141
  */
129
142
  signingKey?: string;
130
143
  };
@@ -319,7 +332,12 @@ type ComposeMarketingResult = {
319
332
  declare class AssetsApi {
320
333
  private readonly opts;
321
334
  constructor(opts: NitidaClientOptions);
322
- /** Look up an asset by full sha256 (64 hex). Returns null on 404. */
335
+ /**
336
+ * Look up an asset by sha256. Accepts the full 64-hex digest that
337
+ * `upload()` returns as `sha256`, or the 16-char short prefix that appears
338
+ * in every CDN URL. Returns null on 404; throws with a message naming the
339
+ * expected shape if the string is neither form.
340
+ */
323
341
  byHash(sha256: string): Promise<AssetDTO | null>;
324
342
  /** Bulk lookup by sha256s. */
325
343
  byHashes(hashes: string[]): Promise<{
@@ -587,6 +605,22 @@ type UploadOptions = {
587
605
  type UploadResult = {
588
606
  assetId: string;
589
607
  sha256: string;
608
+ /**
609
+ * The SAME 16-hex prefix an `AssetDTO` carries, so the result of an upload can
610
+ * be handed straight to any URL builder.
611
+ *
612
+ * ⭐ It exists because it did not, and that cost a real 404. A Haiku agent
613
+ * evaluating the SDK on 2026-08-23 did the most natural thing there is —
614
+ * `transform(await upload(file), { width: 1280 })` — and got
615
+ * `https://8ok.uk/t/width=1280/undefined.webp`. The builders read `sha`; this
616
+ * type only had `sha256`. TypeScript caught it; running through `bun`, or in
617
+ * plain JS, nothing did.
618
+ *
619
+ * The guard (`assertSha`) is the backstop. This field is the actual fix: the
620
+ * obvious call is now the correct one, which is worth more than a good error
621
+ * message about the wrong one.
622
+ */
623
+ sha: string;
590
624
  cdnUrl: string;
591
625
  };
592
626
  type SlotHistoryEntry = {
package/dist/index.js CHANGED
@@ -26,6 +26,7 @@ function isUniversallyPlayableAudio(mime) {
26
26
  import {
27
27
  accessMessage,
28
28
  assertPublic,
29
+ assertSha,
29
30
  bestTextContrast,
30
31
  computeVariantDimensions,
31
32
  configureSlotResolver as configureSlotResolver2,
@@ -52,12 +53,14 @@ import {
52
53
  hasPreset as hasPreset2,
53
54
  hlsLadderAlignment,
54
55
  invalidateSlotCache as invalidateSlotCache2,
56
+ isRequestablePreset as isRequestablePreset2,
55
57
  iteratePaletteSwatches,
56
58
  PRESET_EXT,
57
59
  PRESET_LONG,
58
60
  PRESET_MAX_DIM,
59
61
  PRESET_SHORT,
60
62
  pickAmbientBackground,
63
+ REQUESTABLE_PRESETS as REQUESTABLE_PRESETS2,
61
64
  relativeLuminance,
62
65
  resolveSlot as resolveSlot2,
63
66
  resolveSlots as resolveSlots2,
@@ -66,7 +69,8 @@ import {
66
69
  setTenantId as setTenantId2,
67
70
  signAccessUrl,
68
71
  signTransformUrl,
69
- TRANSFORM_WIDTHS
72
+ TRANSFORM_WIDTHS,
73
+ toRequestablePresets as toRequestablePresets2
70
74
  } from "@nitida/asset-client";
71
75
  function endpointUrl(opts, path, searchParams) {
72
76
  const endpoint = opts.endpoint.replace(/\/+$/, "");
@@ -198,7 +202,12 @@ var AssetsApi = class {
198
202
  this.opts = opts;
199
203
  }
200
204
  opts;
201
- /** Look up an asset by full sha256 (64 hex). Returns null on 404. */
205
+ /**
206
+ * Look up an asset by sha256. Accepts the full 64-hex digest that
207
+ * `upload()` returns as `sha256`, or the 16-char short prefix that appears
208
+ * in every CDN URL. Returns null on 404; throws with a message naming the
209
+ * expected shape if the string is neither form.
210
+ */
202
211
  async byHash(sha256) {
203
212
  const r = await fetch(
204
213
  endpointHref(this.opts, `/assets/by-hash/${sha256}`),
@@ -571,7 +580,7 @@ var NitidaClient = class {
571
580
  }
572
581
  if (!this.opts.signingKey) {
573
582
  throw new Error(
574
- "aq.transform({ sign: true }) requires `signingKey` in NitidaClientOptions. No signingKey on this client. The key is minted by POST /admin/projects/:code/rotate-signing-key, which needs a system-scope credential the platform operator holds \u2014 your own admin key gets 403 SYSTEM_KEY_REQUIRED, so ask for it. Then pass it to the SDK constructor on a SERVER-side instance only."
583
+ "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."
575
584
  );
576
585
  }
577
586
  return getSignedTransformUrl(asset, opts, this.opts.signingKey) ?? Promise.resolve(this.urlFor(asset, "lg"));
@@ -580,7 +589,7 @@ var NitidaClient = class {
580
589
  if (!signOpts?.sign) return getTransformSrcSet(asset, widths, extraOpts);
581
590
  if (!this.opts.signingKey) {
582
591
  throw new Error(
583
- "aq.transformSrcSet({ sign: true }) requires `signingKey` in NitidaClientOptions."
592
+ "aq.transformSrcSet({ sign: true }) requires `signingKey` in NitidaClientOptions. It was returned ONCE, by the response that created your project (POST /admin/projects \u2192 `signingKey`). See the `signingKey` docs on NitidaClientOptions."
584
593
  );
585
594
  }
586
595
  const key = this.opts.signingKey;
@@ -752,6 +761,7 @@ var NitidaClient = class {
752
761
  return {
753
762
  assetId: existing.id,
754
763
  sha256: sha,
764
+ sha: sha.slice(0, 16),
755
765
  cdnUrl: this.urlFor(existing, this.bestPresetForAsset(existing, mime))
756
766
  };
757
767
  }
@@ -768,6 +778,7 @@ var NitidaClient = class {
768
778
  return {
769
779
  assetId: presign.asset.id,
770
780
  sha256: sha,
781
+ sha: sha.slice(0, 16),
771
782
  cdnUrl: this.urlFor(presign.asset, this.defaultPresetForMime(mime))
772
783
  };
773
784
  }
@@ -810,6 +821,7 @@ var NitidaClient = class {
810
821
  return {
811
822
  assetId,
812
823
  sha256: sha,
824
+ sha: sha.slice(0, 16),
813
825
  cdnUrl: this.urlFor(final, this.bestPresetForAsset(final, mime))
814
826
  };
815
827
  }
@@ -856,9 +868,11 @@ export {
856
868
  PRESET_LONG,
857
869
  PRESET_MAX_DIM,
858
870
  PRESET_SHORT,
871
+ REQUESTABLE_PRESETS2 as REQUESTABLE_PRESETS,
859
872
  TRANSFORM_WIDTHS,
860
873
  accessMessage,
861
874
  assertPublic,
875
+ assertSha,
862
876
  bestTextContrast,
863
877
  computeVariantDimensions,
864
878
  configureSlotResolver2 as configureSlotResolver,
@@ -885,6 +899,7 @@ export {
885
899
  hasPreset2 as hasPreset,
886
900
  hlsLadderAlignment,
887
901
  invalidateSlotCache2 as invalidateSlotCache,
902
+ isRequestablePreset2 as isRequestablePreset,
888
903
  isUniversallyPlayableAudio,
889
904
  iteratePaletteSwatches,
890
905
  mimeFromFileName,
@@ -896,6 +911,7 @@ export {
896
911
  setCdnBase2 as setCdnBase,
897
912
  setTenantId2 as setTenantId,
898
913
  signAccessUrl,
899
- signTransformUrl
914
+ signTransformUrl,
915
+ toRequestablePresets2 as toRequestablePresets
900
916
  };
901
917
  //# sourceMappingURL=index.js.map