@nitida/asset-client 0.17.0 → 0.18.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
@@ -66,6 +66,32 @@ now emits only the 1-char vocabulary, and `hasPreset` strips multi-char tokens b
66
66
  either half covers you — a hand-rolled `includes` covers neither. **`mp3` is a deliberate multi-char
67
67
  token that stays in the vocabulary**, so a naive test also reads its `m` as `md`.
68
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
+
69
95
  ## The endpoint
70
96
 
71
97
  The API this client's URLs belong to is `https://api.nitida.gofuture.space`. An older, longer
package/README.md CHANGED
@@ -44,7 +44,7 @@ getTransformSrcSet({ sha }, [640, 960, 1280], { format: "webp" });
44
44
  ```
45
45
 
46
46
  ⚠️ **Widths must be on the unsigned ladder** `TRANSFORM_WIDTHS`
47
- (`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
48
48
  is **HTTP 400** at the edge — it is a DoS guard, not a bug. Import the `TransformWidth` type and an
49
49
  off-ladder number becomes a compile error instead of a runtime 400.
50
50
 
@@ -62,7 +62,10 @@ getAssetUrl({ sha }, "video"); // → https://8ok.uk/<tenantId base36>/v/<sha1
62
62
  `getAssetUrl` build the path.
63
63
 
64
64
  ⚠️ **Never `getVideoTransformUrl` for a stored video.** That builds a `/t/…` transform URL, which
65
- **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")`.
66
69
 
67
70
  ## The `original` variant
68
71
 
@@ -119,7 +122,7 @@ shape. `presets` is populated everywhere, on every version — so read existence
119
122
  | Symptom | Cause |
120
123
  |---|---|
121
124
  | **400** on an image URL | width not on `TRANSFORM_WIDTHS` |
122
- | **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")` |
123
126
  | **404** on a video URL | decimal tenant prefix instead of base36 |
124
127
  | `original` 404s | no `mime` passed, or the stored ext came from the filename (`-o.jpg` vs `-o.jpeg`), or it was never written |
125
128
 
package/dist/index.cjs CHANGED
@@ -25,10 +25,14 @@ __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,
30
+ assertSha: () => assertSha,
28
31
  bestTextContrast: () => bestTextContrast,
29
32
  computeVariantDimensions: () => computeVariantDimensions,
30
33
  configureSlotResolver: () => configureSlotResolver,
31
34
  contrastRatio: () => contrastRatio,
35
+ deriveAccessKey: () => deriveAccessKey,
32
36
  extractAssetSha: () => extractAssetSha,
33
37
  getAmbientGradient: () => getAmbientGradient,
34
38
  getAssetDimensions: () => getAssetDimensions,
@@ -39,6 +43,8 @@ __export(index_exports, {
39
43
  getHlsStreamingUrl: () => getHlsStreamingUrl,
40
44
  getPaletteBlurBackground: () => getPaletteBlurBackground,
41
45
  getPaletteCssVars: () => getPaletteCssVars,
46
+ getPrivateAssetUrl: () => getPrivateAssetUrl,
47
+ getPrivateTransformUrl: () => getPrivateTransformUrl,
42
48
  getSignedTransformUrl: () => getSignedTransformUrl,
43
49
  getTenantId: () => getTenantId,
44
50
  getTextColorForBackground: () => getTextColorForBackground,
@@ -56,10 +62,89 @@ __export(index_exports, {
56
62
  serializeTransform: () => serializeTransform,
57
63
  setCdnBase: () => setCdnBase,
58
64
  setTenantId: () => setTenantId,
65
+ signAccessUrl: () => signAccessUrl,
59
66
  signTransformUrl: () => signTransformUrl
60
67
  });
61
68
  module.exports = __toCommonJS(index_exports);
62
69
 
70
+ // src/access.ts
71
+ var ACCESS_KEY_INFO = "nitida/access/v1";
72
+ async function hmac(key, message) {
73
+ const cryptoKey = await crypto.subtle.importKey(
74
+ "raw",
75
+ key,
76
+ { name: "HMAC", hash: "SHA-256" },
77
+ false,
78
+ ["sign"]
79
+ );
80
+ return new Uint8Array(
81
+ await crypto.subtle.sign(
82
+ "HMAC",
83
+ cryptoKey,
84
+ new TextEncoder().encode(message)
85
+ )
86
+ );
87
+ }
88
+ var toHex = (b) => [...b].map((x) => x.toString(16).padStart(2, "0")).join("");
89
+ async function deriveAccessKey(signingKey) {
90
+ return hmac(new TextEncoder().encode(signingKey), ACCESS_KEY_INFO);
91
+ }
92
+ function accessMessage(tenantPrefix, exp, resourcePath) {
93
+ return `${tenantPrefix}
94
+ ${exp}
95
+ ${resourcePath.replace(/^\/+/, "")}`;
96
+ }
97
+ async function signAccessUrl(publicUrl, signingKey, opts) {
98
+ if (!Number.isFinite(opts.expiresInSeconds) || opts.expiresInSeconds <= 0) {
99
+ throw new Error(
100
+ "signAccessUrl: `expiresInSeconds` must be a positive number \u2014 a signed URL without an expiry is a public URL the moment it is forwarded."
101
+ );
102
+ }
103
+ const u = new URL(publicUrl);
104
+ const segments = u.pathname.split("/").filter(Boolean);
105
+ if (segments[0] === "a" && segments[2] && /^[vrt]$/.test(segments[2])) {
106
+ segments.shift();
107
+ }
108
+ const tenantPrefix = segments.shift();
109
+ if (!tenantPrefix || segments.length === 0) {
110
+ throw new Error(
111
+ `signAccessUrl: expected a tenant-prefixed CDN path like /<tenant>/v/<sha>-<preset>.<ext>, got ${u.pathname}`
112
+ );
113
+ }
114
+ if (!/^[vrt]$/.test(segments[0])) {
115
+ throw new Error(
116
+ `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}.`)
117
+ );
118
+ }
119
+ const resourcePath = segments.join("/");
120
+ const now = opts.nowSeconds ?? Math.floor(Date.now() / 1e3);
121
+ const exp = now + Math.floor(opts.expiresInSeconds);
122
+ const sig = toHex(
123
+ await hmac(
124
+ await deriveAccessKey(signingKey),
125
+ accessMessage(tenantPrefix, exp, resourcePath)
126
+ )
127
+ );
128
+ u.pathname = `/a/${tenantPrefix}/${resourcePath}`;
129
+ u.searchParams.set("exp", String(exp));
130
+ u.searchParams.set("sig", sig);
131
+ return u.toString();
132
+ }
133
+ function assertPublic(asset, fn, escape) {
134
+ if (asset.visibility !== "private") return;
135
+ throw new Error(
136
+ `${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.`
137
+ );
138
+ }
139
+ function assertSha(asset, fn) {
140
+ const sha = asset?.sha;
141
+ if (typeof sha === "string" && /^[0-9a-f]{16,64}$/i.test(sha)) return;
142
+ const hint = asset && typeof asset === "object" && "sha256" in asset ? " The value you passed has `sha256` but not `sha` \u2014 that is the shape `upload()` returns. Use `{ sha: result.sha256.slice(0, 16) }`, or fetch the DTO with `assets.get(id)`." : ` Got ${JSON.stringify(sha)}.`;
143
+ throw new Error(
144
+ `${fn}: no usable \`sha\` on the value you passed, so the URL would contain "undefined" and 404 somewhere else.${hint}`
145
+ );
146
+ }
147
+
63
148
  // src/palette.ts
64
149
  function resolveSwatch(palette, ...keys) {
65
150
  if (!palette) return null;
@@ -77,9 +162,9 @@ function pickAmbientBackground(palette) {
77
162
  function getAmbientGradient(palette, opts = {}) {
78
163
  if (!palette) return void 0;
79
164
  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})`;
165
+ const toHex2 = palette[opts.to ?? "m"] ?? palette.dm ?? palette.d;
166
+ if (!fromHex || !toHex2) return void 0;
167
+ return `linear-gradient(${opts.angle ?? "135deg"}, ${fromHex}, ${toHex2})`;
83
168
  }
84
169
  function getTextColorForBackground(swatch) {
85
170
  if (!swatch) return "#000000";
@@ -164,102 +249,6 @@ function getPaletteBlurBackground(palette) {
164
249
  return layers.length > 0 ? `${layers.join(", ")}, ${base}` : base;
165
250
  }
166
251
 
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
252
  // src/transform.ts
264
253
  var TRANSFORM_WIDTHS = [
265
254
  96,
@@ -331,12 +320,24 @@ function extForOptions(opts) {
331
320
  }
332
321
  }
333
322
  function getVideoTransformUrl(asset, opts) {
323
+ assertSha(asset, "getVideoTransformUrl");
324
+ assertPublic(
325
+ asset,
326
+ "getVideoTransformUrl",
327
+ "getPrivateTransformUrl(asset, opts, signingKey, { expiresInSeconds: 300 })"
328
+ );
334
329
  const dsl = serializeTransform(opts);
335
330
  if (!dsl) return null;
336
331
  const ext = opts.format === "webm" ? "webm" : "mp4";
337
332
  return `${getCdnBase()}/t/${dsl}/${asset.sha}.${ext}`;
338
333
  }
339
334
  function getHlsStreamingUrl(asset, opts = {}) {
335
+ assertSha(asset, "getHlsStreamingUrl");
336
+ assertPublic(
337
+ asset,
338
+ "getHlsStreamingUrl",
339
+ 'getPrivateAssetUrl(asset, "hls", signingKey, { expiresInSeconds: 300 }) \u2014 the worker re-signs the playlist children'
340
+ );
340
341
  const merged = { ...opts, format: "hls" };
341
342
  const dsl = serializeTransform(merged);
342
343
  return `${getCdnBase()}/t/${dsl}/${asset.sha}.m3u8`;
@@ -348,9 +349,21 @@ function buildTransformUrl(asset, opts) {
348
349
  return `${getCdnBase()}/t/${dsl}/${asset.sha}.${ext}`;
349
350
  }
350
351
  function getTransformUrl(asset, opts) {
352
+ assertSha(asset, "getTransformUrl");
353
+ assertPublic(
354
+ asset,
355
+ "getTransformUrl",
356
+ "getPrivateTransformUrl(asset, opts, signingKey, { expiresInSeconds: 300 })"
357
+ );
351
358
  return buildTransformUrl(asset, opts);
352
359
  }
353
360
  function getSignedTransformUrl(asset, opts, signingKey) {
361
+ assertSha(asset, "getSignedTransformUrl");
362
+ assertPublic(
363
+ asset,
364
+ "getSignedTransformUrl",
365
+ "getPrivateTransformUrl(asset, opts, signingKey, { expiresInSeconds: 300 })"
366
+ );
354
367
  const url = buildTransformUrl(asset, opts);
355
368
  if (!url) return null;
356
369
  return signTransformUrl(url, signingKey);
@@ -381,12 +394,114 @@ async function hmacSha256Hex(key, message) {
381
394
  return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
382
395
  }
383
396
  function getTransformSrcSet(asset, widths, extraOpts = {}) {
397
+ assertSha(asset, "getTransformSrcSet");
398
+ assertPublic(
399
+ asset,
400
+ "getTransformSrcSet",
401
+ "getPrivateTransformUrl(asset, opts, signingKey, { expiresInSeconds: 300 }) per width"
402
+ );
384
403
  return widths.map((w) => {
385
404
  const url = buildTransformUrl(asset, { ...extraOpts, width: w });
386
405
  return url ? `${url} ${w}w` : null;
387
406
  }).filter((s) => s != null).join(", ");
388
407
  }
389
408
 
409
+ // src/slots.ts
410
+ var DEFAULT_TTL_MS = 6e4;
411
+ var cache = /* @__PURE__ */ new Map();
412
+ var endpoint = "https://api.nitida.gofuture.space";
413
+ var apiKey = null;
414
+ var tenantCode = null;
415
+ function configureSlotResolver(opts) {
416
+ if (opts.endpoint) endpoint = opts.endpoint.replace(/\/+$/, "");
417
+ if (opts.apiKey !== void 0) apiKey = opts.apiKey;
418
+ if (opts.tenantCode !== void 0) tenantCode = opts.tenantCode;
419
+ }
420
+ function invalidateSlotCache(slotKey) {
421
+ if (slotKey === void 0) cache.clear();
422
+ else
423
+ for (const k of cache.keys())
424
+ if (k.endsWith(`:${slotKey}`)) cache.delete(k);
425
+ }
426
+ var baseHeaders = () => {
427
+ const h = {};
428
+ if (apiKey) h.Authorization = `Bearer ${apiKey}`;
429
+ if (tenantCode) h["X-Tenant-Code"] = tenantCode;
430
+ return h;
431
+ };
432
+ async function fetchSlot(slotKey) {
433
+ const r = await fetch(`${endpoint}/slots/${encodeURIComponent(slotKey)}`, {
434
+ headers: baseHeaders()
435
+ });
436
+ if (r.status === 404) return null;
437
+ if (!r.ok) throw new Error(`slot fetch ${r.status}: ${await r.text()}`);
438
+ return await r.json();
439
+ }
440
+ async function fetchSlotsBulk(slotKeys) {
441
+ if (slotKeys.length === 0) return {};
442
+ const r = await fetch(`${endpoint}/slots/resolve`, {
443
+ method: "POST",
444
+ headers: { ...baseHeaders(), "Content-Type": "application/json" },
445
+ body: JSON.stringify({ keys: slotKeys })
446
+ });
447
+ if (!r.ok) throw new Error(`slots resolve ${r.status}: ${await r.text()}`);
448
+ const body = await r.json();
449
+ return body.resolved;
450
+ }
451
+ async function resolveSlot(slotKey, opts = {}) {
452
+ const ttl = opts.ttlMs ?? DEFAULT_TTL_MS;
453
+ const cacheKey = `${tenantCode ?? "_"}:${slotKey}`;
454
+ const now = Date.now();
455
+ let dto;
456
+ const hit = cache.get(cacheKey);
457
+ if (hit && now - hit.fetchedAt < ttl) {
458
+ dto = hit.value;
459
+ } else {
460
+ dto = await fetchSlot(slotKey);
461
+ cache.set(cacheKey, { fetchedAt: now, value: dto });
462
+ }
463
+ return materializeResolution(dto, opts.preset);
464
+ }
465
+ async function resolveSlots(slotKeys, opts = {}) {
466
+ if (slotKeys.length === 0) return {};
467
+ const ttl = opts.ttlMs ?? DEFAULT_TTL_MS;
468
+ const now = Date.now();
469
+ const missing = [];
470
+ const out = {};
471
+ for (const k of slotKeys) {
472
+ const cacheKey = `${tenantCode ?? "_"}:${k}`;
473
+ const hit = cache.get(cacheKey);
474
+ if (hit && now - hit.fetchedAt < ttl) {
475
+ out[k] = materializeResolution(hit.value, opts.preset);
476
+ } else {
477
+ missing.push(k);
478
+ }
479
+ }
480
+ if (missing.length > 0) {
481
+ const resolved = await fetchSlotsBulk(missing);
482
+ for (const k of missing) {
483
+ const dto = resolved[k] ?? null;
484
+ cache.set(`${tenantCode ?? "_"}:${k}`, { fetchedAt: now, value: dto });
485
+ out[k] = materializeResolution(dto, opts.preset);
486
+ }
487
+ }
488
+ return out;
489
+ }
490
+ function defaultPresetFor(asset) {
491
+ if (!asset) return "lg";
492
+ return asset.kind === "video" ? "video" : "lg";
493
+ }
494
+ function materializeResolution(dto, overridePreset) {
495
+ if (!dto) return { slot: null, preset: overridePreset ?? "lg", url: null };
496
+ const effective = overridePreset ?? dto.preset ?? defaultPresetFor(dto.asset);
497
+ const finalPreset = hasPreset(dto.asset, effective) ? effective : defaultPresetFor(dto.asset);
498
+ return {
499
+ slot: dto,
500
+ preset: finalPreset,
501
+ url: getAssetUrl(dto.asset, finalPreset)
502
+ };
503
+ }
504
+
390
505
  // src/index.ts
391
506
  var PRESET_SHORT = {
392
507
  thumb: "q",
@@ -513,6 +628,25 @@ function originalExtForMime(mime) {
513
628
  return (mime ? ORIGINAL_EXT_BY_MIME[mime] : void 0) ?? PRESET_EXT.original;
514
629
  }
515
630
  function getAssetUrl(asset, preset) {
631
+ assertSha(asset, "getAssetUrl");
632
+ assertPublic(
633
+ asset,
634
+ "getAssetUrl",
635
+ `getPrivateAssetUrl(asset, "${preset}", signingKey, { expiresInSeconds: 300 })`
636
+ );
637
+ const fallback = transformFallbackFor(asset, preset);
638
+ if (fallback) return fallback;
639
+ return buildPublicAssetUrl(asset, preset);
640
+ }
641
+ function transformFallbackFor(asset, preset) {
642
+ if (typeof asset.presets !== "string") return null;
643
+ if (hasPreset({ presets: asset.presets }, preset)) return null;
644
+ const maxDim = PRESET_MAX_DIM[preset];
645
+ if (maxDim == null) return null;
646
+ return `${cdnBaseUrl}/t/format=webp,width=${maxDim}/${asset.sha}.webp`;
647
+ }
648
+ function buildPublicAssetUrl(asset, preset) {
649
+ assertSha(asset, "getPrivateAssetUrl");
516
650
  if (preset === "original") {
517
651
  const stored = asset.variants?.find((v) => v.preset === "original")?.url;
518
652
  if (stored) return stored;
@@ -526,6 +660,25 @@ function getAssetUrl(asset, preset) {
526
660
  }
527
661
  return `${cdnBaseUrl}/${variantPrefix()}${asset.sha}-${PRESET_SHORT[preset]}.${PRESET_EXT[preset]}`;
528
662
  }
663
+ async function getPrivateAssetUrl(asset, preset, signingKey, opts) {
664
+ return signAccessUrl(buildPublicAssetUrl(asset, preset), signingKey, opts);
665
+ }
666
+ async function getPrivateTransformUrl(asset, opts, signingKey, signOpts) {
667
+ const url = buildTransformUrl(asset, opts);
668
+ if (!url) return null;
669
+ const tid = getTenantId();
670
+ if (tid == null) {
671
+ throw new Error(
672
+ "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."
673
+ );
674
+ }
675
+ const u = new URL(url);
676
+ return signAccessUrl(
677
+ `${u.origin}/${tid.toString(36)}${u.pathname}`,
678
+ signingKey,
679
+ signOpts
680
+ );
681
+ }
529
682
  function hasPreset(asset, preset) {
530
683
  if (preset === "mp3") return asset.presets.includes("mp3");
531
684
  return stripMultiCharTokens(asset.presets).includes(PRESET_SHORT[preset]);
@@ -535,6 +688,12 @@ function stripMultiCharTokens(presets) {
535
688
  }
536
689
  var IMAGE_PRESETS = ["thumb", "sm", "md", "lg", "xl"];
537
690
  function getAssetSrcSet(asset) {
691
+ assertSha(asset, "getAssetSrcSet");
692
+ assertPublic(
693
+ asset,
694
+ "getAssetSrcSet",
695
+ "getPrivateAssetUrl(asset, preset, signingKey, { expiresInSeconds: 300 }) per preset"
696
+ );
538
697
  return IMAGE_PRESETS.filter(
539
698
  (p) => hasPreset(asset, p) && PRESET_MAX_DIM[p] != null
540
699
  ).map((p) => `${getAssetUrl(asset, p)} ${PRESET_MAX_DIM[p]}w`).join(", ");
@@ -561,10 +720,14 @@ function getAssetDimensions(asset) {
561
720
  PRESET_MAX_DIM,
562
721
  PRESET_SHORT,
563
722
  TRANSFORM_WIDTHS,
723
+ accessMessage,
724
+ assertPublic,
725
+ assertSha,
564
726
  bestTextContrast,
565
727
  computeVariantDimensions,
566
728
  configureSlotResolver,
567
729
  contrastRatio,
730
+ deriveAccessKey,
568
731
  extractAssetSha,
569
732
  getAmbientGradient,
570
733
  getAssetDimensions,
@@ -575,6 +738,8 @@ function getAssetDimensions(asset) {
575
738
  getHlsStreamingUrl,
576
739
  getPaletteBlurBackground,
577
740
  getPaletteCssVars,
741
+ getPrivateAssetUrl,
742
+ getPrivateTransformUrl,
578
743
  getSignedTransformUrl,
579
744
  getTenantId,
580
745
  getTextColorForBackground,
@@ -592,6 +757,7 @@ function getAssetDimensions(asset) {
592
757
  serializeTransform,
593
758
  setCdnBase,
594
759
  setTenantId,
760
+ signAccessUrl,
595
761
  signTransformUrl
596
762
  });
597
763
  //# sourceMappingURL=index.cjs.map