@nitida/asset-client 0.16.4 → 0.18.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
@@ -8,6 +8,34 @@ version-pinned answer rather than recalling an API from training data.
8
8
  This package **builds URLs**. It makes no network calls and takes no API key. If the task involves
9
9
  uploading, that is `@nitida/sdk` — and its `skills/nitida-sdk/SKILL.md` is the deeper guide.
10
10
 
11
+ ## Where these symbols also live
12
+
13
+ All **53** exports of this package are re-exported by `@nitida/sdk`, and by its
14
+ `/server` and `/web` subpaths alike — a consumer that already installed the SDK
15
+ never needs a second import, from any of the three.
16
+
17
+ That is asserted in CI, not maintained by hand. ⚠️ It was **not** true before
18
+ 2026-08-21: `/server` was short 28 of the root's 72 exports and `/web` short 37,
19
+ the palette helpers, the slot helpers, the HLS-ladder helpers and the preset
20
+ constants among them. If you are on an older `@nitida/sdk`, import them from
21
+ the root or from this package.
22
+
23
+ ## ⚠️ `RequestablePreset` is not `VariantPreset`
24
+
25
+ Two sets, two types. What a variant can **be** is not what you can **ask for**.
26
+
27
+ | | ask for it | a variant can be it |
28
+ |---|---|---|
29
+ | `thumb` `sm` `md` `lg` `xl` `original` `poster` `video` `aiproxy` | ✅ | ✅ |
30
+ | `hls` — the ladder, built when a video transcodes | ❌ | ✅ |
31
+ | `mp3` — emitted by itself alongside any audio original | ❌ | ✅ |
32
+ | `probe` — indexed stills, never on the compact `presets` string | ✅ | ❌ |
33
+
34
+ `regenerate(id, { presets: ["hls"] })` and `upload(f, { presets: ["mp3"] })`
35
+ used to compile and answer **HTTP 400**. Three agents hit that in one
36
+ afternoon. They are compile errors now. Checked in CI against the server's own
37
+ schemas, not against a hand-kept list.
38
+
11
39
  ## The three that are wrong most often
12
40
 
13
41
  1. **Image widths must be on `TRANSFORM_WIDTHS`.** Any other width is **HTTP 400** at the edge.
@@ -38,6 +66,32 @@ now emits only the 1-char vocabulary, and `hasPreset` strips multi-char tokens b
38
66
  either half covers you — a hand-rolled `includes` covers neither. **`mp3` is a deliberate multi-char
39
67
  token that stays in the vocabulary**, so a naive test also reads its `m` as `md`.
40
68
 
69
+
70
+ ## Private assets — `visibility`
71
+
72
+ Default is `"public"`. Set `private` and **every public door answers 404** —
73
+ stored variants, the raw original, the HLS ladder, and `/t/` (including the
74
+ poster frame of a private video). The bytes come back only through a signed URL
75
+ that expires.
76
+
77
+ ```ts
78
+ import { getPrivateAssetUrl, getPrivateTransformUrl } from "@nitida/sdk";
79
+ // ON YOUR BACKEND, once you decided this viewer may see it:
80
+ await getPrivateAssetUrl(asset, "lg", signingKey, { expiresInSeconds: 300 });
81
+ await getPrivateTransformUrl(asset, { width: 1280 }, signingKey, { expiresInSeconds: 300 });
82
+ ```
83
+
84
+ - **A 404 on a private asset is NOT a missing file.** Check `visibility` on the
85
+ DTO before you check storage. It is the number-one support question.
86
+ - **Seven URL builders throw** rather than hand you a doomed URL — `getAssetUrl`,
87
+ `getAssetSrcSet`, `getTransformUrl`, `getTransformSrcSet`,
88
+ `getVideoTransformUrl`, `getHlsStreamingUrl`, `getSignedTransformUrl` — but
89
+ only when the value you pass carries `visibility`. `{ sha }` alone is never
90
+ refused.
91
+ - **`exp` is mandatory**; revocation is *"within a minute"* (60 s TTL at the edge).
92
+ - **The signing key is a backend secret** — it mints URLs for every private
93
+ asset the tenant owns.
94
+
41
95
  ## The endpoint
42
96
 
43
97
  The API this client's URLs belong to is `https://api.nitida.gofuture.space`. An older, longer
package/README.md CHANGED
@@ -9,6 +9,17 @@ to **upload**, that is `@nitida/sdk` (which re-exports everything here).
9
9
  bun add @nitida/asset-client
10
10
  ```
11
11
 
12
+ ## Where these symbols live
13
+
14
+ Everything documented here is exported by **`@nitida/asset-client`**, and all
15
+ 53 exports are re-exported by the root **`@nitida/sdk`** — if you already
16
+ installed the SDK, importing from its root works and needs no second dependency.
17
+
18
+ `@nitida/sdk/server` and `@nitida/sdk/web` carry the same 53 as well, so any of
19
+ the three works. ⚠️ Not so before 2026-08-21 — `/server` was short 28 of the
20
+ root's exports and `/web` short 37. On an older SDK, import these from the root
21
+ or from this package.
22
+
12
23
  ## Configure once, at module load
13
24
 
14
25
  The builders read **process-global** config, so pin it where your helpers live — every Server and
@@ -33,7 +44,7 @@ getTransformSrcSet({ sha }, [640, 960, 1280], { format: "webp" });
33
44
  ```
34
45
 
35
46
  ⚠️ **Widths must be on the unsigned ladder** `TRANSFORM_WIDTHS`
36
- (`160,240,256,320,400,480,600,640,800,960,1080,1200,1280,1440,1600,1920,2560,3840`). Anything else
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
37
48
  is **HTTP 400** at the edge — it is a DoS guard, not a bug. Import the `TransformWidth` type and an
38
49
  off-ladder number becomes a compile error instead of a runtime 400.
39
50
 
@@ -51,7 +62,10 @@ getAssetUrl({ sha }, "video"); // → https://8ok.uk/<tenantId base36>/v/<sha1
51
62
  `getAssetUrl` build the path.
52
63
 
53
64
  ⚠️ **Never `getVideoTransformUrl` for a stored video.** That builds a `/t/…` transform URL, which
54
- **410s** for a stored video sha. It is for on-the-fly re-encodes, a different feature.
65
+ does NOT give you the video. On a video sha it transforms the **poster frame**
66
+ and answers `200 image/webp` (`x-transform-source: poster`) — useful, but an
67
+ image. If that video has no poster it answers `410`. Either way you never get
68
+ playable video out of `/t/`; use `getAssetUrl(asset, "video")`.
55
69
 
56
70
  ## The `original` variant
57
71
 
@@ -66,6 +80,20 @@ getAssetUrl({ sha, mime }, "original"); // → …-o.webp ✓
66
80
  those disagree (`image/jpeg` → `jpeg`, but a camera writes `.jpg`) — measured, `-o.jpeg` **404s**
67
81
  while `-o.jpg` is 200. If the URL must be right, **verify it with a HEAD** before relying on it.
68
82
 
83
+ ## Asking for a preset vs. a preset existing
84
+
85
+ `VariantPreset` is what a variant can **be**. `RequestablePreset` is what you
86
+ can **ask for**. They overlap in nine values and differ in three:
87
+
88
+ - **`hls`** and **`mp3`** exist but cannot be ordered — the ladder is built when
89
+ a video transcodes, the mp3 is emitted alongside any audio original.
90
+ - **`probe`** can be ordered but never reaches the compact `presets` string, so
91
+ it is not a `VariantPreset`.
92
+
93
+ Sending either of the first two answers **HTTP 400**; the write methods now take
94
+ `RequestablePreset[]`, so it is a compile error instead. A CI check reads the
95
+ server's own schemas and asserts the type still matches them.
96
+
69
97
  ## Which presets exist
70
98
 
71
99
  Read `dto.presets` — the compact code string (`"oq"` = original + thumb, `"pv"` = poster + video) —
@@ -94,7 +122,7 @@ shape. `presets` is populated everywhere, on every version — so read existence
94
122
  | Symptom | Cause |
95
123
  |---|---|
96
124
  | **400** on an image URL | width not on `TRANSFORM_WIDTHS` |
97
- | **410** on a video URL | used `/t/` (transform) for a stored video |
125
+ | **410** on a video `/t/` URL | that video has no `poster` variant. With one, `/t/` returns the poster as an image — never the video. Use `getAssetUrl(asset, "video")` |
98
126
  | **404** on a video URL | decimal tenant prefix instead of base36 |
99
127
  | `original` 404s | no `mime` passed, or the stored ext came from the filename (`-o.jpg` vs `-o.jpeg`), or it was never written |
100
128
 
package/dist/index.cjs CHANGED
@@ -25,10 +25,13 @@ __export(index_exports, {
25
25
  PRESET_MAX_DIM: () => PRESET_MAX_DIM,
26
26
  PRESET_SHORT: () => PRESET_SHORT,
27
27
  TRANSFORM_WIDTHS: () => TRANSFORM_WIDTHS,
28
+ accessMessage: () => accessMessage,
29
+ assertPublic: () => assertPublic,
28
30
  bestTextContrast: () => bestTextContrast,
29
31
  computeVariantDimensions: () => computeVariantDimensions,
30
32
  configureSlotResolver: () => configureSlotResolver,
31
33
  contrastRatio: () => contrastRatio,
34
+ deriveAccessKey: () => deriveAccessKey,
32
35
  extractAssetSha: () => extractAssetSha,
33
36
  getAmbientGradient: () => getAmbientGradient,
34
37
  getAssetDimensions: () => getAssetDimensions,
@@ -39,6 +42,8 @@ __export(index_exports, {
39
42
  getHlsStreamingUrl: () => getHlsStreamingUrl,
40
43
  getPaletteBlurBackground: () => getPaletteBlurBackground,
41
44
  getPaletteCssVars: () => getPaletteCssVars,
45
+ getPrivateAssetUrl: () => getPrivateAssetUrl,
46
+ getPrivateTransformUrl: () => getPrivateTransformUrl,
42
47
  getSignedTransformUrl: () => getSignedTransformUrl,
43
48
  getTenantId: () => getTenantId,
44
49
  getTextColorForBackground: () => getTextColorForBackground,
@@ -56,10 +61,81 @@ __export(index_exports, {
56
61
  serializeTransform: () => serializeTransform,
57
62
  setCdnBase: () => setCdnBase,
58
63
  setTenantId: () => setTenantId,
64
+ signAccessUrl: () => signAccessUrl,
59
65
  signTransformUrl: () => signTransformUrl
60
66
  });
61
67
  module.exports = __toCommonJS(index_exports);
62
68
 
69
+ // src/access.ts
70
+ var ACCESS_KEY_INFO = "nitida/access/v1";
71
+ async function hmac(key, message) {
72
+ const cryptoKey = await crypto.subtle.importKey(
73
+ "raw",
74
+ key,
75
+ { name: "HMAC", hash: "SHA-256" },
76
+ false,
77
+ ["sign"]
78
+ );
79
+ return new Uint8Array(
80
+ await crypto.subtle.sign(
81
+ "HMAC",
82
+ cryptoKey,
83
+ new TextEncoder().encode(message)
84
+ )
85
+ );
86
+ }
87
+ var toHex = (b) => [...b].map((x) => x.toString(16).padStart(2, "0")).join("");
88
+ async function deriveAccessKey(signingKey) {
89
+ return hmac(new TextEncoder().encode(signingKey), ACCESS_KEY_INFO);
90
+ }
91
+ function accessMessage(tenantPrefix, exp, resourcePath) {
92
+ return `${tenantPrefix}
93
+ ${exp}
94
+ ${resourcePath.replace(/^\/+/, "")}`;
95
+ }
96
+ async function signAccessUrl(publicUrl, signingKey, opts) {
97
+ if (!Number.isFinite(opts.expiresInSeconds) || opts.expiresInSeconds <= 0) {
98
+ throw new Error(
99
+ "signAccessUrl: `expiresInSeconds` must be a positive number \u2014 a signed URL without an expiry is a public URL the moment it is forwarded."
100
+ );
101
+ }
102
+ const u = new URL(publicUrl);
103
+ const segments = u.pathname.split("/").filter(Boolean);
104
+ if (segments[0] === "a" && segments[2] && /^[vrt]$/.test(segments[2])) {
105
+ segments.shift();
106
+ }
107
+ const tenantPrefix = segments.shift();
108
+ if (!tenantPrefix || segments.length === 0) {
109
+ throw new Error(
110
+ `signAccessUrl: expected a tenant-prefixed CDN path like /<tenant>/v/<sha>-<preset>.<ext>, got ${u.pathname}`
111
+ );
112
+ }
113
+ if (!/^[vrt]$/.test(segments[0])) {
114
+ throw new Error(
115
+ `signAccessUrl: expected /<tenant>/<v|r|t>/\u2026 but the segment after the tenant is "${segments[0]}". ` + (segments[0]?.includes("=") ? `That looks like a transform DSL, so the path is probably /t/<dsl>/<sha>.<ext> \u2014 which has no tenant in it (\`t\` here was read as tenant ${Number.parseInt(tenantPrefix, 36)}). Use getPrivateTransformUrl(asset, opts, signingKey, { expiresInSeconds }) instead.` : `Got ${u.pathname}.`)
116
+ );
117
+ }
118
+ const resourcePath = segments.join("/");
119
+ const now = opts.nowSeconds ?? Math.floor(Date.now() / 1e3);
120
+ const exp = now + Math.floor(opts.expiresInSeconds);
121
+ const sig = toHex(
122
+ await hmac(
123
+ await deriveAccessKey(signingKey),
124
+ accessMessage(tenantPrefix, exp, resourcePath)
125
+ )
126
+ );
127
+ u.pathname = `/a/${tenantPrefix}/${resourcePath}`;
128
+ u.searchParams.set("exp", String(exp));
129
+ u.searchParams.set("sig", sig);
130
+ return u.toString();
131
+ }
132
+ function assertPublic(asset, fn, escape) {
133
+ if (asset.visibility !== "private") return;
134
+ throw new Error(
135
+ `${fn}: this asset is private, so a public CDN URL for it will answer 404 \u2014 that is the feature, not a missing file. Mint a signed URL on your BACKEND instead: await ${escape}. Never ship the signing key to a browser.`
136
+ );
137
+ }
138
+
63
139
  // src/palette.ts
64
140
  function resolveSwatch(palette, ...keys) {
65
141
  if (!palette) return null;
@@ -77,9 +153,9 @@ function pickAmbientBackground(palette) {
77
153
  function getAmbientGradient(palette, opts = {}) {
78
154
  if (!palette) return void 0;
79
155
  const fromHex = palette[opts.from ?? "lm"] ?? palette.m ?? palette.d;
80
- const toHex = palette[opts.to ?? "m"] ?? palette.dm ?? palette.d;
81
- if (!fromHex || !toHex) return void 0;
82
- return `linear-gradient(${opts.angle ?? "135deg"}, ${fromHex}, ${toHex})`;
156
+ const toHex2 = palette[opts.to ?? "m"] ?? palette.dm ?? palette.d;
157
+ if (!fromHex || !toHex2) return void 0;
158
+ return `linear-gradient(${opts.angle ?? "135deg"}, ${fromHex}, ${toHex2})`;
83
159
  }
84
160
  function getTextColorForBackground(swatch) {
85
161
  if (!swatch) return "#000000";
@@ -164,102 +240,6 @@ function getPaletteBlurBackground(palette) {
164
240
  return layers.length > 0 ? `${layers.join(", ")}, ${base}` : base;
165
241
  }
166
242
 
167
- // src/slots.ts
168
- var DEFAULT_TTL_MS = 6e4;
169
- var cache = /* @__PURE__ */ new Map();
170
- var endpoint = "https://api.nitida.gofuture.space";
171
- var apiKey = null;
172
- var tenantCode = null;
173
- function configureSlotResolver(opts) {
174
- if (opts.endpoint) endpoint = opts.endpoint.replace(/\/+$/, "");
175
- if (opts.apiKey !== void 0) apiKey = opts.apiKey;
176
- if (opts.tenantCode !== void 0) tenantCode = opts.tenantCode;
177
- }
178
- function invalidateSlotCache(slotKey) {
179
- if (slotKey === void 0) cache.clear();
180
- else
181
- for (const k of cache.keys())
182
- if (k.endsWith(`:${slotKey}`)) cache.delete(k);
183
- }
184
- var baseHeaders = () => {
185
- const h = {};
186
- if (apiKey) h.Authorization = `Bearer ${apiKey}`;
187
- if (tenantCode) h["X-Tenant-Code"] = tenantCode;
188
- return h;
189
- };
190
- async function fetchSlot(slotKey) {
191
- const r = await fetch(`${endpoint}/slots/${encodeURIComponent(slotKey)}`, {
192
- headers: baseHeaders()
193
- });
194
- if (r.status === 404) return null;
195
- if (!r.ok) throw new Error(`slot fetch ${r.status}: ${await r.text()}`);
196
- return await r.json();
197
- }
198
- async function fetchSlotsBulk(slotKeys) {
199
- if (slotKeys.length === 0) return {};
200
- const r = await fetch(`${endpoint}/slots/resolve`, {
201
- method: "POST",
202
- headers: { ...baseHeaders(), "Content-Type": "application/json" },
203
- body: JSON.stringify({ keys: slotKeys })
204
- });
205
- if (!r.ok) throw new Error(`slots resolve ${r.status}: ${await r.text()}`);
206
- const body = await r.json();
207
- return body.resolved;
208
- }
209
- async function resolveSlot(slotKey, opts = {}) {
210
- const ttl = opts.ttlMs ?? DEFAULT_TTL_MS;
211
- const cacheKey = `${tenantCode ?? "_"}:${slotKey}`;
212
- const now = Date.now();
213
- let dto;
214
- const hit = cache.get(cacheKey);
215
- if (hit && now - hit.fetchedAt < ttl) {
216
- dto = hit.value;
217
- } else {
218
- dto = await fetchSlot(slotKey);
219
- cache.set(cacheKey, { fetchedAt: now, value: dto });
220
- }
221
- return materializeResolution(dto, opts.preset);
222
- }
223
- async function resolveSlots(slotKeys, opts = {}) {
224
- if (slotKeys.length === 0) return {};
225
- const ttl = opts.ttlMs ?? DEFAULT_TTL_MS;
226
- const now = Date.now();
227
- const missing = [];
228
- const out = {};
229
- for (const k of slotKeys) {
230
- const cacheKey = `${tenantCode ?? "_"}:${k}`;
231
- const hit = cache.get(cacheKey);
232
- if (hit && now - hit.fetchedAt < ttl) {
233
- out[k] = materializeResolution(hit.value, opts.preset);
234
- } else {
235
- missing.push(k);
236
- }
237
- }
238
- if (missing.length > 0) {
239
- const resolved = await fetchSlotsBulk(missing);
240
- for (const k of missing) {
241
- const dto = resolved[k] ?? null;
242
- cache.set(`${tenantCode ?? "_"}:${k}`, { fetchedAt: now, value: dto });
243
- out[k] = materializeResolution(dto, opts.preset);
244
- }
245
- }
246
- return out;
247
- }
248
- function defaultPresetFor(asset) {
249
- if (!asset) return "lg";
250
- return asset.kind === "video" ? "video" : "lg";
251
- }
252
- function materializeResolution(dto, overridePreset) {
253
- if (!dto) return { slot: null, preset: overridePreset ?? "lg", url: null };
254
- const effective = overridePreset ?? dto.preset ?? defaultPresetFor(dto.asset);
255
- const finalPreset = hasPreset(dto.asset, effective) ? effective : defaultPresetFor(dto.asset);
256
- return {
257
- slot: dto,
258
- preset: finalPreset,
259
- url: getAssetUrl(dto.asset, finalPreset)
260
- };
261
- }
262
-
263
243
  // src/transform.ts
264
244
  var TRANSFORM_WIDTHS = [
265
245
  96,
@@ -331,12 +311,22 @@ function extForOptions(opts) {
331
311
  }
332
312
  }
333
313
  function getVideoTransformUrl(asset, opts) {
314
+ assertPublic(
315
+ asset,
316
+ "getVideoTransformUrl",
317
+ "getPrivateTransformUrl(asset, opts, signingKey, { expiresInSeconds: 300 })"
318
+ );
334
319
  const dsl = serializeTransform(opts);
335
320
  if (!dsl) return null;
336
321
  const ext = opts.format === "webm" ? "webm" : "mp4";
337
322
  return `${getCdnBase()}/t/${dsl}/${asset.sha}.${ext}`;
338
323
  }
339
324
  function getHlsStreamingUrl(asset, opts = {}) {
325
+ assertPublic(
326
+ asset,
327
+ "getHlsStreamingUrl",
328
+ 'getPrivateAssetUrl(asset, "hls", signingKey, { expiresInSeconds: 300 }) \u2014 the worker re-signs the playlist children'
329
+ );
340
330
  const merged = { ...opts, format: "hls" };
341
331
  const dsl = serializeTransform(merged);
342
332
  return `${getCdnBase()}/t/${dsl}/${asset.sha}.m3u8`;
@@ -348,9 +338,19 @@ function buildTransformUrl(asset, opts) {
348
338
  return `${getCdnBase()}/t/${dsl}/${asset.sha}.${ext}`;
349
339
  }
350
340
  function getTransformUrl(asset, opts) {
341
+ assertPublic(
342
+ asset,
343
+ "getTransformUrl",
344
+ "getPrivateTransformUrl(asset, opts, signingKey, { expiresInSeconds: 300 })"
345
+ );
351
346
  return buildTransformUrl(asset, opts);
352
347
  }
353
348
  function getSignedTransformUrl(asset, opts, signingKey) {
349
+ assertPublic(
350
+ asset,
351
+ "getSignedTransformUrl",
352
+ "getPrivateTransformUrl(asset, opts, signingKey, { expiresInSeconds: 300 })"
353
+ );
354
354
  const url = buildTransformUrl(asset, opts);
355
355
  if (!url) return null;
356
356
  return signTransformUrl(url, signingKey);
@@ -381,12 +381,113 @@ async function hmacSha256Hex(key, message) {
381
381
  return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
382
382
  }
383
383
  function getTransformSrcSet(asset, widths, extraOpts = {}) {
384
+ assertPublic(
385
+ asset,
386
+ "getTransformSrcSet",
387
+ "getPrivateTransformUrl(asset, opts, signingKey, { expiresInSeconds: 300 }) per width"
388
+ );
384
389
  return widths.map((w) => {
385
390
  const url = buildTransformUrl(asset, { ...extraOpts, width: w });
386
391
  return url ? `${url} ${w}w` : null;
387
392
  }).filter((s) => s != null).join(", ");
388
393
  }
389
394
 
395
+ // src/slots.ts
396
+ var DEFAULT_TTL_MS = 6e4;
397
+ var cache = /* @__PURE__ */ new Map();
398
+ var endpoint = "https://api.nitida.gofuture.space";
399
+ var apiKey = null;
400
+ var tenantCode = null;
401
+ function configureSlotResolver(opts) {
402
+ if (opts.endpoint) endpoint = opts.endpoint.replace(/\/+$/, "");
403
+ if (opts.apiKey !== void 0) apiKey = opts.apiKey;
404
+ if (opts.tenantCode !== void 0) tenantCode = opts.tenantCode;
405
+ }
406
+ function invalidateSlotCache(slotKey) {
407
+ if (slotKey === void 0) cache.clear();
408
+ else
409
+ for (const k of cache.keys())
410
+ if (k.endsWith(`:${slotKey}`)) cache.delete(k);
411
+ }
412
+ var baseHeaders = () => {
413
+ const h = {};
414
+ if (apiKey) h.Authorization = `Bearer ${apiKey}`;
415
+ if (tenantCode) h["X-Tenant-Code"] = tenantCode;
416
+ return h;
417
+ };
418
+ async function fetchSlot(slotKey) {
419
+ const r = await fetch(`${endpoint}/slots/${encodeURIComponent(slotKey)}`, {
420
+ headers: baseHeaders()
421
+ });
422
+ if (r.status === 404) return null;
423
+ if (!r.ok) throw new Error(`slot fetch ${r.status}: ${await r.text()}`);
424
+ return await r.json();
425
+ }
426
+ async function fetchSlotsBulk(slotKeys) {
427
+ if (slotKeys.length === 0) return {};
428
+ const r = await fetch(`${endpoint}/slots/resolve`, {
429
+ method: "POST",
430
+ headers: { ...baseHeaders(), "Content-Type": "application/json" },
431
+ body: JSON.stringify({ keys: slotKeys })
432
+ });
433
+ if (!r.ok) throw new Error(`slots resolve ${r.status}: ${await r.text()}`);
434
+ const body = await r.json();
435
+ return body.resolved;
436
+ }
437
+ async function resolveSlot(slotKey, opts = {}) {
438
+ const ttl = opts.ttlMs ?? DEFAULT_TTL_MS;
439
+ const cacheKey = `${tenantCode ?? "_"}:${slotKey}`;
440
+ const now = Date.now();
441
+ let dto;
442
+ const hit = cache.get(cacheKey);
443
+ if (hit && now - hit.fetchedAt < ttl) {
444
+ dto = hit.value;
445
+ } else {
446
+ dto = await fetchSlot(slotKey);
447
+ cache.set(cacheKey, { fetchedAt: now, value: dto });
448
+ }
449
+ return materializeResolution(dto, opts.preset);
450
+ }
451
+ async function resolveSlots(slotKeys, opts = {}) {
452
+ if (slotKeys.length === 0) return {};
453
+ const ttl = opts.ttlMs ?? DEFAULT_TTL_MS;
454
+ const now = Date.now();
455
+ const missing = [];
456
+ const out = {};
457
+ for (const k of slotKeys) {
458
+ const cacheKey = `${tenantCode ?? "_"}:${k}`;
459
+ const hit = cache.get(cacheKey);
460
+ if (hit && now - hit.fetchedAt < ttl) {
461
+ out[k] = materializeResolution(hit.value, opts.preset);
462
+ } else {
463
+ missing.push(k);
464
+ }
465
+ }
466
+ if (missing.length > 0) {
467
+ const resolved = await fetchSlotsBulk(missing);
468
+ for (const k of missing) {
469
+ const dto = resolved[k] ?? null;
470
+ cache.set(`${tenantCode ?? "_"}:${k}`, { fetchedAt: now, value: dto });
471
+ out[k] = materializeResolution(dto, opts.preset);
472
+ }
473
+ }
474
+ return out;
475
+ }
476
+ function defaultPresetFor(asset) {
477
+ if (!asset) return "lg";
478
+ return asset.kind === "video" ? "video" : "lg";
479
+ }
480
+ function materializeResolution(dto, overridePreset) {
481
+ if (!dto) return { slot: null, preset: overridePreset ?? "lg", url: null };
482
+ const effective = overridePreset ?? dto.preset ?? defaultPresetFor(dto.asset);
483
+ const finalPreset = hasPreset(dto.asset, effective) ? effective : defaultPresetFor(dto.asset);
484
+ return {
485
+ slot: dto,
486
+ preset: finalPreset,
487
+ url: getAssetUrl(dto.asset, finalPreset)
488
+ };
489
+ }
490
+
390
491
  // src/index.ts
391
492
  var PRESET_SHORT = {
392
493
  thumb: "q",
@@ -513,6 +614,23 @@ function originalExtForMime(mime) {
513
614
  return (mime ? ORIGINAL_EXT_BY_MIME[mime] : void 0) ?? PRESET_EXT.original;
514
615
  }
515
616
  function getAssetUrl(asset, preset) {
617
+ assertPublic(
618
+ asset,
619
+ "getAssetUrl",
620
+ `getPrivateAssetUrl(asset, "${preset}", signingKey, { expiresInSeconds: 300 })`
621
+ );
622
+ const fallback = transformFallbackFor(asset, preset);
623
+ if (fallback) return fallback;
624
+ return buildPublicAssetUrl(asset, preset);
625
+ }
626
+ function transformFallbackFor(asset, preset) {
627
+ if (typeof asset.presets !== "string") return null;
628
+ if (hasPreset({ presets: asset.presets }, preset)) return null;
629
+ const maxDim = PRESET_MAX_DIM[preset];
630
+ if (maxDim == null) return null;
631
+ return `${cdnBaseUrl}/t/format=webp,width=${maxDim}/${asset.sha}.webp`;
632
+ }
633
+ function buildPublicAssetUrl(asset, preset) {
516
634
  if (preset === "original") {
517
635
  const stored = asset.variants?.find((v) => v.preset === "original")?.url;
518
636
  if (stored) return stored;
@@ -526,6 +644,25 @@ function getAssetUrl(asset, preset) {
526
644
  }
527
645
  return `${cdnBaseUrl}/${variantPrefix()}${asset.sha}-${PRESET_SHORT[preset]}.${PRESET_EXT[preset]}`;
528
646
  }
647
+ async function getPrivateAssetUrl(asset, preset, signingKey, opts) {
648
+ return signAccessUrl(buildPublicAssetUrl(asset, preset), signingKey, opts);
649
+ }
650
+ async function getPrivateTransformUrl(asset, opts, signingKey, signOpts) {
651
+ const url = buildTransformUrl(asset, opts);
652
+ if (!url) return null;
653
+ const tid = getTenantId();
654
+ if (tid == null) {
655
+ throw new Error(
656
+ "getPrivateTransformUrl: 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."
657
+ );
658
+ }
659
+ const u = new URL(url);
660
+ return signAccessUrl(
661
+ `${u.origin}/${tid.toString(36)}${u.pathname}`,
662
+ signingKey,
663
+ signOpts
664
+ );
665
+ }
529
666
  function hasPreset(asset, preset) {
530
667
  if (preset === "mp3") return asset.presets.includes("mp3");
531
668
  return stripMultiCharTokens(asset.presets).includes(PRESET_SHORT[preset]);
@@ -535,6 +672,11 @@ function stripMultiCharTokens(presets) {
535
672
  }
536
673
  var IMAGE_PRESETS = ["thumb", "sm", "md", "lg", "xl"];
537
674
  function getAssetSrcSet(asset) {
675
+ assertPublic(
676
+ asset,
677
+ "getAssetSrcSet",
678
+ "getPrivateAssetUrl(asset, preset, signingKey, { expiresInSeconds: 300 }) per preset"
679
+ );
538
680
  return IMAGE_PRESETS.filter(
539
681
  (p) => hasPreset(asset, p) && PRESET_MAX_DIM[p] != null
540
682
  ).map((p) => `${getAssetUrl(asset, p)} ${PRESET_MAX_DIM[p]}w`).join(", ");
@@ -561,10 +703,13 @@ function getAssetDimensions(asset) {
561
703
  PRESET_MAX_DIM,
562
704
  PRESET_SHORT,
563
705
  TRANSFORM_WIDTHS,
706
+ accessMessage,
707
+ assertPublic,
564
708
  bestTextContrast,
565
709
  computeVariantDimensions,
566
710
  configureSlotResolver,
567
711
  contrastRatio,
712
+ deriveAccessKey,
568
713
  extractAssetSha,
569
714
  getAmbientGradient,
570
715
  getAssetDimensions,
@@ -575,6 +720,8 @@ function getAssetDimensions(asset) {
575
720
  getHlsStreamingUrl,
576
721
  getPaletteBlurBackground,
577
722
  getPaletteCssVars,
723
+ getPrivateAssetUrl,
724
+ getPrivateTransformUrl,
578
725
  getSignedTransformUrl,
579
726
  getTenantId,
580
727
  getTextColorForBackground,
@@ -592,6 +739,7 @@ function getAssetDimensions(asset) {
592
739
  serializeTransform,
593
740
  setCdnBase,
594
741
  setTenantId,
742
+ signAccessUrl,
595
743
  signTransformUrl
596
744
  });
597
745
  //# sourceMappingURL=index.cjs.map