@nitida/sdk 0.26.0 → 0.27.1

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
@@ -74,6 +74,77 @@ version of this file, verify against the `.d.ts` you actually installed.
74
74
  ASYNCHRONOUSLY: the call answers `{ assetId, status: "processing" }` and a background job flips
75
75
  it to `ready` 1–2 min later, so `processAndWait` needs a `timeoutMs` of at least `300_000`.
76
76
 
77
+
78
+ ## Private assets — `visibility`
79
+
80
+ Default is `"public"`. Set `private` and **every public door answers 404** —
81
+ stored variants, the raw original, the HLS ladder, and `/t/` (including the
82
+ poster frame of a private video). The bytes come back only through a signed URL
83
+ that expires.
84
+
85
+ ```ts
86
+ import { getPrivateAssetUrl, getPrivateTransformUrl } from "@nitida/sdk";
87
+ // ON YOUR BACKEND, once you decided this viewer may see it:
88
+ await getPrivateAssetUrl(asset, "lg", signingKey, { expiresInSeconds: 300 });
89
+ await getPrivateTransformUrl(asset, { width: 1280 }, signingKey, { expiresInSeconds: 300 });
90
+ ```
91
+
92
+ - **A 404 on a private asset is NOT a missing file.** Check `visibility` on the
93
+ DTO before you check storage. It is the number-one support question.
94
+ - **Seven URL builders throw** rather than hand you a doomed URL — `getAssetUrl`,
95
+ `getAssetSrcSet`, `getTransformUrl`, `getTransformSrcSet`,
96
+ `getVideoTransformUrl`, `getHlsStreamingUrl`, `getSignedTransformUrl` — but
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.
101
+ - **`exp` is mandatory**; revocation is *"within a minute"* (60 s TTL at the edge).
102
+ - **The signing key is a backend secret** — it mints URLs for every private
103
+ asset the tenant owns.
104
+
105
+ ## Two ways an image gets smaller, and only one of them is yours to call
106
+
107
+ This is the question every programmatic caller gets wrong, so it is stated flat:
108
+
109
+ | | who does it | what it shrinks |
110
+ |---|---|---|
111
+ | **Client compressor** (browser / Expo) | the UI, before the PUT | the **upload**: quality 0.85, max 2880 px, WebP. iPhone 9.1 MB → 1.8 MB |
112
+ | **Variants + `/t/`** | the backend, on request | the **delivery**: 19 MB JPEG → 170 kB WebP at 1920 |
113
+
114
+ **The backend never recompresses the raw. Ever.** That is deliberate: the raw
115
+ has to stay pristine so variants are *regenerable* — the day you add AVIF or
116
+ raise the max dimension, the pipeline re-runs against it. A client→server lossy
117
+ chain bakes artifacts in forever.
118
+
119
+ So for an API/agent upload there is nothing to "turn on": you were never going
120
+ to compress the raw, and delivery is already optimised two ways.
121
+
122
+ ```ts
123
+ await aq.upload(file);
124
+ aq.transform(asset, { width: 1280, format: "webp" }); // → /t/…, generated once, cached after
125
+
126
+ // Only for a rung you KNOW will be rendered over and over:
127
+ await aq.upload(file, { presets: ["original", "thumb"] });
128
+ ```
129
+
130
+ **`presets` defaults to `["original"]`** — a bare upload stores the raw and no
131
+ rendition. Since 2026-08-22 that is no longer a trap: when the DTO says a preset
132
+ was never materialised, `getAssetUrl` / `urlFor` **fall back to `/t/`** instead
133
+ of returning a URL that 404s. You still get optimised bytes; you just pay the
134
+ first encode.
135
+
136
+ **On demand is the default, and it is the right one.** A variant exists because
137
+ somebody asked for it; we do not manufacture sizes on the chance that they might
138
+ be wanted. Name presets at upload only for a rung you *know* will be rendered
139
+ over and over — a card thumbnail on every listing page — where paying the encode
140
+ once up front beats paying it once lazily. For everything else, upload bare and
141
+ let `/t/` do it.
142
+
143
+ ⚠️ `getAssetSrcSet` deliberately does **not** fall back — a srcSet promises
144
+ pixel widths and a transform cannot keep that promise on a source smaller than
145
+ the rung, because the pipeline never enlarges. An empty srcSet degrades to `src`; a lying one
146
+ degrades to a wrong choice.
147
+
77
148
  ## Related packages
78
149
 
79
150
  | Package | Job |
package/README.md CHANGED
@@ -832,8 +832,9 @@ const asset = await aq.assets.byHash(sha256);
832
832
  | `dpr` | `1` / `2` / `3` | `1` |
833
833
 
834
834
  > **`width` is strongly typed.** `TransformOptions.width` is a **`TransformWidth`** — the
835
- > predefined CDN ladder (`160, 240, 256, 320, 400, 480, 600, 640, 800, 960, 1080, 1200,
836
- > 1280, 1440, 1600, 1920, 2560, 3840`, exported as `TRANSFORM_WIDTHS`). An off-ladder
835
+ > predefined CDN ladder (`96, 128, 160, 240, 256, 320, 400, 480, 600, 640, 800, 960,
836
+ > 1080, 1200, 1280, 1440, 1600, 1920, 2560, 3840` 20 widths, exported as
837
+ > `TRANSFORM_WIDTHS`). An off-ladder
837
838
  > width is a **compile error**: the edge whitelists exactly these as a DoS guard and
838
839
  > HTTP 400s anything else on unsigned URLs. Need a custom off-ladder width? **Sign it** —
839
840
  > `aq.transform(asset, { width: 1490 }, { sign: true })` and `getSignedTransformUrl` take
@@ -926,6 +927,66 @@ hex-encoded. The server canonicalizes the URL the same way the SDK does
926
927
  (sort keys, lowercase strings), so two URLs with the same params in
927
928
  different order accept the same signature.
928
929
 
930
+ ## Private assets — `visibility`
931
+
932
+ Every asset carries `visibility`, and the default is `"public"`.
933
+
934
+ | | `"public"` | `"private"` |
935
+ |---|---|---|
936
+ | stored variants, raw, HLS ladder, transforms | served to anyone with the URL | **404**, to everyone |
937
+ | how the bytes come back | the URL | a signed URL under `/a/{tenant}/…?exp&sig` |
938
+ | expiry | none | mandatory |
939
+ | edge caching | shared, effectively free | `private`, per-viewer |
940
+
941
+ ```ts
942
+ // Flip it (write scope):
943
+ await fetch(`${endpoint}/assets/${assetId}/visibility`, {
944
+ method: "PATCH",
945
+ headers: { Authorization: `Bearer ${apiKey}`, "X-Tenant-Code": code,
946
+ "Content-Type": "application/json" },
947
+ body: JSON.stringify({ visibility: "private" }),
948
+ });
949
+
950
+ // Hand a viewer the bytes — ON YOUR BACKEND:
951
+ import { getPrivateAssetUrl, getPrivateTransformUrl } from "@nitida/sdk";
952
+
953
+ const url = await getPrivateAssetUrl(asset, "lg", signingKey, {
954
+ expiresInSeconds: 300,
955
+ });
956
+ // → https://8ok.uk/a/5/v/<sha>-l.webp?exp=…&sig=…
957
+
958
+ // …or any width/crop/format, not just the materialised ones:
959
+ const resized = await getPrivateTransformUrl(
960
+ asset,
961
+ { width: 1280, format: "webp" },
962
+ signingKey,
963
+ { expiresInSeconds: 300 },
964
+ );
965
+ // → https://8ok.uk/a/5/t/format=webp,width=1280/<sha>.webp?exp=…&sig=…
966
+ ```
967
+
968
+ Nothing moves when you flip it: one row changes and the edge cache for that
969
+ asset is purged, so a 1 GB video flips as fast as a thumbnail. It is the same
970
+ single copy behind both doors.
971
+
972
+ **Four things worth knowing before you rely on it:**
973
+
974
+ 1. **A 404 on a private asset is the feature, not a missing file.** Everywhere
975
+ else here a 404 means the object was never written. Check `visibility` on
976
+ the DTO before you check storage.
977
+ 2. **The SDK refuses instead of handing you a URL that 404s.** `getAssetUrl`,
978
+ `getAssetSrcSet`, `getTransformUrl`, `getTransformSrcSet`,
979
+ `getVideoTransformUrl` and `getHlsStreamingUrl` all throw when the value you
980
+ pass says `visibility: "private"` — with a message naming
981
+ `getPrivateAssetUrl`. Pass only `{ sha }` and there is nothing to check.
982
+ 3. **Revocation is "within a minute".** Flipping back to `private` really does
983
+ kill URLs already handed out — the edge is purged — but the CDN refreshes
984
+ its list of private assets on a 60-second TTL.
985
+ 4. **The signing key is a backend secret.** It mints URLs for every private
986
+ asset the tenant owns. It is a different claim from the transform `?sig=`
987
+ above, computed under a separately derived key, so a signature minted to
988
+ resize can never be replayed as one to enter.
989
+
929
990
  ### ⚠️ Admin operations need the SYSTEM key, not the admin key in your triplet
930
991
 
931
992
  Both are `amk_ad_*`, and that is the whole trap. The `admin` key issued with
package/dist/index.d.ts CHANGED
@@ -1,5 +1,39 @@
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, SignedTransformOptions, SlotDTO, SlotResolution, TRANSFORM_WIDTHS, TransformEffect, TransformFit, TransformFormat, TransformGravity, TransformOptions, TransformWidth, VariantEntryPreset, VariantPreset, bestTextContrast, computeVariantDimensions, configureSlotResolver, contrastRatio, extractAssetSha, getAmbientGradient, getAssetDimensions, getAssetSrcSet, getAssetUrl, getCdnBase, getHlsLadder, getHlsStreamingUrl, getPaletteBlurBackground, getPaletteCssVars, getSignedTransformUrl, getTenantId, getTextColorForBackground, getTransformSrcSet, getTransformUrl, getVideoTransformUrl, hasPreset, hlsLadderAlignment, invalidateSlotCache, iteratePaletteSwatches, pickAmbientBackground, relativeLuminance, resolveSlot, resolveSlots, serializeTransform, setCdnBase, setTenantId, signTransformUrl } 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, 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, iteratePaletteSwatches, pickAmbientBackground, relativeLuminance, resolveSlot, resolveSlots, serializeTransform, setCdnBase, setTenantId, signAccessUrl, signTransformUrl } from '@nitida/asset-client';
3
+
4
+ /**
5
+ * Which audio uploads already play everywhere — the single source of truth for
6
+ * both the server's transcode guard and the client's delivery-preset choice.
7
+ *
8
+ * It lives in its own module, rather than beside the preset order that uses it,
9
+ * for a mechanical reason: `apps/asset-manager` imports it by relative path.
10
+ * The SDK is a PUBLISHED package, so its `exports` point at `dist/`, and
11
+ * `dist/` is gitignored — it does not exist inside the Docker image, which
12
+ * copies `packages/` as source. A bare `@nitida/sdk` import type-checks on any
13
+ * machine that built the package once and then fails the image build. (It did,
14
+ * on 2026-08-22, with `@nitida/asset-client`.) A single small file keeps that
15
+ * relative import from dragging the whole client into the server.
16
+ *
17
+ * Two copies of this list drifting apart is how a platform ends up generating
18
+ * a variant its own client refuses to use — which is exactly the bug this
19
+ * predicate was introduced to end.
20
+ */
21
+ /**
22
+ * `true` when a browser can play these bytes as uploaded, so re-encoding them
23
+ * to MP3 buys nothing.
24
+ *
25
+ * - `audio/mpeg` — unambiguous.
26
+ * - `audio/mp4` / `audio/aac` — plays in Safari, Chrome, Firefox and Edge on
27
+ * desktop and mobile. The historical worry was old AOSP builds without
28
+ * proprietary codecs; the call to treat AAC as universal was made with the
29
+ * platform's one real AAC consumer, whose viewer is WebGL — a device that
30
+ * cannot decode AAC cannot run that product at all, so the fallback would
31
+ * only ever protect a device that had already lost.
32
+ *
33
+ * Deliberately NOT here: `audio/webm` and `audio/ogg` (Opus). iOS Safari
34
+ * cannot decode them, and that is the case the MP3 fallback exists for.
35
+ */
36
+ declare function isUniversallyPlayableAudio(mime: string): boolean;
3
37
 
4
38
  /**
5
39
  * @nitida/sdk — universal client for the aquienpz multi-tenant asset
@@ -86,7 +120,9 @@ type NitidaClientOptions = {
86
120
  * only when calling `aq.transform(asset, opts, { sign: true })`.
87
121
  *
88
122
  * 32 random bytes, generated server-side on tenant creation; fetch
89
- * via `GET /admin/tenants/:id` with an admin key. **Keep it
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
90
126
  * server-side only** — do not ship in `NEXT_PUBLIC_*` env vars. Sign
91
127
  * URLs from a BFF route handler, or pre-sign at build time.
92
128
  */
@@ -329,9 +365,10 @@ declare class AssetsApi {
329
365
  */
330
366
  variants(assetId: string): Promise<AssetVariant[]>;
331
367
  /**
332
- * Add or rebuild variants on an existing asset. Image presets are
333
- * MERGED with what's there passing `{ presets: ["thumb"] }` adds
334
- * the thumb variant without touching `lg`, `sm`, `original`, etc.
368
+ * Add or rebuild variants on an existing asset. Presets are MERGED
369
+ * with what's there, for images AND for video passing
370
+ * `{ presets: ["thumb"] }` adds the thumb variant without touching
371
+ * `lg`, `sm`, `original`, etc.
335
372
  *
336
373
  * // Day 0: upload original-only logo
337
374
  * const { assetId } = await aq.upload(logoFile); // defaults to ["original"]
@@ -350,10 +387,18 @@ declare class AssetsApi {
350
387
  * reading the source bytes from the permanent `original` variant —
351
388
  * no need to re-upload.
352
389
  *
353
- * Video presets are filtered to `["poster","video","aiproxy"]` and
354
- * dispatched to a background job (the call returns immediately
390
+ * Video presets are filtered to `["poster","video","aiproxy","probe"]`
391
+ * and dispatched to a background job (the call returns immediately
355
392
  * with a dispatch handle; poll `aq.assets.get(id).status` for
356
393
  * completion).
394
+ *
395
+ * ⚠️ Before 2026-08-22 the video path REPLACED the whole variant
396
+ * registry instead of merging, so a partial regenerate silently
397
+ * deregistered `poster`, `video` and — irrecoverably — `hls`, which
398
+ * is not a {@link RequestablePreset} and therefore cannot be asked
399
+ * for again. The objects kept serving from the CDN; only the
400
+ * registry died. Fixed server-side; a client on an older server
401
+ * still loses them.
357
402
  */
358
403
  regenerate(assetId: string, opts?: {
359
404
  presets?: RequestablePreset[];
@@ -542,6 +587,22 @@ type UploadOptions = {
542
587
  type UploadResult = {
543
588
  assetId: string;
544
589
  sha256: string;
590
+ /**
591
+ * The SAME 16-hex prefix an `AssetDTO` carries, so the result of an upload can
592
+ * be handed straight to any URL builder.
593
+ *
594
+ * ⭐ It exists because it did not, and that cost a real 404. A Haiku agent
595
+ * evaluating the SDK on 2026-08-23 did the most natural thing there is —
596
+ * `transform(await upload(file), { width: 1280 })` — and got
597
+ * `https://8ok.uk/t/width=1280/undefined.webp`. The builders read `sha`; this
598
+ * type only had `sha256`. TypeScript caught it; running through `bun`, or in
599
+ * plain JS, nothing did.
600
+ *
601
+ * The guard (`assertSha`) is the backstop. This field is the actual fix: the
602
+ * obvious call is now the correct one, which is worth more than a good error
603
+ * message about the wrong one.
604
+ */
605
+ sha: string;
545
606
  cdnUrl: string;
546
607
  };
547
608
  type SlotHistoryEntry = {
@@ -647,6 +708,7 @@ declare class UsageApi {
647
708
  }>;
648
709
  private headers;
649
710
  }
711
+
650
712
  declare class NitidaClient {
651
713
  readonly slots: SlotsApi;
652
714
  readonly assets: AssetsApi;
@@ -822,11 +884,29 @@ declare class NitidaClient {
822
884
  * `presets` string. Falls back through the preference order
823
885
  * lg → md → sm → thumb → original (for images)
824
886
  * video → poster (for videos)
825
- * mp3original (for audio)
887
+ * originalmp3 (for audio ALREADY playable everywhere)
888
+ * mp3 → original (for any other audio)
826
889
  * so an upload that was processed with e.g. `["original"]` still
827
890
  * returns a non-404 URL in `aq.upload`'s result.
891
+ *
892
+ * ⭐ Why audio branches on the source mime (changed 2026-08-22).
893
+ *
894
+ * It used to be `mp3 → original` unconditionally, so `upload().cdnUrl`
895
+ * handed back the server's auto-generated mp3 — libmp3lame, mono ~96 kbps —
896
+ * even when the caller had uploaded an MP3 or an AAC that already plays in
897
+ * every target browser. A consumer asking for "my file" silently received a
898
+ * re-encoded, lower-quality one, with no error to notice.
899
+ *
900
+ * Measured across the platform: of 126 audio assets carrying an mp3 variant,
901
+ * **105 had an `audio/mpeg` source** — an MP3 re-encoded into an MP3, for
902
+ * zero compatibility gain.
903
+ *
904
+ * The mp3 still wins for `audio/webm`/Opus and anything exotic, which is the
905
+ * case it was built for: Chrome records webm/Opus, which iOS Safari cannot
906
+ * decode. That guarantee is preserved exactly; only the needless downgrade
907
+ * is gone.
828
908
  */
829
909
  private bestPresetForAsset;
830
910
  }
831
911
 
832
- export { type ComposeMarketingComposition, type ComposeMarketingOptions, type ComposeMarketingResult, type ComposeMarketingSegment, type CompressOptions, NitidaClient, type NitidaClientOptions, type PresignUploadUrlOptions, type RegenerateResult, type SlotHistoryEntry, type UploadOptions, type UploadResult, type UploadUrlResult, type UploadVideoOptions, type UsageDailyPoint, type UsagePerKey, type UsageSnapshot, type UsageWindow, mimeFromFileName };
912
+ export { type ComposeMarketingComposition, type ComposeMarketingOptions, type ComposeMarketingResult, type ComposeMarketingSegment, type CompressOptions, NitidaClient, type NitidaClientOptions, type PresignUploadUrlOptions, type RegenerateResult, type SlotHistoryEntry, type UploadOptions, type UploadResult, type UploadUrlResult, type UploadVideoOptions, type UsageDailyPoint, type UsagePerKey, type UsageSnapshot, type UsageWindow, isUniversallyPlayableAudio, mimeFromFileName };
package/dist/index.js CHANGED
@@ -15,11 +15,23 @@ import {
15
15
  setCdnBase,
16
16
  setTenantId
17
17
  } from "@nitida/asset-client";
18
+
19
+ // src/audio-compat.ts
20
+ function isUniversallyPlayableAudio(mime) {
21
+ const base = (mime.split(";")[0] ?? "").trim().toLowerCase();
22
+ return base === "audio/mpeg" || base === "audio/mp4" || base === "audio/aac";
23
+ }
24
+
25
+ // src/index.ts
18
26
  import {
27
+ accessMessage,
28
+ assertPublic,
29
+ assertSha,
19
30
  bestTextContrast,
20
31
  computeVariantDimensions,
21
32
  configureSlotResolver as configureSlotResolver2,
22
33
  contrastRatio,
34
+ deriveAccessKey,
23
35
  extractAssetSha,
24
36
  getAmbientGradient,
25
37
  getAssetDimensions,
@@ -30,6 +42,8 @@ import {
30
42
  getHlsStreamingUrl as getHlsStreamingUrl2,
31
43
  getPaletteBlurBackground,
32
44
  getPaletteCssVars,
45
+ getPrivateAssetUrl,
46
+ getPrivateTransformUrl,
33
47
  getSignedTransformUrl as getSignedTransformUrl2,
34
48
  getTenantId,
35
49
  getTextColorForBackground,
@@ -51,6 +65,7 @@ import {
51
65
  serializeTransform,
52
66
  setCdnBase as setCdnBase2,
53
67
  setTenantId as setTenantId2,
68
+ signAccessUrl,
54
69
  signTransformUrl,
55
70
  TRANSFORM_WIDTHS
56
71
  } from "@nitida/asset-client";
@@ -256,9 +271,10 @@ var AssetsApi = class {
256
271
  return Array.isArray(dto.variants) ? dto.variants : [];
257
272
  }
258
273
  /**
259
- * Add or rebuild variants on an existing asset. Image presets are
260
- * MERGED with what's there passing `{ presets: ["thumb"] }` adds
261
- * the thumb variant without touching `lg`, `sm`, `original`, etc.
274
+ * Add or rebuild variants on an existing asset. Presets are MERGED
275
+ * with what's there, for images AND for video passing
276
+ * `{ presets: ["thumb"] }` adds the thumb variant without touching
277
+ * `lg`, `sm`, `original`, etc.
262
278
  *
263
279
  * // Day 0: upload original-only logo
264
280
  * const { assetId } = await aq.upload(logoFile); // defaults to ["original"]
@@ -277,10 +293,18 @@ var AssetsApi = class {
277
293
  * reading the source bytes from the permanent `original` variant —
278
294
  * no need to re-upload.
279
295
  *
280
- * Video presets are filtered to `["poster","video","aiproxy"]` and
281
- * dispatched to a background job (the call returns immediately
296
+ * Video presets are filtered to `["poster","video","aiproxy","probe"]`
297
+ * and dispatched to a background job (the call returns immediately
282
298
  * with a dispatch handle; poll `aq.assets.get(id).status` for
283
299
  * completion).
300
+ *
301
+ * ⚠️ Before 2026-08-22 the video path REPLACED the whole variant
302
+ * registry instead of merging, so a partial regenerate silently
303
+ * deregistered `poster`, `video` and — irrecoverably — `hls`, which
304
+ * is not a {@link RequestablePreset} and therefore cannot be asked
305
+ * for again. The objects kept serving from the CDN; only the
306
+ * registry died. Fixed server-side; a client on an older server
307
+ * still loses them.
284
308
  */
285
309
  async regenerate(assetId, opts = {}) {
286
310
  const r = await fetch(
@@ -548,7 +572,7 @@ var NitidaClient = class {
548
572
  }
549
573
  if (!this.opts.signingKey) {
550
574
  throw new Error(
551
- "aq.transform({ sign: true }) requires `signingKey` in NitidaClientOptions. Pull the tenant's signing key from /admin/tenants/:id and pass it to the SDK constructor on a SERVER-side instance only."
575
+ "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."
552
576
  );
553
577
  }
554
578
  return getSignedTransformUrl(asset, opts, this.opts.signingKey) ?? Promise.resolve(this.urlFor(asset, "lg"));
@@ -729,6 +753,7 @@ var NitidaClient = class {
729
753
  return {
730
754
  assetId: existing.id,
731
755
  sha256: sha,
756
+ sha: sha.slice(0, 16),
732
757
  cdnUrl: this.urlFor(existing, this.bestPresetForAsset(existing, mime))
733
758
  };
734
759
  }
@@ -745,6 +770,7 @@ var NitidaClient = class {
745
770
  return {
746
771
  assetId: presign.asset.id,
747
772
  sha256: sha,
773
+ sha: sha.slice(0, 16),
748
774
  cdnUrl: this.urlFor(presign.asset, this.defaultPresetForMime(mime))
749
775
  };
750
776
  }
@@ -787,6 +813,7 @@ var NitidaClient = class {
787
813
  return {
788
814
  assetId,
789
815
  sha256: sha,
816
+ sha: sha.slice(0, 16),
790
817
  cdnUrl: this.urlFor(final, this.bestPresetForAsset(final, mime))
791
818
  };
792
819
  }
@@ -800,12 +827,30 @@ var NitidaClient = class {
800
827
  * `presets` string. Falls back through the preference order
801
828
  * lg → md → sm → thumb → original (for images)
802
829
  * video → poster (for videos)
803
- * mp3original (for audio)
830
+ * originalmp3 (for audio ALREADY playable everywhere)
831
+ * mp3 → original (for any other audio)
804
832
  * so an upload that was processed with e.g. `["original"]` still
805
833
  * returns a non-404 URL in `aq.upload`'s result.
834
+ *
835
+ * ⭐ Why audio branches on the source mime (changed 2026-08-22).
836
+ *
837
+ * It used to be `mp3 → original` unconditionally, so `upload().cdnUrl`
838
+ * handed back the server's auto-generated mp3 — libmp3lame, mono ~96 kbps —
839
+ * even when the caller had uploaded an MP3 or an AAC that already plays in
840
+ * every target browser. A consumer asking for "my file" silently received a
841
+ * re-encoded, lower-quality one, with no error to notice.
842
+ *
843
+ * Measured across the platform: of 126 audio assets carrying an mp3 variant,
844
+ * **105 had an `audio/mpeg` source** — an MP3 re-encoded into an MP3, for
845
+ * zero compatibility gain.
846
+ *
847
+ * The mp3 still wins for `audio/webm`/Opus and anything exotic, which is the
848
+ * case it was built for: Chrome records webm/Opus, which iOS Safari cannot
849
+ * decode. That guarantee is preserved exactly; only the needless downgrade
850
+ * is gone.
806
851
  */
807
852
  bestPresetForAsset(asset, mime) {
808
- const order = mime.startsWith("video/") ? ["video", "poster"] : mime.startsWith("audio/") ? ["mp3", "original"] : ["lg", "md", "sm", "thumb", "xl", "original"];
853
+ const order = mime.startsWith("video/") ? ["video", "poster"] : mime.startsWith("audio/") ? isUniversallyPlayableAudio(mime) ? ["original", "mp3"] : ["mp3", "original"] : ["lg", "md", "sm", "thumb", "xl", "original"];
809
854
  return order.find((p) => hasPreset(asset, p)) ?? this.defaultPresetForMime(mime);
810
855
  }
811
856
  };
@@ -816,10 +861,14 @@ export {
816
861
  PRESET_MAX_DIM,
817
862
  PRESET_SHORT,
818
863
  TRANSFORM_WIDTHS,
864
+ accessMessage,
865
+ assertPublic,
866
+ assertSha,
819
867
  bestTextContrast,
820
868
  computeVariantDimensions,
821
869
  configureSlotResolver2 as configureSlotResolver,
822
870
  contrastRatio,
871
+ deriveAccessKey,
823
872
  extractAssetSha,
824
873
  getAmbientGradient,
825
874
  getAssetDimensions,
@@ -830,6 +879,8 @@ export {
830
879
  getHlsStreamingUrl2 as getHlsStreamingUrl,
831
880
  getPaletteBlurBackground,
832
881
  getPaletteCssVars,
882
+ getPrivateAssetUrl,
883
+ getPrivateTransformUrl,
833
884
  getSignedTransformUrl2 as getSignedTransformUrl,
834
885
  getTenantId,
835
886
  getTextColorForBackground,
@@ -839,6 +890,7 @@ export {
839
890
  hasPreset2 as hasPreset,
840
891
  hlsLadderAlignment,
841
892
  invalidateSlotCache2 as invalidateSlotCache,
893
+ isUniversallyPlayableAudio,
842
894
  iteratePaletteSwatches,
843
895
  mimeFromFileName,
844
896
  pickAmbientBackground,
@@ -848,6 +900,7 @@ export {
848
900
  serializeTransform,
849
901
  setCdnBase2 as setCdnBase,
850
902
  setTenantId2 as setTenantId,
903
+ signAccessUrl,
851
904
  signTransformUrl
852
905
  };
853
906
  //# sourceMappingURL=index.js.map