@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/dist/index.d.ts CHANGED
@@ -1,3 +1,114 @@
1
+ /**
2
+ * Signed URLs for PRIVATE assets — the `/a/{tenant}/…?exp&sig` tree.
3
+ *
4
+ * ## Who calls this, and who must not
5
+ *
6
+ * The tenant's BACKEND, which knows who the viewer is and holds the signing
7
+ * key. Never a browser: shipping the signing key to the client would let any
8
+ * visitor mint URLs for any private asset of that tenant, which is the whole
9
+ * property the tree exists to provide. This module is deliberately importable
10
+ * from anywhere — it runs on WebCrypto, so browsers, Node, Bun and Workers all
11
+ * work — and that convenience is exactly why the warning is here rather than
12
+ * in a doc nobody reads at the call site.
13
+ *
14
+ * ## Not the same signature as `signTransformUrl`
15
+ *
16
+ * `signTransformUrl` vouches for a WIDTH; this vouches for a VIEWER, until
17
+ * `exp`. They come from the same `signing_key` but not the same key material:
18
+ * the access key is derived (`HMAC(signing_key, "nitida/access/v1")`) so that
19
+ * no crafted transform path can be replayed as an access signature. The full
20
+ * argument lives beside the server implementation in `access-signing.ts`; the
21
+ * short version is that a shared payload with a prefix separator IS
22
+ * collidable, because both fields of the transform message are
23
+ * attacker-influenced path segments.
24
+ *
25
+ * ## `exp` is mandatory
26
+ *
27
+ * A signed URL that never expires is a public URL as soon as someone forwards
28
+ * it. There is no "no expiry" option here, and there will not be one.
29
+ */
30
+ /**
31
+ * The per-tenant access key, derived from `signing_key`. Same value the origin
32
+ * computes and hands the edge — nothing needs to be stored or synchronised.
33
+ */
34
+ declare function deriveAccessKey(signingKey: string): Promise<Uint8Array>;
35
+ /**
36
+ * The signed message. Byte-identical to the server's `accessMessage` and the
37
+ * worker's — three implementations of one string, which is why all three pin
38
+ * the exact bytes in a test.
39
+ *
40
+ * `tenantPrefix` is the base36 segment as it appears in the URL, not the
41
+ * decimal id: tenant 10 lives at `/a/a/`, and the thing being vouched for is a
42
+ * path.
43
+ */
44
+ declare function accessMessage(tenantPrefix: string, exp: number, resourcePath: string): string;
45
+ type SignAccessOptions = {
46
+ /** Lifetime in seconds. Required — see the header. */
47
+ expiresInSeconds: number;
48
+ /** Injectable clock, for tests that need a URL already dead on arrival. */
49
+ nowSeconds?: number;
50
+ };
51
+ /**
52
+ * Turn a PUBLIC-tree URL into a signed PRIVATE-tree URL.
53
+ *
54
+ * https://8ok.uk/5/v/<sha16>-lg.webp
55
+ * → https://8ok.uk/a/5/v/<sha16>-lg.webp?exp=…&sig=…
56
+ *
57
+ * Accepts a URL that is already under `/a/` and re-signs it, so calling twice
58
+ * is not an error and does not produce `/a/a/`.
59
+ */
60
+ declare function signAccessUrl(publicUrl: string, signingKey: string, opts: SignAccessOptions): Promise<string>;
61
+ /** What every URL builder accepts so it can refuse a doomed URL. */
62
+ type VisibilityHint = {
63
+ visibility?: "public" | "private";
64
+ };
65
+ /**
66
+ * Refuse to build a public URL for a private asset.
67
+ *
68
+ * Doctrine of the house: **a silence reads as "you can't"**. Returning
69
+ * `https://8ok.uk/5/v/<sha>-lg.webp` for a private asset is not a smaller
70
+ * failure than throwing — it is a URL that answers 404, in a platform where a
71
+ * 404 has always meant "that file does not exist". The caller then debugs the
72
+ * wrong thing.
73
+ *
74
+ * Only refuses when it was actually TOLD. A caller passing `{ sha }` carries no
75
+ * visibility, and guessing would break every existing call site to protect
76
+ * assets that are not there.
77
+ */
78
+ declare function assertPublic(asset: VisibilityHint, fn: string,
79
+ /**
80
+ * The call to make instead — declared per call site, not guessed.
81
+ *
82
+ * It matters which one: `getPrivateAssetUrl` signs a STORED preset, and
83
+ * pointing a transform caller at it sends them to a function that cannot do
84
+ * what they asked for. The first version of this message named
85
+ * `getPrivateAssetUrl` for all seven builders; a test caught it.
86
+ */
87
+ escape: string): void;
88
+ /**
89
+ * Refuse a value whose `sha` is missing or malformed, instead of interpolating
90
+ * it into a URL.
91
+ *
92
+ * ## Found by a Haiku agent, 2026-08-23
93
+ *
94
+ * It did the most natural thing there is — passed the result of `upload()`
95
+ * straight to `transform()` — and got:
96
+ *
97
+ * https://8ok.uk/t/width=1280/undefined.webp
98
+ *
99
+ * `UploadResult` carries `sha256`; every URL builder wants `sha`. TypeScript
100
+ * catches the mismatch, but an agent running through `bun` (or anyone in plain
101
+ * JS) sees no error at all: just a 200-shaped URL with the word `undefined` in
102
+ * it, which 404s later and somewhere else.
103
+ *
104
+ * The house rule applies exactly as it does to private assets: **a silence
105
+ * reads as "you can't"**. A builder that cannot name the asset must say so at
106
+ * the call site, not hand back a string that will fail far from here.
107
+ */
108
+ declare function assertSha(asset: {
109
+ sha?: unknown;
110
+ }, fn: string): void;
111
+
1
112
  /**
2
113
  * Color palette helpers — render harmonious ambient backgrounds behind
3
114
  * product images, inspired by Spotify Now Playing / Apple Music / Pico.
@@ -92,83 +203,6 @@ declare function iteratePaletteSwatches(palette: AssetPalette | null | undefined
92
203
  */
93
204
  declare function getPaletteBlurBackground(palette: AssetPalette | null | undefined): string | undefined;
94
205
 
95
- /**
96
- * @nitida/asset-client/slots — slot resolver for tenant-named assets.
97
- *
98
- * Slots give tenants a way to attach stable, human-readable names
99
- * ("webapp.wizard.pool-type.icon-1", "storefront.cr.hero-video.landscape_hd_16x9.mp4")
100
- * to assets they uploaded. Consumers resolve names → AssetDTOs at
101
- * build / runtime so their source never hardcodes a CDN URL; an
102
- * admin rebinds a slot in the platform console and every consumer
103
- * picks up the swap on cache refresh.
104
- *
105
- * Two layers in this package:
106
- * - `resolveSlot` / `resolveSlots` — universal (server, edge,
107
- * workers) fetch helpers. Cache 60s by default.
108
- * - React hooks live in `@nitida/asset-client/react/use-slot`
109
- * (kept out of this module so the SSR-safe core stays
110
- * dependency-free of react).
111
- */
112
-
113
- type SlotDTO = {
114
- slotKey: string;
115
- /** Preset hint set when the slot was bound (e.g. `thumb` for icon slots). */
116
- preset: VariantPreset | null;
117
- description: string | null;
118
- updatedAt: string;
119
- asset: AssetDTO;
120
- };
121
- type SlotResolution = {
122
- /** The resolved DTO (`null` when the slot is unbound or asset missing). */
123
- slot: SlotDTO | null;
124
- /**
125
- * Effective preset — what `url` below was built with. Resolution order:
126
- * 1. caller's `preset` override
127
- * 2. slot's `preset` hint
128
- * 3. `lg` for images, `video` for video kind
129
- */
130
- preset: VariantPreset;
131
- /** The CDN URL the consumer should use. */
132
- url: string | null;
133
- };
134
- /**
135
- * Configure the resolver process-wide. Call once at boot from your
136
- * storefront layout / server entry / worker init.
137
- *
138
- * configureSlotResolver({
139
- * endpoint: process.env.AQUIENPZ_URL,
140
- * apiKey: process.env.AQUIENPZ_API_KEY, // amk_rt_* — server-only
141
- * tenantCode: "acme-co",
142
- * });
143
- */
144
- declare function configureSlotResolver(opts: {
145
- endpoint?: string;
146
- apiKey?: string;
147
- tenantCode?: string;
148
- }): void;
149
- /** Wipe the in-process cache (test helper or forced refresh). */
150
- declare function invalidateSlotCache(slotKey?: string): void;
151
- type ResolveSlotOptions = {
152
- /** Override preset (caller knows the use case better than the slot binding). */
153
- preset?: VariantPreset;
154
- /** TTL for the in-process cache. Default 60s. Set 0 to bypass. */
155
- ttlMs?: number;
156
- };
157
- /**
158
- * Resolve a single slot to a CDN URL. Returns `{slot: null, url: null}`
159
- * when the slot is unbound — callers fall back to a placeholder.
160
- *
161
- * Cached for `ttlMs` (default 60s). Slot rebindings propagate within the
162
- * TTL window without an app restart.
163
- */
164
- declare function resolveSlot(slotKey: string, opts?: ResolveSlotOptions): Promise<SlotResolution>;
165
- /**
166
- * Bulk-resolve N slot keys in one round-trip. The SDK's `useSlots`
167
- * React hook calls this so every storefront header (logo + tagline +
168
- * nav cover + …) loads as one request.
169
- */
170
- declare function resolveSlots(slotKeys: string[], opts?: ResolveSlotOptions): Promise<Record<string, SlotResolution>>;
171
-
172
206
  /**
173
207
  * On-the-fly transform URL builder.
174
208
  *
@@ -309,7 +343,7 @@ declare function serializeTransform(opts: SignedTransformOptions): string;
309
343
  * <video src={aq.transformVideo(asset, { width: 1080, height: 1920 })}
310
344
  * autoPlay muted loop playsInline />
311
345
  */
312
- declare function getVideoTransformUrl(asset: Pick<AssetDTO, "sha">, opts: TransformOptions): string | null;
346
+ declare function getVideoTransformUrl(asset: Pick<AssetDTO, "sha"> & VisibilityHint, opts: TransformOptions): string | null;
313
347
  /**
314
348
  * Build an HLS streaming URL for a VIDEO asset (Phase 5). Returns the
315
349
  * master.m3u8 entry point — HLS-aware players (Video.js's
@@ -368,8 +402,9 @@ declare function getVideoTransformUrl(asset: Pick<AssetDTO, "sha">, opts: Transf
368
402
  * on demand after the raw is unavailable it reads the `-v.mp4`, which is
369
403
  * capped at 1920 wide. `getAssetUrl(sha, "video")` is always <= 1080p.
370
404
  */
371
- declare function getHlsStreamingUrl(asset: Pick<AssetDTO, "sha">, opts?: Omit<TransformOptions, "format">): string;
372
- declare function getTransformUrl(asset: Pick<AssetDTO, "sha">, opts: TransformOptions): string | null;
405
+ declare function getHlsStreamingUrl(asset: Pick<AssetDTO, "sha"> & VisibilityHint, opts?: Omit<TransformOptions, "format">): string;
406
+
407
+ declare function getTransformUrl(asset: Pick<AssetDTO, "sha"> & VisibilityHint, opts: TransformOptions): string | null;
373
408
  /**
374
409
  * Build AND sign a transform URL, allowing an off-ladder custom `width`.
375
410
  *
@@ -381,7 +416,7 @@ declare function getTransformUrl(asset: Pick<AssetDTO, "sha">, opts: TransformOp
381
416
  * Returns `null` only when `opts` serialize to an empty DSL (no transform
382
417
  * requested) — same contract as {@link getTransformUrl}.
383
418
  */
384
- declare function getSignedTransformUrl(asset: Pick<AssetDTO, "sha">, opts: SignedTransformOptions, signingKey: string): Promise<string> | null;
419
+ declare function getSignedTransformUrl(asset: Pick<AssetDTO, "sha"> & VisibilityHint, opts: SignedTransformOptions, signingKey: string): Promise<string> | null;
385
420
  /**
386
421
  * Sign a transform URL with the tenant's HMAC signing key. Appends
387
422
  * `?sig=<hex>` where hex = HMAC-SHA256(signingKey, `<canonical-DSL>/<filename>`).
@@ -404,7 +439,84 @@ declare function signTransformUrl(unsignedUrl: string, signingKey: string): Prom
404
439
  * sizes="(max-width: 768px) 100vw, 50vw"
405
440
  * />
406
441
  */
407
- declare function getTransformSrcSet(asset: Pick<AssetDTO, "sha">, widths: number[], extraOpts?: Omit<TransformOptions, "width">): string;
442
+ declare function getTransformSrcSet(asset: Pick<AssetDTO, "sha"> & VisibilityHint, widths: number[], extraOpts?: Omit<TransformOptions, "width">): string;
443
+
444
+ /**
445
+ * @nitida/asset-client/slots — slot resolver for tenant-named assets.
446
+ *
447
+ * Slots give tenants a way to attach stable, human-readable names
448
+ * ("webapp.wizard.pool-type.icon-1", "storefront.cr.hero-video.landscape_hd_16x9.mp4")
449
+ * to assets they uploaded. Consumers resolve names → AssetDTOs at
450
+ * build / runtime so their source never hardcodes a CDN URL; an
451
+ * admin rebinds a slot in the platform console and every consumer
452
+ * picks up the swap on cache refresh.
453
+ *
454
+ * Two layers in this package:
455
+ * - `resolveSlot` / `resolveSlots` — universal (server, edge,
456
+ * workers) fetch helpers. Cache 60s by default.
457
+ * - React hooks live in `@nitida/asset-client/react/use-slot`
458
+ * (kept out of this module so the SSR-safe core stays
459
+ * dependency-free of react).
460
+ */
461
+
462
+ type SlotDTO = {
463
+ slotKey: string;
464
+ /** Preset hint set when the slot was bound (e.g. `thumb` for icon slots). */
465
+ preset: VariantPreset | null;
466
+ description: string | null;
467
+ updatedAt: string;
468
+ asset: AssetDTO;
469
+ };
470
+ type SlotResolution = {
471
+ /** The resolved DTO (`null` when the slot is unbound or asset missing). */
472
+ slot: SlotDTO | null;
473
+ /**
474
+ * Effective preset — what `url` below was built with. Resolution order:
475
+ * 1. caller's `preset` override
476
+ * 2. slot's `preset` hint
477
+ * 3. `lg` for images, `video` for video kind
478
+ */
479
+ preset: VariantPreset;
480
+ /** The CDN URL the consumer should use. */
481
+ url: string | null;
482
+ };
483
+ /**
484
+ * Configure the resolver process-wide. Call once at boot from your
485
+ * storefront layout / server entry / worker init.
486
+ *
487
+ * configureSlotResolver({
488
+ * endpoint: process.env.AQUIENPZ_URL,
489
+ * apiKey: process.env.AQUIENPZ_API_KEY, // amk_rt_* — server-only
490
+ * tenantCode: "acme-co",
491
+ * });
492
+ */
493
+ declare function configureSlotResolver(opts: {
494
+ endpoint?: string;
495
+ apiKey?: string;
496
+ tenantCode?: string;
497
+ }): void;
498
+ /** Wipe the in-process cache (test helper or forced refresh). */
499
+ declare function invalidateSlotCache(slotKey?: string): void;
500
+ type ResolveSlotOptions = {
501
+ /** Override preset (caller knows the use case better than the slot binding). */
502
+ preset?: VariantPreset;
503
+ /** TTL for the in-process cache. Default 60s. Set 0 to bypass. */
504
+ ttlMs?: number;
505
+ };
506
+ /**
507
+ * Resolve a single slot to a CDN URL. Returns `{slot: null, url: null}`
508
+ * when the slot is unbound — callers fall back to a placeholder.
509
+ *
510
+ * Cached for `ttlMs` (default 60s). Slot rebindings propagate within the
511
+ * TTL window without an app restart.
512
+ */
513
+ declare function resolveSlot(slotKey: string, opts?: ResolveSlotOptions): Promise<SlotResolution>;
514
+ /**
515
+ * Bulk-resolve N slot keys in one round-trip. The SDK's `useSlots`
516
+ * React hook calls this so every storefront header (logo + tagline +
517
+ * nav cover + …) loads as one request.
518
+ */
519
+ declare function resolveSlots(slotKeys: string[], opts?: ResolveSlotOptions): Promise<Record<string, SlotResolution>>;
408
520
 
409
521
  /**
410
522
  * @nitida/asset-client — read helpers for asset URLs.
@@ -626,6 +738,23 @@ type AssetDTO = {
626
738
  */
627
739
  presets: string;
628
740
  status: "processing" | "ready" | "failed";
741
+ /**
742
+ * Who may fetch the bytes.
743
+ *
744
+ * - `"public"` — the CDN serves it to anyone with the URL. The default,
745
+ * and what all 27 484 assets were until this field existed.
746
+ * - `"private"` — every public door answers **404**: the stored variants,
747
+ * the raw original, the HLS ladder and `/t/`. The bytes are reachable
748
+ * only through a signed URL under `/a/{tenant}/…?exp&sig`, which your
749
+ * BACKEND mints with {@link getPrivateAssetUrl}.
750
+ *
751
+ * Optional so an older server that does not send it is read as `"public"` —
752
+ * which is what such a server means.
753
+ *
754
+ * ⚠️ A 404 on a private asset is not a missing file. It is the feature
755
+ * working. See {@link getPrivateAssetUrl}.
756
+ */
757
+ visibility?: "public" | "private";
629
758
  /** Soft-delete timestamp (ISO). Hidden from catalog when set. */
630
759
  deletedAt?: string | null;
631
760
  /**
@@ -695,7 +824,59 @@ type OriginalHints = {
695
824
  * const url = hasPreset(asset, "thumb") ? getAssetUrl(asset, "thumb") : null;
696
825
  * ```
697
826
  */
698
- declare function getAssetUrl(asset: Pick<AssetDTO, "sha"> & OriginalHints, preset: VariantPreset): string;
827
+ declare function getAssetUrl(asset: Pick<AssetDTO, "sha"> & OriginalHints & VisibilityHint, preset: VariantPreset): string;
828
+ /**
829
+ * The signed URL for one preset of a PRIVATE asset — what every refusal above
830
+ * points at.
831
+ *
832
+ * ```ts
833
+ * // On your BACKEND, once you have decided this viewer may see it:
834
+ * const url = await getPrivateAssetUrl(asset, "lg", tenantSigningKey, {
835
+ * expiresInSeconds: 300,
836
+ * });
837
+ * ```
838
+ *
839
+ * It works on a public asset too — `/a/` is a different door onto the same
840
+ * object — but there is no reason to pay for it: a public URL is cacheable at
841
+ * the edge and costs nothing, a signed one is neither.
842
+ *
843
+ * ⚠️ **Backend only.** Handing the signing key to a browser lets any visitor
844
+ * mint URLs for every private asset the tenant owns, which is the whole
845
+ * property the private tree exists to provide.
846
+ *
847
+ * ⚠️ Needs {@link setTenantId} (or a `NitidaClient` with `tenantId`), like
848
+ * every variant URL builder: the tenant segment is base36 and part of what the
849
+ * signature covers, so a missing tenant does not produce a wrong URL — it
850
+ * produces an unsignable one.
851
+ */
852
+ declare function getPrivateAssetUrl(asset: Pick<AssetDTO, "sha"> & OriginalHints, preset: VariantPreset, signingKey: string, opts: SignAccessOptions): Promise<string>;
853
+ /**
854
+ * The signed URL for a TRANSFORM of a private asset — an arbitrary width, crop
855
+ * or format, not just the sizes that happen to be materialised.
856
+ *
857
+ * ```ts
858
+ * const url = await getPrivateTransformUrl(
859
+ * asset,
860
+ * { width: 1280, format: "webp" },
861
+ * tenantSigningKey,
862
+ * { expiresInSeconds: 300 },
863
+ * );
864
+ * // → https://8ok.uk/a/5/t/format=webp,width=1280/<sha>.webp?exp=…&sig=…
865
+ * ```
866
+ *
867
+ * Why this exists at all: a private asset that can only be served at the sizes
868
+ * someone already generated is barely a product. The signed tree mirrors the
869
+ * public one, transforms included.
870
+ *
871
+ * Returns `null` when `opts` serialize to an empty DSL — same contract as
872
+ * {@link getTransformUrl}, because "no transform requested" is not an error,
873
+ * it just means you wanted {@link getPrivateAssetUrl}.
874
+ *
875
+ * ⚠️ **Backend only**, like every signer here. And note the width is a plain
876
+ * `number`: a signed URL is a trusted caller, so the edge ladder does not
877
+ * apply — the same rule `getSignedTransformUrl` already follows.
878
+ */
879
+ declare function getPrivateTransformUrl(asset: Pick<AssetDTO, "sha">, opts: SignedTransformOptions, signingKey: string, signOpts: SignAccessOptions): Promise<string | null>;
699
880
  /**
700
881
  * Did the processor actually generate this preset?
701
882
  *
@@ -714,7 +895,7 @@ declare function getAssetUrl(asset: Pick<AssetDTO, "sha"> & OriginalHints, prese
714
895
  * ```
715
896
  */
716
897
  declare function hasPreset(asset: Pick<AssetDTO, "presets">, preset: VariantPreset): boolean;
717
- declare function getAssetSrcSet(asset: Pick<AssetDTO, "sha" | "presets">): string;
898
+ declare function getAssetSrcSet(asset: Pick<AssetDTO, "sha" | "presets"> & VisibilityHint): string;
718
899
  /**
719
900
  * Compute the dimensions a variant would have given the source asset's
720
901
  * width/height and the variant's bounding box. For thumbnails (square
@@ -731,4 +912,4 @@ declare function getAssetDimensions(asset: Pick<AssetDTO, "w" | "h">): {
731
912
  height: number;
732
913
  } | null;
733
914
 
734
- export { type AssetDTO, type AssetPalette, type AssetVariant, type HlsRung, PRESET_EXT, PRESET_LONG, PRESET_MAX_DIM, PRESET_SHORT, type PaletteSwatch, type RequestablePreset, type ResolveSlotOptions, type SignedTransformOptions, type SlotDTO, type SlotResolution, TRANSFORM_WIDTHS, type TransformEffect, type TransformFit, type TransformFormat, type TransformGravity, type TransformOptions, type TransformWidth, type VariantEntryPreset, type 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 };
915
+ export { type AssetDTO, type AssetPalette, type AssetVariant, type HlsRung, PRESET_EXT, PRESET_LONG, PRESET_MAX_DIM, PRESET_SHORT, type PaletteSwatch, type RequestablePreset, type ResolveSlotOptions, type SignAccessOptions, type SignedTransformOptions, type SlotDTO, type SlotResolution, TRANSFORM_WIDTHS, type TransformEffect, type TransformFit, type TransformFormat, type TransformGravity, type TransformOptions, type TransformWidth, type VariantEntryPreset, type VariantPreset, type 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 };