@nitida/asset-client 0.18.0 → 0.19.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/src/index.ts CHANGED
@@ -80,6 +80,67 @@ export type RequestablePreset =
80
80
  // compact `presets` wire string — so it is here and not on VariantPreset.
81
81
  | "probe";
82
82
 
83
+ /**
84
+ * The same set as {@link RequestablePreset}, at RUNTIME.
85
+ *
86
+ * The type stops the mistake in TypeScript. It cannot stop it anywhere else,
87
+ * and "anywhere else" is where it keeps happening: a preset list assembled
88
+ * from config, from a route body, from JSON, or from a script's argv arrives
89
+ * as `string[]`, and the only way past the type was a cast.
90
+ *
91
+ * Measured in neo-real-estate on 2026-08-23, in THREE independent files:
92
+ *
93
+ * presets: [...opts.presets] as VariantPreset[]
94
+ *
95
+ * — a blind cast, and to the wrong type at that: `VariantPreset` includes
96
+ * `hls` and `mp3`, which are precisely the two you may not ask for. Every one
97
+ * of those casts would have compiled a request the server answers 400.
98
+ *
99
+ * So the narrowing lives here, once, instead of being re-invented per repo.
100
+ */
101
+ export const REQUESTABLE_PRESETS: readonly RequestablePreset[] = [
102
+ "thumb",
103
+ "sm",
104
+ "md",
105
+ "lg",
106
+ "xl",
107
+ "original",
108
+ "poster",
109
+ "video",
110
+ "aiproxy",
111
+ "probe",
112
+ ] as const;
113
+
114
+ /** Type guard for a single value. */
115
+ export const isRequestablePreset = (v: string): v is RequestablePreset =>
116
+ (REQUESTABLE_PRESETS as readonly string[]).includes(v);
117
+
118
+ /**
119
+ * Narrow a `string[]` to the presets the server can actually produce, or throw
120
+ * naming the offender.
121
+ *
122
+ * Throws rather than filtering silently, for the same reason the seven URL
123
+ * builders throw on a private asset: **a silence reads as "you can't"**. A
124
+ * caller that asked for `hls` wants an HLS ladder; dropping it quietly returns
125
+ * a 200 and no ladder, and the 404 lands later and somewhere else. The error
126
+ * names the value, says why the server cannot make it, and lists what it can.
127
+ */
128
+ export const toRequestablePresets = (
129
+ input: readonly string[],
130
+ ): RequestablePreset[] => {
131
+ const bad = input.filter((p) => !isRequestablePreset(p));
132
+ if (bad.length > 0) {
133
+ throw new Error(
134
+ `Cannot request ${bad.map((b) => `"${b}"`).join(", ")}. ` +
135
+ "`hls` and `mp3` are things a variant can BE, not things you may ask " +
136
+ "for — the server derives them itself (hls when a video is " +
137
+ "transcoded, mp3 alongside any audio original). " +
138
+ `Requestable: ${REQUESTABLE_PRESETS.join(", ")}.`,
139
+ );
140
+ }
141
+ return input as RequestablePreset[];
142
+ };
143
+
83
144
  /** 1-char alias used in storage keys / wire `presets` string. */
84
145
  export const PRESET_SHORT: Record<VariantPreset, string> = {
85
146
  thumb: "q",
@@ -374,6 +435,7 @@ export type AssetDTO = {
374
435
  export {
375
436
  accessMessage,
376
437
  assertPublic,
438
+ assertSha,
377
439
  deriveAccessKey,
378
440
  type SignAccessOptions,
379
441
  signAccessUrl,
@@ -398,6 +460,7 @@ export {
398
460
 
399
461
  import {
400
462
  assertPublic,
463
+ assertSha,
401
464
  type SignAccessOptions,
402
465
  signAccessUrl,
403
466
  type VisibilityHint,
@@ -556,6 +619,7 @@ export function getAssetUrl(
556
619
  preset: VariantPreset,
557
620
  ): string {
558
621
  // Refuses rather than returning a URL that 404s. See `assertPublic`.
622
+ assertSha(asset, "getAssetUrl");
559
623
  assertPublic(
560
624
  asset,
561
625
  "getAssetUrl",
@@ -626,6 +690,13 @@ function buildPublicAssetUrl(
626
690
  asset: Pick<AssetDTO, "sha"> & OriginalHints,
627
691
  preset: VariantPreset,
628
692
  ): string {
693
+ // The guard lives HERE and not only in `getAssetUrl`, because
694
+ // `getPrivateAssetUrl` reaches this function directly. Without it, passing an
695
+ // upload result produced `/a/5/v/undefined-l.webp?exp=…&sig=…` — a URL with a
696
+ // **cryptographically valid signature over a path containing `undefined`**.
697
+ // That is strictly worse than the public case: the signature makes it look
698
+ // authoritative, and it passes shape checks at the edge before 404ing.
699
+ assertSha(asset, "getPrivateAssetUrl");
629
700
  if (preset === "original") {
630
701
  // The stored URL beats every derivation, because it IS the key. Only fall
631
702
  // through to a guess when the caller gave us the sha and nothing else.
@@ -818,6 +889,7 @@ export function getAssetSrcSet(
818
889
  // Doing this properly means capping the rungs by the asset's real width, and
819
890
  // this signature does not carry it (`Pick<AssetDTO,"sha"|"presets">`). Worth
820
891
  // doing; not worth guessing.
892
+ assertSha(asset, "getAssetSrcSet");
821
893
  assertPublic(
822
894
  asset,
823
895
  "getAssetSrcSet",
package/src/transform.ts CHANGED
@@ -18,7 +18,7 @@
18
18
  * request's Accept header.
19
19
  */
20
20
 
21
- import { assertPublic, type VisibilityHint } from "./access";
21
+ import { assertPublic, assertSha, type VisibilityHint } from "./access";
22
22
  import type { AssetDTO } from "./index";
23
23
  import { getCdnBase } from "./index";
24
24
 
@@ -231,6 +231,7 @@ export function getVideoTransformUrl(
231
231
  asset: Pick<AssetDTO, "sha"> & VisibilityHint,
232
232
  opts: TransformOptions,
233
233
  ): string | null {
234
+ assertSha(asset, "getVideoTransformUrl");
234
235
  assertPublic(
235
236
  asset,
236
237
  "getVideoTransformUrl",
@@ -304,6 +305,7 @@ export function getHlsStreamingUrl(
304
305
  asset: Pick<AssetDTO, "sha"> & VisibilityHint,
305
306
  opts: Omit<TransformOptions, "format"> = {},
306
307
  ): string {
308
+ assertSha(asset, "getHlsStreamingUrl");
307
309
  assertPublic(
308
310
  asset,
309
311
  "getHlsStreamingUrl",
@@ -336,6 +338,7 @@ export function getTransformUrl(
336
338
  asset: Pick<AssetDTO, "sha"> & VisibilityHint,
337
339
  opts: TransformOptions,
338
340
  ): string | null {
341
+ assertSha(asset, "getTransformUrl");
339
342
  assertPublic(
340
343
  asset,
341
344
  "getTransformUrl",
@@ -366,6 +369,7 @@ export function getSignedTransformUrl(
366
369
  // where a backend developer holding a signing key and a private asset ends
367
370
  // up — so without this, the same call site throws when unsigned and returns
368
371
  // a doomed URL when signed, which is the worst of both.
372
+ assertSha(asset, "getSignedTransformUrl");
369
373
  assertPublic(
370
374
  asset,
371
375
  "getSignedTransformUrl",
@@ -438,6 +442,7 @@ export function getTransformSrcSet(
438
442
  widths: number[],
439
443
  extraOpts: Omit<TransformOptions, "width"> = {},
440
444
  ): string {
445
+ assertSha(asset, "getTransformSrcSet");
441
446
  assertPublic(
442
447
  asset,
443
448
  "getTransformSrcSet",