@nitida/asset-client 0.24.0 → 0.24.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitida/asset-client",
3
- "version": "0.24.0",
3
+ "version": "0.24.1",
4
4
  "description": "nitida URL builders — construct image, video and HLS URLs for the nitida CDN. No network, no key, no config beyond a tenant id.",
5
5
  "private": false,
6
6
  "publishConfig": {
@@ -42,7 +42,7 @@
42
42
  },
43
43
  "devDependencies": {
44
44
  "tsup": "^8.5.1",
45
- "typescript": "^6.0.3"
45
+ "typescript": "npm:@typescript/typescript6@6.0.2"
46
46
  },
47
47
  "keywords": [
48
48
  "media",
package/src/slots.ts CHANGED
@@ -61,6 +61,31 @@ let endpoint = "https://api.nitida.gofuture.space";
61
61
  let apiKey: string | null = null;
62
62
  let tenantCode: string | null = null;
63
63
 
64
+ /** Per-call resolver scope. When passed, it fully overrides the process globals. */
65
+ export type SlotResolverConfig = {
66
+ endpoint?: string;
67
+ apiKey?: string | null;
68
+ tenantCode?: string | null;
69
+ };
70
+
71
+ /**
72
+ * Effective config for one resolve call: explicit per-client config wins, else
73
+ * the process globals. This is what makes a per-client `slots.resolve()` use ITS
74
+ * OWN key instead of whatever client was constructed last (audit N15).
75
+ */
76
+ function effectiveConfig(cfg?: SlotResolverConfig): {
77
+ endpoint: string;
78
+ apiKey: string | null;
79
+ tenantCode: string | null;
80
+ } {
81
+ if (!cfg) return { endpoint, apiKey, tenantCode };
82
+ return {
83
+ endpoint: cfg.endpoint ? cfg.endpoint.replace(/\/+$/, "") : endpoint,
84
+ apiKey: cfg.apiKey ?? null,
85
+ tenantCode: cfg.tenantCode ?? null,
86
+ };
87
+ }
88
+
64
89
  /**
65
90
  * Configure the resolver process-wide. Call once at boot from your
66
91
  * storefront layout / server entry / worker init.
@@ -93,16 +118,25 @@ export function invalidateSlotCache(slotKey?: string): void {
93
118
  // Internal fetch helper
94
119
  // ---------------------------------------------------------------------------
95
120
 
96
- const baseHeaders = (): Record<string, string> => {
121
+ type ResolvedConfig = {
122
+ endpoint: string;
123
+ apiKey: string | null;
124
+ tenantCode: string | null;
125
+ };
126
+
127
+ const baseHeaders = (cfg: ResolvedConfig): Record<string, string> => {
97
128
  const h: Record<string, string> = {};
98
- if (apiKey) h.Authorization = `Bearer ${apiKey}`;
99
- if (tenantCode) h["X-Tenant-Code"] = tenantCode;
129
+ if (cfg.apiKey) h.Authorization = `Bearer ${cfg.apiKey}`;
130
+ if (cfg.tenantCode) h["X-Tenant-Code"] = cfg.tenantCode;
100
131
  return h;
101
132
  };
102
133
 
103
- async function fetchSlot(slotKey: string): Promise<SlotDTO | null> {
104
- const r = await fetch(`${endpoint}/slots/${encodeURIComponent(slotKey)}`, {
105
- headers: baseHeaders(),
134
+ async function fetchSlot(
135
+ slotKey: string,
136
+ cfg: ResolvedConfig,
137
+ ): Promise<SlotDTO | null> {
138
+ const r = await fetch(`${cfg.endpoint}/slots/${encodeURIComponent(slotKey)}`, {
139
+ headers: baseHeaders(cfg),
106
140
  });
107
141
  if (r.status === 404) return null;
108
142
  if (!r.ok) throw new Error(`slot fetch ${r.status}: ${await r.text()}`);
@@ -111,11 +145,12 @@ async function fetchSlot(slotKey: string): Promise<SlotDTO | null> {
111
145
 
112
146
  async function fetchSlotsBulk(
113
147
  slotKeys: string[],
148
+ cfg: ResolvedConfig,
114
149
  ): Promise<Record<string, SlotDTO | null>> {
115
150
  if (slotKeys.length === 0) return {};
116
- const r = await fetch(`${endpoint}/slots/resolve`, {
151
+ const r = await fetch(`${cfg.endpoint}/slots/resolve`, {
117
152
  method: "POST",
118
- headers: { ...baseHeaders(), "Content-Type": "application/json" },
153
+ headers: { ...baseHeaders(cfg), "Content-Type": "application/json" },
119
154
  body: JSON.stringify({ keys: slotKeys }),
120
155
  });
121
156
  if (!r.ok) throw new Error(`slots resolve ${r.status}: ${await r.text()}`);
@@ -132,6 +167,12 @@ export type ResolveSlotOptions = {
132
167
  preset?: VariantPreset;
133
168
  /** TTL for the in-process cache. Default 60s. Set 0 to bypass. */
134
169
  ttlMs?: number;
170
+ /**
171
+ * Per-client auth/endpoint scope. Omit to use the process globals
172
+ * (configureSlotResolver). A NitidaClient passes its own here so its
173
+ * resolve() never uses another client's key (audit N15).
174
+ */
175
+ config?: SlotResolverConfig;
135
176
  };
136
177
 
137
178
  /**
@@ -146,14 +187,15 @@ export async function resolveSlot(
146
187
  opts: ResolveSlotOptions = {},
147
188
  ): Promise<SlotResolution> {
148
189
  const ttl = opts.ttlMs ?? DEFAULT_TTL_MS;
149
- const cacheKey = `${tenantCode ?? "_"}:${slotKey}`;
190
+ const cfg = effectiveConfig(opts.config);
191
+ const cacheKey = `${cfg.tenantCode ?? "_"}:${slotKey}`;
150
192
  const now = Date.now();
151
193
  let dto: SlotDTO | null;
152
194
  const hit = cache.get(cacheKey);
153
195
  if (hit && now - hit.fetchedAt < ttl) {
154
196
  dto = hit.value;
155
197
  } else {
156
- dto = await fetchSlot(slotKey);
198
+ dto = await fetchSlot(slotKey, cfg);
157
199
  cache.set(cacheKey, { fetchedAt: now, value: dto });
158
200
  }
159
201
  return materializeResolution(dto, opts.preset);
@@ -170,11 +212,12 @@ export async function resolveSlots(
170
212
  ): Promise<Record<string, SlotResolution>> {
171
213
  if (slotKeys.length === 0) return {};
172
214
  const ttl = opts.ttlMs ?? DEFAULT_TTL_MS;
215
+ const cfg = effectiveConfig(opts.config);
173
216
  const now = Date.now();
174
217
  const missing: string[] = [];
175
218
  const out: Record<string, SlotResolution> = {};
176
219
  for (const k of slotKeys) {
177
- const cacheKey = `${tenantCode ?? "_"}:${k}`;
220
+ const cacheKey = `${cfg.tenantCode ?? "_"}:${k}`;
178
221
  const hit = cache.get(cacheKey);
179
222
  if (hit && now - hit.fetchedAt < ttl) {
180
223
  out[k] = materializeResolution(hit.value, opts.preset);
@@ -183,10 +226,10 @@ export async function resolveSlots(
183
226
  }
184
227
  }
185
228
  if (missing.length > 0) {
186
- const resolved = await fetchSlotsBulk(missing);
229
+ const resolved = await fetchSlotsBulk(missing, cfg);
187
230
  for (const k of missing) {
188
231
  const dto = resolved[k] ?? null;
189
- cache.set(`${tenantCode ?? "_"}:${k}`, { fetchedAt: now, value: dto });
232
+ cache.set(`${cfg.tenantCode ?? "_"}:${k}`, { fetchedAt: now, value: dto });
190
233
  out[k] = materializeResolution(dto, opts.preset);
191
234
  }
192
235
  }
package/src/transform.ts CHANGED
@@ -238,10 +238,21 @@ export function serializeTransform(
238
238
  ): string {
239
239
  const entries: Array<[string, string]> = [];
240
240
  const keys = Object.keys(opts).sort();
241
+ // DSL segments are joined with `,` and `/`; a value carrying either (or `=`,
242
+ // `%`, a space) breaks out of its segment and smuggles extra transform ops
243
+ // into the path (audit N17). Every legitimate DSL value is [a-z0-9._-] — reject
244
+ // anything else rather than emit it, keeping the canonical form byte-identical
245
+ // to what the server parses.
246
+ const VALUE_RE = /^[a-z0-9._-]+$/;
241
247
  for (const k of keys) {
242
248
  const v = (opts as Record<string, unknown>)[k];
243
249
  if (v == null) continue;
244
250
  const serialized = typeof v === "string" ? v.toLowerCase() : String(v);
251
+ if (!VALUE_RE.test(serialized)) {
252
+ throw new Error(
253
+ `invalid transform value for "${k}": ${JSON.stringify(serialized)} (only [a-z0-9._-] allowed)`,
254
+ );
255
+ }
245
256
  entries.push([k, serialized]);
246
257
  }
247
258
  return entries.map(([k, v]) => `${k}=${v}`).join(",");