@nitida/sdk 0.36.0 → 0.36.2

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/sdk",
3
- "version": "0.36.0",
3
+ "version": "0.36.2",
4
4
  "description": "nitida — the media SDK: browser and mobile upload with resume, client-side compression, on-the-fly transforms behind a CDN, video transcode, HLS ladders and AI proxies. Multi-tenant.",
5
5
  "private": false,
6
6
  "publishConfig": {
@@ -93,7 +93,7 @@
93
93
  "@types/bun": "^1.3.14",
94
94
  "react": "^19.2.6",
95
95
  "tsup": "^8.5.1",
96
- "typescript": "^6.0.3",
96
+ "typescript": "npm:@typescript/typescript6@6.0.2",
97
97
  "@nitida/asset-client": "workspace:*",
98
98
  "@nitida/asset-compressor-web": "workspace:*",
99
99
  "@nitida/asset-compressor-native": "workspace:*",
@@ -190,11 +190,24 @@ everyone, and the ladder buys you nothing.
190
190
 
191
191
  > Full measurements, with the method: <https://nitida.gofuture.space/guides/transform-benchmark/>
192
192
 
193
- `/t/` short-circuits to the smallest **stored variant** that covers the request
194
- — and that variant is already a WebP
195
- that went through one lossy pass. **The short-circuit only fires when the
196
- request carries an explicit numeric `quality`**, so **passing any explicit numeric `quality` at all** is
197
- exactly what turns it on.
193
+ `/t/` can short-circuit to the smallest **stored variant** that covers the request
194
+ — and that variant is already a WebP that went through one lossy pass.
195
+
196
+ 🔴 **Until 2026-08-31 that shortcut fired on `typeof quality === "number"`**, so
197
+ the parameter named "quality" was the one that chose WHICH BYTES to decode, and
198
+ pinning it *lowered* quality. **That is fixed at the server.** `quality` now only
199
+ sets the encoder; the cheap source has to be asked for by name, `source=nearest`
200
+ (default `master`), and a bogus value is a 400.
201
+
202
+ Verified against production the day it shipped, same asset, cold DSLs:
203
+ `quality=61` → `x-transform-source: lg` before · `quality=62` → `original` after ·
204
+ `quality=64,source=nearest` → `lg`, deliberately.
205
+
206
+ ⇒ **You cannot trigger a second lossy pass by accident any more.** The numbers
207
+ below are the honest cost of one, i.e. what `source=nearest` buys today. And
208
+ `source` is deliberately NOT in `TransformOptions`: the only legitimate consumer
209
+ is a bulk pipeline that hand-builds URLs, and keeping it off the typed surface is
210
+ what stops an ordinary caller from landing there.
198
211
 
199
212
  Measured at `width=800` on five photographs, same photo uploaded twice (full
200
213
  ladder vs `["original"]` only):
package/src/index.ts CHANGED
@@ -309,12 +309,22 @@ export {
309
309
  class SlotsApi {
310
310
  constructor(private readonly opts: NitidaClientOptions) {}
311
311
 
312
+ /** This client's own auth/endpoint scope, threaded into every resolve so it
313
+ * never uses another client's key (audit N15). */
314
+ private resolverConfig() {
315
+ return {
316
+ endpoint: this.opts.endpoint,
317
+ apiKey: this.opts.apiKey ?? null,
318
+ tenantCode: this.opts.tenantCode,
319
+ };
320
+ }
321
+
312
322
  /** Resolve one slot — returns `{slot, preset, url}` or `{slot: null, url: null}` when unbound. */
313
323
  resolve(
314
324
  slotKey: string,
315
325
  options: ResolveSlotOptions = {},
316
326
  ): Promise<SlotResolution> {
317
- return resolveSlot(slotKey, options);
327
+ return resolveSlot(slotKey, { config: this.resolverConfig(), ...options });
318
328
  }
319
329
 
320
330
  /** Bulk-resolve N slots in one HTTP round-trip. */
@@ -322,7 +332,7 @@ class SlotsApi {
322
332
  slotKeys: string[],
323
333
  options: ResolveSlotOptions = {},
324
334
  ): Promise<Record<string, SlotResolution>> {
325
- return resolveSlots(slotKeys, options);
335
+ return resolveSlots(slotKeys, { config: this.resolverConfig(), ...options });
326
336
  }
327
337
 
328
338
  /** List slots for the tenant (admin). Optional prefix filter for tree views. */
@@ -570,7 +580,7 @@ class AssetsApi {
570
580
  */
571
581
  async byHash(sha256: string): Promise<AssetDTO | null> {
572
582
  const r = await fetch(
573
- endpointHref(this.opts, `/assets/by-hash/${sha256}`),
583
+ endpointHref(this.opts, `/assets/by-hash/${encodeURIComponent(sha256)}`),
574
584
  { headers: this.headers() },
575
585
  );
576
586
  if (r.status === 404) return null;
@@ -614,7 +624,7 @@ class AssetsApi {
614
624
 
615
625
  /** Full DTO for an asset (admin view — includes audit-only fields). */
616
626
  async get(assetId: string): Promise<AssetDTO & Record<string, unknown>> {
617
- const r = await fetch(endpointHref(this.opts, `/assets/${assetId}`), {
627
+ const r = await fetch(endpointHref(this.opts, `/assets/${encodeURIComponent(assetId)}`), {
618
628
  headers: this.headers(),
619
629
  });
620
630
  if (!r.ok) throw new Error(`asset get ${r.status}: ${await r.text()}`);
@@ -635,7 +645,7 @@ class AssetsApi {
635
645
  updatedBy: string | null;
636
646
  }>
637
647
  > {
638
- const r = await fetch(endpointHref(this.opts, `/assets/${assetId}/slots`), {
648
+ const r = await fetch(endpointHref(this.opts, `/assets/${encodeURIComponent(assetId)}/slots`), {
639
649
  headers: this.headers(),
640
650
  });
641
651
  if (!r.ok) throw new Error(`asset bindings ${r.status}: ${await r.text()}`);
@@ -713,7 +723,7 @@ class AssetsApi {
713
723
  opts: { presets?: RequestablePreset[] } = {},
714
724
  ): Promise<RegenerateResult> {
715
725
  const r = await fetch(
716
- endpointHref(this.opts, `/assets/${assetId}/regenerate`),
726
+ endpointHref(this.opts, `/assets/${encodeURIComponent(assetId)}/regenerate`),
717
727
  {
718
728
  method: "POST",
719
729
  headers: { ...this.headers(), "Content-Type": "application/json" },
@@ -730,7 +740,7 @@ class AssetsApi {
730
740
  assetId: string,
731
741
  metadata: Record<string, unknown>,
732
742
  ): Promise<{ ok: true; metadata: Record<string, unknown> }> {
733
- const r = await fetch(endpointHref(this.opts, `/assets/${assetId}`), {
743
+ const r = await fetch(endpointHref(this.opts, `/assets/${encodeURIComponent(assetId)}`), {
734
744
  method: "PATCH",
735
745
  headers: { ...this.headers(), "Content-Type": "application/json" },
736
746
  body: JSON.stringify({ metadata }),
@@ -1434,11 +1444,12 @@ export class NitidaClient {
1434
1444
  // (`getAssetUrl`, `urlFor`, `srcSetFor`, upload `cdnUrl`) emits the
1435
1445
  // tenant-prefixed path `<cdn>/<tid b36>/v/<sha>-<preset>.<ext>`.
1436
1446
  setTenantId(opts.tenantId);
1437
- configureSlotResolver({
1438
- endpoint: opts.endpoint,
1439
- apiKey: opts.apiKey,
1440
- tenantCode: opts.tenantCode,
1441
- });
1447
+ // NOTE: do NOT configure the process-global slot resolver here. A second
1448
+ // client for another tenant would overwrite the first client's apiKey /
1449
+ // tenantCode and make its slots.resolve() read cross-tenant (audit N15).
1450
+ // Slot scope is threaded per-client via SlotsApi.resolve → resolveSlot({config}).
1451
+ // `configureSlotResolver` stays exported for standalone resolveSlot() users
1452
+ // (a browser/edge that boots one resolver).
1442
1453
  this.slots = new SlotsApi(opts);
1443
1454
  this.assets = new AssetsApi(opts);
1444
1455
  this.usage = new UsageApi(opts);
package/src/web/index.ts CHANGED
@@ -84,6 +84,16 @@ export type WebClientOptions = Omit<
84
84
  */
85
85
  export class NitidaClient extends BaseNitidaClient {
86
86
  constructor(opts: WebClientOptions) {
87
+ // "Keys can't reach a browser" was a TYPESCRIPT-only property: WebClientOptions
88
+ // omits apiKey/signingKey, but a JS consumer (or `as any`) could still pass them
89
+ // and this bundle would use them (audit N14). Refuse at runtime too.
90
+ for (const k of ["apiKey", "signingKey"] as const) {
91
+ if (k in (opts as object)) {
92
+ throw new Error(
93
+ `@nitida/sdk/web: ${k} must never reach a browser bundle. Import @nitida/sdk/server on the server, and use a BFF route from the browser.`,
94
+ );
95
+ }
96
+ }
87
97
  super(opts as NitidaClientOptions);
88
98
  }
89
99
  }