@nitida/asset-client 0.17.0 → 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/dist/index.d.cts CHANGED
@@ -1,3 +1,91 @@
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
+
1
89
  /**
2
90
  * Color palette helpers — render harmonious ambient backgrounds behind
3
91
  * product images, inspired by Spotify Now Playing / Apple Music / Pico.
@@ -92,83 +180,6 @@ declare function iteratePaletteSwatches(palette: AssetPalette | null | undefined
92
180
  */
93
181
  declare function getPaletteBlurBackground(palette: AssetPalette | null | undefined): string | undefined;
94
182
 
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
183
  /**
173
184
  * On-the-fly transform URL builder.
174
185
  *
@@ -309,7 +320,7 @@ declare function serializeTransform(opts: SignedTransformOptions): string;
309
320
  * <video src={aq.transformVideo(asset, { width: 1080, height: 1920 })}
310
321
  * autoPlay muted loop playsInline />
311
322
  */
312
- declare function getVideoTransformUrl(asset: Pick<AssetDTO, "sha">, opts: TransformOptions): string | null;
323
+ declare function getVideoTransformUrl(asset: Pick<AssetDTO, "sha"> & VisibilityHint, opts: TransformOptions): string | null;
313
324
  /**
314
325
  * Build an HLS streaming URL for a VIDEO asset (Phase 5). Returns the
315
326
  * master.m3u8 entry point — HLS-aware players (Video.js's
@@ -368,8 +379,9 @@ declare function getVideoTransformUrl(asset: Pick<AssetDTO, "sha">, opts: Transf
368
379
  * on demand after the raw is unavailable it reads the `-v.mp4`, which is
369
380
  * capped at 1920 wide. `getAssetUrl(sha, "video")` is always <= 1080p.
370
381
  */
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;
382
+ declare function getHlsStreamingUrl(asset: Pick<AssetDTO, "sha"> & VisibilityHint, opts?: Omit<TransformOptions, "format">): string;
383
+
384
+ declare function getTransformUrl(asset: Pick<AssetDTO, "sha"> & VisibilityHint, opts: TransformOptions): string | null;
373
385
  /**
374
386
  * Build AND sign a transform URL, allowing an off-ladder custom `width`.
375
387
  *
@@ -381,7 +393,7 @@ declare function getTransformUrl(asset: Pick<AssetDTO, "sha">, opts: TransformOp
381
393
  * Returns `null` only when `opts` serialize to an empty DSL (no transform
382
394
  * requested) — same contract as {@link getTransformUrl}.
383
395
  */
384
- declare function getSignedTransformUrl(asset: Pick<AssetDTO, "sha">, opts: SignedTransformOptions, signingKey: string): Promise<string> | null;
396
+ declare function getSignedTransformUrl(asset: Pick<AssetDTO, "sha"> & VisibilityHint, opts: SignedTransformOptions, signingKey: string): Promise<string> | null;
385
397
  /**
386
398
  * Sign a transform URL with the tenant's HMAC signing key. Appends
387
399
  * `?sig=<hex>` where hex = HMAC-SHA256(signingKey, `<canonical-DSL>/<filename>`).
@@ -404,7 +416,84 @@ declare function signTransformUrl(unsignedUrl: string, signingKey: string): Prom
404
416
  * sizes="(max-width: 768px) 100vw, 50vw"
405
417
  * />
406
418
  */
407
- declare function getTransformSrcSet(asset: Pick<AssetDTO, "sha">, widths: number[], extraOpts?: Omit<TransformOptions, "width">): string;
419
+ declare function getTransformSrcSet(asset: Pick<AssetDTO, "sha"> & VisibilityHint, widths: number[], extraOpts?: Omit<TransformOptions, "width">): string;
420
+
421
+ /**
422
+ * @nitida/asset-client/slots — slot resolver for tenant-named assets.
423
+ *
424
+ * Slots give tenants a way to attach stable, human-readable names
425
+ * ("webapp.wizard.pool-type.icon-1", "storefront.cr.hero-video.landscape_hd_16x9.mp4")
426
+ * to assets they uploaded. Consumers resolve names → AssetDTOs at
427
+ * build / runtime so their source never hardcodes a CDN URL; an
428
+ * admin rebinds a slot in the platform console and every consumer
429
+ * picks up the swap on cache refresh.
430
+ *
431
+ * Two layers in this package:
432
+ * - `resolveSlot` / `resolveSlots` — universal (server, edge,
433
+ * workers) fetch helpers. Cache 60s by default.
434
+ * - React hooks live in `@nitida/asset-client/react/use-slot`
435
+ * (kept out of this module so the SSR-safe core stays
436
+ * dependency-free of react).
437
+ */
438
+
439
+ type SlotDTO = {
440
+ slotKey: string;
441
+ /** Preset hint set when the slot was bound (e.g. `thumb` for icon slots). */
442
+ preset: VariantPreset | null;
443
+ description: string | null;
444
+ updatedAt: string;
445
+ asset: AssetDTO;
446
+ };
447
+ type SlotResolution = {
448
+ /** The resolved DTO (`null` when the slot is unbound or asset missing). */
449
+ slot: SlotDTO | null;
450
+ /**
451
+ * Effective preset — what `url` below was built with. Resolution order:
452
+ * 1. caller's `preset` override
453
+ * 2. slot's `preset` hint
454
+ * 3. `lg` for images, `video` for video kind
455
+ */
456
+ preset: VariantPreset;
457
+ /** The CDN URL the consumer should use. */
458
+ url: string | null;
459
+ };
460
+ /**
461
+ * Configure the resolver process-wide. Call once at boot from your
462
+ * storefront layout / server entry / worker init.
463
+ *
464
+ * configureSlotResolver({
465
+ * endpoint: process.env.AQUIENPZ_URL,
466
+ * apiKey: process.env.AQUIENPZ_API_KEY, // amk_rt_* — server-only
467
+ * tenantCode: "acme-co",
468
+ * });
469
+ */
470
+ declare function configureSlotResolver(opts: {
471
+ endpoint?: string;
472
+ apiKey?: string;
473
+ tenantCode?: string;
474
+ }): void;
475
+ /** Wipe the in-process cache (test helper or forced refresh). */
476
+ declare function invalidateSlotCache(slotKey?: string): void;
477
+ type ResolveSlotOptions = {
478
+ /** Override preset (caller knows the use case better than the slot binding). */
479
+ preset?: VariantPreset;
480
+ /** TTL for the in-process cache. Default 60s. Set 0 to bypass. */
481
+ ttlMs?: number;
482
+ };
483
+ /**
484
+ * Resolve a single slot to a CDN URL. Returns `{slot: null, url: null}`
485
+ * when the slot is unbound — callers fall back to a placeholder.
486
+ *
487
+ * Cached for `ttlMs` (default 60s). Slot rebindings propagate within the
488
+ * TTL window without an app restart.
489
+ */
490
+ declare function resolveSlot(slotKey: string, opts?: ResolveSlotOptions): Promise<SlotResolution>;
491
+ /**
492
+ * Bulk-resolve N slot keys in one round-trip. The SDK's `useSlots`
493
+ * React hook calls this so every storefront header (logo + tagline +
494
+ * nav cover + …) loads as one request.
495
+ */
496
+ declare function resolveSlots(slotKeys: string[], opts?: ResolveSlotOptions): Promise<Record<string, SlotResolution>>;
408
497
 
409
498
  /**
410
499
  * @nitida/asset-client — read helpers for asset URLs.
@@ -626,6 +715,23 @@ type AssetDTO = {
626
715
  */
627
716
  presets: string;
628
717
  status: "processing" | "ready" | "failed";
718
+ /**
719
+ * Who may fetch the bytes.
720
+ *
721
+ * - `"public"` — the CDN serves it to anyone with the URL. The default,
722
+ * and what all 27 484 assets were until this field existed.
723
+ * - `"private"` — every public door answers **404**: the stored variants,
724
+ * the raw original, the HLS ladder and `/t/`. The bytes are reachable
725
+ * only through a signed URL under `/a/{tenant}/…?exp&sig`, which your
726
+ * BACKEND mints with {@link getPrivateAssetUrl}.
727
+ *
728
+ * Optional so an older server that does not send it is read as `"public"` —
729
+ * which is what such a server means.
730
+ *
731
+ * ⚠️ A 404 on a private asset is not a missing file. It is the feature
732
+ * working. See {@link getPrivateAssetUrl}.
733
+ */
734
+ visibility?: "public" | "private";
629
735
  /** Soft-delete timestamp (ISO). Hidden from catalog when set. */
630
736
  deletedAt?: string | null;
631
737
  /**
@@ -695,7 +801,59 @@ type OriginalHints = {
695
801
  * const url = hasPreset(asset, "thumb") ? getAssetUrl(asset, "thumb") : null;
696
802
  * ```
697
803
  */
698
- declare function getAssetUrl(asset: Pick<AssetDTO, "sha"> & OriginalHints, preset: VariantPreset): string;
804
+ declare function getAssetUrl(asset: Pick<AssetDTO, "sha"> & OriginalHints & VisibilityHint, preset: VariantPreset): string;
805
+ /**
806
+ * The signed URL for one preset of a PRIVATE asset — what every refusal above
807
+ * points at.
808
+ *
809
+ * ```ts
810
+ * // On your BACKEND, once you have decided this viewer may see it:
811
+ * const url = await getPrivateAssetUrl(asset, "lg", tenantSigningKey, {
812
+ * expiresInSeconds: 300,
813
+ * });
814
+ * ```
815
+ *
816
+ * It works on a public asset too — `/a/` is a different door onto the same
817
+ * object — but there is no reason to pay for it: a public URL is cacheable at
818
+ * the edge and costs nothing, a signed one is neither.
819
+ *
820
+ * ⚠️ **Backend only.** Handing the signing key to a browser lets any visitor
821
+ * mint URLs for every private asset the tenant owns, which is the whole
822
+ * property the private tree exists to provide.
823
+ *
824
+ * ⚠️ Needs {@link setTenantId} (or a `NitidaClient` with `tenantId`), like
825
+ * every variant URL builder: the tenant segment is base36 and part of what the
826
+ * signature covers, so a missing tenant does not produce a wrong URL — it
827
+ * produces an unsignable one.
828
+ */
829
+ declare function getPrivateAssetUrl(asset: Pick<AssetDTO, "sha"> & OriginalHints, preset: VariantPreset, signingKey: string, opts: SignAccessOptions): Promise<string>;
830
+ /**
831
+ * The signed URL for a TRANSFORM of a private asset — an arbitrary width, crop
832
+ * or format, not just the sizes that happen to be materialised.
833
+ *
834
+ * ```ts
835
+ * const url = await getPrivateTransformUrl(
836
+ * asset,
837
+ * { width: 1280, format: "webp" },
838
+ * tenantSigningKey,
839
+ * { expiresInSeconds: 300 },
840
+ * );
841
+ * // → https://8ok.uk/a/5/t/format=webp,width=1280/<sha>.webp?exp=…&sig=…
842
+ * ```
843
+ *
844
+ * Why this exists at all: a private asset that can only be served at the sizes
845
+ * someone already generated is barely a product. The signed tree mirrors the
846
+ * public one, transforms included.
847
+ *
848
+ * Returns `null` when `opts` serialize to an empty DSL — same contract as
849
+ * {@link getTransformUrl}, because "no transform requested" is not an error,
850
+ * it just means you wanted {@link getPrivateAssetUrl}.
851
+ *
852
+ * ⚠️ **Backend only**, like every signer here. And note the width is a plain
853
+ * `number`: a signed URL is a trusted caller, so the edge ladder does not
854
+ * apply — the same rule `getSignedTransformUrl` already follows.
855
+ */
856
+ declare function getPrivateTransformUrl(asset: Pick<AssetDTO, "sha">, opts: SignedTransformOptions, signingKey: string, signOpts: SignAccessOptions): Promise<string | null>;
699
857
  /**
700
858
  * Did the processor actually generate this preset?
701
859
  *
@@ -714,7 +872,7 @@ declare function getAssetUrl(asset: Pick<AssetDTO, "sha"> & OriginalHints, prese
714
872
  * ```
715
873
  */
716
874
  declare function hasPreset(asset: Pick<AssetDTO, "presets">, preset: VariantPreset): boolean;
717
- declare function getAssetSrcSet(asset: Pick<AssetDTO, "sha" | "presets">): string;
875
+ declare function getAssetSrcSet(asset: Pick<AssetDTO, "sha" | "presets"> & VisibilityHint): string;
718
876
  /**
719
877
  * Compute the dimensions a variant would have given the source asset's
720
878
  * width/height and the variant's bounding box. For thumbnails (square
@@ -731,4 +889,4 @@ declare function getAssetDimensions(asset: Pick<AssetDTO, "w" | "h">): {
731
889
  height: number;
732
890
  } | null;
733
891
 
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 };
892
+ 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, 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 };