@qnsp/storage-sdk 0.2.1 → 0.3.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.
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validation.js","sourceRoot":"","sources":["../src/validation.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB;;GAEG;AAEH,MAAM,CAAC,MAAM,UAAU,GAAG,CAAC,CAAC,MAAM,EAAE,CAAC,IAAI,CAAC,qBAAqB,CAAC,CAAC;AAEjE;;GAEG;AACH,MAAM,UAAU,YAAY,CAAC,KAAa,EAAE,SAAiB;IAC5D,IAAI,CAAC;QACJ,UAAU,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;IACzB,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QAChB,IAAI,KAAK,YAAY,CAAC,CAAC,QAAQ,EAAE,CAAC;YACjC,MAAM,IAAI,KAAK,CAAC,WAAW,SAAS,KAAK,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,OAAO,IAAI,gBAAgB,EAAE,CAAC,CAAC;QAC1F,CAAC;QACD,MAAM,KAAK,CAAC;IACb,CAAC;AACF,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qnsp/storage-sdk",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -23,16 +23,16 @@
23
23
  },
24
24
  "dependencies": {
25
25
  "@opentelemetry/api": "^1.9.0",
26
- "@opentelemetry/exporter-metrics-otlp-http": "^0.208.0",
27
- "@opentelemetry/resources": "^2.0.0",
28
- "@opentelemetry/sdk-metrics": "^2.2.0",
29
- "undici": "^7.16.0",
30
- "zod": "^4.1.12"
26
+ "@opentelemetry/exporter-metrics-otlp-http": "^0.210.0",
27
+ "@opentelemetry/resources": "^2.4.0",
28
+ "@opentelemetry/sdk-metrics": "^2.4.0",
29
+ "undici": "^7.18.2",
30
+ "zod": "^4.3.5"
31
31
  },
32
32
  "devDependencies": {
33
- "@types/node": "^24.10.4",
33
+ "@types/node": "^25.0.9",
34
34
  "tsx": "^4.21.0",
35
- "vitest": "4.0.16"
35
+ "vitest": "4.0.17"
36
36
  },
37
37
  "engines": {
38
38
  "node": "24.12.0"
@@ -1,4 +1,3 @@
1
- import { randomUUID } from "node:crypto";
2
1
  import { z } from "zod";
3
2
 
4
3
  export const eventMetadataSchema = z.object({
@@ -20,7 +19,7 @@ export const eventEnvelopeSchema = z.object({
20
19
  id: z
21
20
  .string()
22
21
  .uuid()
23
- .default(() => randomUUID()),
22
+ .default(() => crypto.randomUUID()),
24
23
  topic: z.string().min(1),
25
24
  version: z.string().min(1).default("1"),
26
25
  occuredAt: z
package/src/events.ts CHANGED
@@ -1,5 +1,3 @@
1
- import { performance } from "node:perf_hooks";
2
-
3
1
  import type { EventEnvelope } from "./event-envelope.js";
4
2
  import { createEventEnvelope } from "./event-envelope.js";
5
3
  import type {
package/src/index.test.ts CHANGED
@@ -1,9 +1,15 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
2
  import { StorageClient, StorageEventsClient } from "./index.js";
3
3
 
4
- const mockTenantId = "tenant-123";
4
+ const mockTenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
5
5
  const baseUrl = "https://storage.qnsp.example/";
6
6
 
7
+ // Valid UUID test constants
8
+ const mockUploadId = "11111111-1111-4111-a111-111111111111";
9
+ const mockUploadId2 = "22222222-2222-4222-a222-222222222222";
10
+ const mockUploadIdErr = "33333333-3333-4333-a333-333333333333";
11
+ const mockDocumentId = "44444444-4444-4444-a444-444444444444";
12
+
7
13
  function jsonResponse(payload: unknown, init?: ResponseInit): Response {
8
14
  return new Response(JSON.stringify(payload), {
9
15
  status: 200,
@@ -60,8 +66,8 @@ describe("StorageClient", () => {
60
66
  });
61
67
 
62
68
  const expectedResponse = {
63
- uploadId: "upload-1",
64
- documentId: "document-1",
69
+ uploadId: mockUploadId,
70
+ documentId: mockDocumentId,
65
71
  tenantId: mockTenantId,
66
72
  chunkSizeBytes: 16_777_216,
67
73
  totalSizeBytes: 42_000_000,
@@ -118,7 +124,7 @@ describe("StorageClient", () => {
118
124
  });
119
125
 
120
126
  const expectedPayload = {
121
- uploadId: "upload-1",
127
+ uploadId: mockUploadId,
122
128
  partId: 1,
123
129
  status: "uploaded",
124
130
  sizeBytes: 3,
@@ -137,7 +143,7 @@ describe("StorageClient", () => {
137
143
 
138
144
  const source = Buffer.from([0xde, 0xad, 0xbe]);
139
145
 
140
- const result = await client.uploadPart("upload-1", 1, source);
146
+ const result = await client.uploadPart(mockUploadId, 1, source);
141
147
  expect(result).toEqual(expectedPayload);
142
148
  expect(globalThis.fetch).toHaveBeenCalledTimes(1);
143
149
  const uploadCall = (globalThis.fetch as unknown as ReturnType<typeof vi.fn>).mock.calls.at(0);
@@ -158,6 +164,7 @@ describe("StorageClient", () => {
158
164
  it("parses ranged download responses and surfaces stream metadata", async () => {
159
165
  const client = new StorageClient({
160
166
  baseUrl,
167
+ apiKey: "test-api-key",
161
168
  tenantId: mockTenantId,
162
169
  });
163
170
 
@@ -179,12 +186,12 @@ describe("StorageClient", () => {
179
186
 
180
187
  (globalThis.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(response);
181
188
 
182
- const result = await client.downloadStream("document-1", 2, {
189
+ const result = await client.downloadStream(mockDocumentId, 2, {
183
190
  range: "bytes=0-2",
184
191
  });
185
192
 
186
193
  expect(globalThis.fetch).toHaveBeenCalledWith(
187
- "https://storage.qnsp.example/storage/v1/documents/document-1/versions/2/content?tenantId=tenant-123&range=bytes%3D0-2",
194
+ `https://storage.qnsp.example/storage/v1/documents/${mockDocumentId}/versions/2/content?tenantId=${mockTenantId}&range=bytes%3D0-2`,
188
195
  expect.objectContaining({
189
196
  method: "GET",
190
197
  headers: expect.objectContaining({
@@ -207,13 +214,14 @@ describe("StorageClient", () => {
207
214
  const telemetry = { record: vi.fn() };
208
215
  const client = new StorageClient({
209
216
  baseUrl,
217
+ apiKey: "test-api-key",
210
218
  tenantId: mockTenantId,
211
219
  telemetry,
212
220
  });
213
221
 
214
222
  const statusResponse = {
215
- uploadId: "upload-123",
216
- documentId: "doc-1",
223
+ uploadId: mockUploadId2,
224
+ documentId: mockDocumentId,
217
225
  tenantId: mockTenantId,
218
226
  status: "pending",
219
227
  chunkSizeBytes: 4,
@@ -232,7 +240,7 @@ describe("StorageClient", () => {
232
240
  jsonResponse(statusResponse),
233
241
  );
234
242
 
235
- await client.getUploadStatus("upload-123");
243
+ await client.getUploadStatus(mockUploadId2);
236
244
 
237
245
  expect(telemetry.record).toHaveBeenCalled();
238
246
  const event = (telemetry.record as ReturnType<typeof vi.fn>).mock.calls.at(-1)?.[0];
@@ -248,6 +256,7 @@ describe("StorageClient", () => {
248
256
  const telemetry = { record: vi.fn() };
249
257
  const client = new StorageClient({
250
258
  baseUrl,
259
+ apiKey: "test-api-key",
251
260
  tenantId: mockTenantId,
252
261
  telemetry,
253
262
  });
@@ -256,7 +265,7 @@ describe("StorageClient", () => {
256
265
  new Response("boom", { status: 500, statusText: "error" }),
257
266
  );
258
267
 
259
- await expect(client.completeUpload("upload-err")).rejects.toThrow(/Storage API error/);
268
+ await expect(client.completeUpload(mockUploadIdErr)).rejects.toThrow(/Storage API error/);
260
269
 
261
270
  const event = (telemetry.record as ReturnType<typeof vi.fn>).mock.calls.at(-1)?.[0];
262
271
  expect(event).toBeDefined();
@@ -271,12 +280,13 @@ describe("StorageClient", () => {
271
280
  const telemetry = { record: vi.fn() };
272
281
  const client = new StorageClient({
273
282
  baseUrl,
283
+ apiKey: "test-api-key",
274
284
  tenantId: mockTenantId,
275
285
  telemetry,
276
286
  });
277
287
 
278
288
  const expectedPayload = {
279
- uploadId: "upload-1",
289
+ uploadId: mockUploadId,
280
290
  partId: 1,
281
291
  status: "uploaded",
282
292
  sizeBytes: 3,
@@ -293,7 +303,7 @@ describe("StorageClient", () => {
293
303
  jsonResponse(expectedPayload),
294
304
  );
295
305
 
296
- await client.uploadPart("upload-1", 1, Buffer.from([1, 2, 3]));
306
+ await client.uploadPart(mockUploadId, 1, Buffer.from([1, 2, 3]));
297
307
 
298
308
  const event = (telemetry.record as ReturnType<typeof vi.fn>).mock.calls.at(-1)?.[0];
299
309
  expect(event).toBeDefined();
@@ -315,7 +325,7 @@ describe("StorageClient", () => {
315
325
  // PATCH response
316
326
  .mockResolvedValueOnce(
317
327
  jsonResponse({
318
- documentId: "doc-1",
328
+ documentId: mockDocumentId,
319
329
  tenantId: mockTenantId,
320
330
  compliance: {
321
331
  retentionMode: "compliance",
@@ -328,7 +338,7 @@ describe("StorageClient", () => {
328
338
  // GET response
329
339
  .mockResolvedValueOnce(
330
340
  jsonResponse({
331
- documentId: "doc-1",
341
+ documentId: mockDocumentId,
332
342
  tenantId: mockTenantId,
333
343
  compliance: {
334
344
  retentionMode: "compliance",
@@ -344,26 +354,30 @@ describe("StorageClient", () => {
344
354
  }),
345
355
  );
346
356
 
347
- const updated = await client.updateDocumentPolicies("doc-1", {
357
+ const updated = await client.updateDocumentPolicies(mockDocumentId, {
348
358
  retentionMode: "compliance",
349
359
  retainUntil: "2026-01-01T00:00:00Z",
350
360
  legalHolds: ["hold-1"],
351
361
  });
352
- expect(updated.documentId).toBe("doc-1");
362
+ expect(updated.documentId).toBe(mockDocumentId);
353
363
  const patchCall = (globalThis.fetch as unknown as ReturnType<typeof vi.fn>).mock
354
364
  .calls[0] as unknown as [string, RequestInit];
355
- expect(patchCall[0]).toBe("https://storage.qnsp.example/storage/v1/documents/doc-1/policies");
365
+ expect(patchCall[0]).toBe(
366
+ `https://storage.qnsp.example/storage/v1/documents/${mockDocumentId}/policies`,
367
+ );
356
368
  expect(patchCall[1]?.headers).toMatchObject({
357
369
  "Content-Type": "application/json",
358
370
  Authorization: "Bearer key",
359
371
  "x-tenant-id": mockTenantId,
360
372
  });
361
373
 
362
- const policies = await client.getDocumentPolicies("doc-1");
374
+ const policies = await client.getDocumentPolicies(mockDocumentId);
363
375
  expect(policies.tenantId).toBe(mockTenantId);
364
376
  const getCall = (globalThis.fetch as unknown as ReturnType<typeof vi.fn>).mock
365
377
  .calls[1] as unknown as [string, RequestInit];
366
- expect(getCall[0]).toBe("https://storage.qnsp.example/storage/v1/documents/doc-1/policies");
378
+ expect(getCall[0]).toBe(
379
+ `https://storage.qnsp.example/storage/v1/documents/${mockDocumentId}/policies`,
380
+ );
367
381
  expect(getCall[1]?.headers).toMatchObject({
368
382
  "Content-Type": "application/json",
369
383
  Authorization: "Bearer key",
@@ -382,7 +396,7 @@ describe("StorageClient", () => {
382
396
  // apply
383
397
  .mockResolvedValueOnce(
384
398
  jsonResponse({
385
- documentId: "doc-1",
399
+ documentId: mockDocumentId,
386
400
  tenantId: mockTenantId,
387
401
  legalHolds: ["hold-9"],
388
402
  }),
@@ -390,21 +404,23 @@ describe("StorageClient", () => {
390
404
  // release (204)
391
405
  .mockResolvedValueOnce(new Response(null, { status: 204 }));
392
406
 
393
- const applied = await client.applyLegalHold("doc-1", { holdId: "hold-9" });
407
+ const applied = await client.applyLegalHold(mockDocumentId, { holdId: "hold-9" });
394
408
  expect(applied.legalHolds).toContain("hold-9");
395
409
  const postCall = (globalThis.fetch as unknown as ReturnType<typeof vi.fn>).mock
396
410
  .calls[0] as unknown as [string, RequestInit];
397
- expect(postCall[0]).toBe("https://storage.qnsp.example/storage/v1/documents/doc-1/legal-holds");
411
+ expect(postCall[0]).toBe(
412
+ `https://storage.qnsp.example/storage/v1/documents/${mockDocumentId}/legal-holds`,
413
+ );
398
414
  expect(postCall[1]?.method).toBe("POST");
399
415
  expect(postCall[1]?.headers).toMatchObject({
400
416
  "x-tenant-id": mockTenantId,
401
417
  });
402
418
 
403
- await client.releaseLegalHold("doc-1", "hold-9");
419
+ await client.releaseLegalHold(mockDocumentId, "hold-9");
404
420
  const delCall = (globalThis.fetch as unknown as ReturnType<typeof vi.fn>).mock
405
421
  .calls[1] as unknown as [string, RequestInit];
406
422
  expect(delCall[0]).toBe(
407
- "https://storage.qnsp.example/storage/v1/documents/doc-1/legal-holds/hold-9",
423
+ `https://storage.qnsp.example/storage/v1/documents/${mockDocumentId}/legal-holds/hold-9`,
408
424
  );
409
425
  expect(delCall[1]?.method).toBe("DELETE");
410
426
  expect(delCall[1]?.headers).toMatchObject({
@@ -427,6 +443,7 @@ describe("StorageEventsClient", () => {
427
443
  const telemetry = { record: vi.fn() };
428
444
  const client = new StorageEventsClient({
429
445
  baseUrl,
446
+ apiKey: "test-api-key",
430
447
  telemetry,
431
448
  });
432
449
 
package/src/index.ts CHANGED
@@ -1,11 +1,10 @@
1
- import { performance } from "node:perf_hooks";
2
-
3
1
  import type {
4
2
  StorageClientTelemetry,
5
3
  StorageClientTelemetryConfig,
6
4
  StorageClientTelemetryEvent,
7
5
  } from "./observability.js";
8
6
  import { createStorageClientTelemetry, isStorageClientTelemetry } from "./observability.js";
7
+ import { validateUUID } from "./validation.js";
9
8
 
10
9
  /**
11
10
  * @qnsp/storage-sdk
@@ -26,15 +25,116 @@ export interface PqcMetadata {
26
25
  }
27
26
 
28
27
  /**
29
- * Mapping from internal algorithm names to NIST standardized names.
28
+ * Mapping from internal algorithm names to NIST/standards display names.
29
+ * Covers all 90 PQC algorithms supported by QNSP.
30
+ * Canonical source: @qnsp/cryptography pqc-standards.ts ALGORITHM_NIST_NAMES
30
31
  */
31
32
  export const ALGORITHM_TO_NIST: Record<string, string> = {
33
+ // FIPS 203 — ML-KEM
32
34
  "kyber-512": "ML-KEM-512",
33
35
  "kyber-768": "ML-KEM-768",
34
36
  "kyber-1024": "ML-KEM-1024",
37
+ // FIPS 204 — ML-DSA
35
38
  "dilithium-2": "ML-DSA-44",
36
39
  "dilithium-3": "ML-DSA-65",
37
40
  "dilithium-5": "ML-DSA-87",
41
+ // FIPS 205 — SLH-DSA (SHA-2 variants)
42
+ "sphincs-sha2-128f-simple": "SLH-DSA-SHA2-128f",
43
+ "sphincs-sha2-128s-simple": "SLH-DSA-SHA2-128s",
44
+ "sphincs-sha2-192f-simple": "SLH-DSA-SHA2-192f",
45
+ "sphincs-sha2-192s-simple": "SLH-DSA-SHA2-192s",
46
+ "sphincs-sha2-256f-simple": "SLH-DSA-SHA2-256f",
47
+ "sphincs-sha2-256s-simple": "SLH-DSA-SHA2-256s",
48
+ // FIPS 205 — SLH-DSA (SHAKE variants)
49
+ "sphincs-shake-128f-simple": "SLH-DSA-SHAKE-128f",
50
+ "sphincs-shake-128s-simple": "SLH-DSA-SHAKE-128s",
51
+ "sphincs-shake-192f-simple": "SLH-DSA-SHAKE-192f",
52
+ "sphincs-shake-192s-simple": "SLH-DSA-SHAKE-192s",
53
+ "sphincs-shake-256f-simple": "SLH-DSA-SHAKE-256f",
54
+ "sphincs-shake-256s-simple": "SLH-DSA-SHAKE-256s",
55
+ // FN-DSA (FIPS 206 draft)
56
+ "falcon-512": "FN-DSA-512",
57
+ "falcon-1024": "FN-DSA-1024",
58
+ // HQC (NIST selected March 2025)
59
+ "hqc-128": "HQC-128",
60
+ "hqc-192": "HQC-192",
61
+ "hqc-256": "HQC-256",
62
+ // BIKE (NIST Round 4)
63
+ "bike-l1": "BIKE-L1",
64
+ "bike-l3": "BIKE-L3",
65
+ "bike-l5": "BIKE-L5",
66
+ // Classic McEliece (ISO standard)
67
+ "mceliece-348864": "Classic-McEliece-348864",
68
+ "mceliece-460896": "Classic-McEliece-460896",
69
+ "mceliece-6688128": "Classic-McEliece-6688128",
70
+ "mceliece-6960119": "Classic-McEliece-6960119",
71
+ "mceliece-8192128": "Classic-McEliece-8192128",
72
+ // FrodoKEM (ISO standard)
73
+ "frodokem-640-aes": "FrodoKEM-640-AES",
74
+ "frodokem-640-shake": "FrodoKEM-640-SHAKE",
75
+ "frodokem-976-aes": "FrodoKEM-976-AES",
76
+ "frodokem-976-shake": "FrodoKEM-976-SHAKE",
77
+ "frodokem-1344-aes": "FrodoKEM-1344-AES",
78
+ "frodokem-1344-shake": "FrodoKEM-1344-SHAKE",
79
+ // NTRU (lattice-based, re-added in liboqs 0.15)
80
+ "ntru-hps-2048-509": "NTRU-HPS-2048-509",
81
+ "ntru-hps-2048-677": "NTRU-HPS-2048-677",
82
+ "ntru-hps-4096-821": "NTRU-HPS-4096-821",
83
+ "ntru-hps-4096-1229": "NTRU-HPS-4096-1229",
84
+ "ntru-hrss-701": "NTRU-HRSS-701",
85
+ "ntru-hrss-1373": "NTRU-HRSS-1373",
86
+ // NTRU-Prime
87
+ sntrup761: "sntrup761",
88
+ // MAYO (NIST Additional Signatures Round 2)
89
+ "mayo-1": "MAYO-1",
90
+ "mayo-2": "MAYO-2",
91
+ "mayo-3": "MAYO-3",
92
+ "mayo-5": "MAYO-5",
93
+ // CROSS (NIST Additional Signatures Round 2)
94
+ "cross-rsdp-128-balanced": "CROSS-RSDP-128-balanced",
95
+ "cross-rsdp-128-fast": "CROSS-RSDP-128-fast",
96
+ "cross-rsdp-128-small": "CROSS-RSDP-128-small",
97
+ "cross-rsdp-192-balanced": "CROSS-RSDP-192-balanced",
98
+ "cross-rsdp-192-fast": "CROSS-RSDP-192-fast",
99
+ "cross-rsdp-192-small": "CROSS-RSDP-192-small",
100
+ "cross-rsdp-256-balanced": "CROSS-RSDP-256-balanced",
101
+ "cross-rsdp-256-fast": "CROSS-RSDP-256-fast",
102
+ "cross-rsdp-256-small": "CROSS-RSDP-256-small",
103
+ "cross-rsdpg-128-balanced": "CROSS-RSDPG-128-balanced",
104
+ "cross-rsdpg-128-fast": "CROSS-RSDPG-128-fast",
105
+ "cross-rsdpg-128-small": "CROSS-RSDPG-128-small",
106
+ "cross-rsdpg-192-balanced": "CROSS-RSDPG-192-balanced",
107
+ "cross-rsdpg-192-fast": "CROSS-RSDPG-192-fast",
108
+ "cross-rsdpg-192-small": "CROSS-RSDPG-192-small",
109
+ "cross-rsdpg-256-balanced": "CROSS-RSDPG-256-balanced",
110
+ "cross-rsdpg-256-fast": "CROSS-RSDPG-256-fast",
111
+ "cross-rsdpg-256-small": "CROSS-RSDPG-256-small",
112
+ // UOV (NIST Additional Signatures Round 2)
113
+ "ov-Is": "UOV-Is",
114
+ "ov-Ip": "UOV-Ip",
115
+ "ov-III": "UOV-III",
116
+ "ov-V": "UOV-V",
117
+ "ov-Is-pkc": "UOV-Is-pkc",
118
+ "ov-Ip-pkc": "UOV-Ip-pkc",
119
+ "ov-III-pkc": "UOV-III-pkc",
120
+ "ov-V-pkc": "UOV-V-pkc",
121
+ "ov-Is-pkc-skc": "UOV-Is-pkc-skc",
122
+ "ov-Ip-pkc-skc": "UOV-Ip-pkc-skc",
123
+ "ov-III-pkc-skc": "UOV-III-pkc-skc",
124
+ "ov-V-pkc-skc": "UOV-V-pkc-skc",
125
+ // SNOVA (NIST Additional Signatures Round 2, liboqs 0.14+)
126
+ "snova-24-5-4": "SNOVA-24-5-4",
127
+ "snova-24-5-4-shake": "SNOVA-24-5-4-SHAKE",
128
+ "snova-24-5-4-esk": "SNOVA-24-5-4-ESK",
129
+ "snova-24-5-4-shake-esk": "SNOVA-24-5-4-SHAKE-ESK",
130
+ "snova-25-8-3": "SNOVA-25-8-3",
131
+ "snova-37-17-2": "SNOVA-37-17-2",
132
+ "snova-37-8-4": "SNOVA-37-8-4",
133
+ "snova-24-5-5": "SNOVA-24-5-5",
134
+ "snova-56-25-2": "SNOVA-56-25-2",
135
+ "snova-49-11-3": "SNOVA-49-11-3",
136
+ "snova-60-10-4": "SNOVA-60-10-4",
137
+ "snova-29-6-5": "SNOVA-29-6-5",
38
138
  };
39
139
 
40
140
  /**
@@ -46,9 +146,11 @@ export function toNistAlgorithmName(algorithm: string): string {
46
146
 
47
147
  export interface StorageClientConfig {
48
148
  readonly baseUrl: string;
49
- readonly apiKey?: string;
149
+ readonly apiKey: string;
50
150
  readonly tenantId: string;
51
151
  readonly timeoutMs?: number;
152
+ readonly maxRetries?: number;
153
+ readonly retryDelayMs?: number;
52
154
  readonly telemetry?: StorageClientTelemetry | StorageClientTelemetryConfig;
53
155
  }
54
156
 
@@ -57,6 +159,8 @@ type InternalStorageClientConfig = {
57
159
  readonly apiKey: string;
58
160
  readonly tenantId: string;
59
161
  readonly timeoutMs: number;
162
+ readonly maxRetries: number;
163
+ readonly retryDelayMs: number;
60
164
  };
61
165
 
62
166
  export interface InitiateUploadOptions {
@@ -184,11 +288,37 @@ export class StorageClient {
184
288
  private readonly targetService: string;
185
289
 
186
290
  constructor(config: StorageClientConfig) {
291
+ if (!config.apiKey || config.apiKey.trim().length === 0) {
292
+ throw new Error(
293
+ "QNSP Storage SDK: apiKey is required. " +
294
+ "Get your free API key at https://cloud.qnsp.cuilabs.io/signup — " +
295
+ "no credit card required (FREE tier: 5 GB storage, 2,000 API calls/month). " +
296
+ "Docs: https://docs.qnsp.cuilabs.io/sdk/storage-sdk",
297
+ );
298
+ }
299
+
300
+ const baseUrl = config.baseUrl.replace(/\/$/, "");
301
+
302
+ // Enforce HTTPS in production (allow HTTP only for localhost in development)
303
+ if (!baseUrl.startsWith("https://")) {
304
+ const isLocalhost =
305
+ baseUrl.startsWith("http://localhost") || baseUrl.startsWith("http://127.0.0.1");
306
+ const isDevelopment =
307
+ process.env["NODE_ENV"] === "development" || process.env["NODE_ENV"] === "test";
308
+ if (!isLocalhost || !isDevelopment) {
309
+ throw new Error(
310
+ "baseUrl must use HTTPS in production. HTTP is only allowed for localhost in development.",
311
+ );
312
+ }
313
+ }
314
+
187
315
  this.config = {
188
- baseUrl: config.baseUrl.replace(/\/$/, ""),
189
- apiKey: config.apiKey ?? "",
316
+ baseUrl,
317
+ apiKey: config.apiKey,
190
318
  tenantId: config.tenantId,
191
319
  timeoutMs: config.timeoutMs ?? 30_000,
320
+ maxRetries: config.maxRetries ?? 3,
321
+ retryDelayMs: config.retryDelayMs ?? 1_000,
192
322
  };
193
323
 
194
324
  this.telemetry = config.telemetry
@@ -205,15 +335,22 @@ export class StorageClient {
205
335
  }
206
336
 
207
337
  private async request<T>(method: string, path: string, options?: RequestOptions): Promise<T> {
338
+ return this.requestWithRetry<T>(method, path, options, 0);
339
+ }
340
+
341
+ private async requestWithRetry<T>(
342
+ method: string,
343
+ path: string,
344
+ options: RequestOptions | undefined,
345
+ attempt: number,
346
+ ): Promise<T> {
208
347
  const url = `${this.config.baseUrl}${path}`;
209
348
  const headers: Record<string, string> = {
210
349
  "Content-Type": "application/json",
211
350
  ...options?.headers,
212
351
  };
213
352
 
214
- if (this.config.apiKey) {
215
- headers["Authorization"] = `Bearer ${this.config.apiKey}`;
216
- }
353
+ headers["Authorization"] = `Bearer ${this.config.apiKey}`;
217
354
 
218
355
  const controller = new AbortController();
219
356
  const timeoutId = setTimeout(() => controller.abort(), this.config.timeoutMs);
@@ -241,15 +378,39 @@ export class StorageClient {
241
378
  clearTimeout(timeoutId);
242
379
  httpStatus = response.status;
243
380
 
244
- if (!response.ok) {
381
+ // Handle rate limiting (429) with retry logic
382
+ if (response.status === 429) {
383
+ if (attempt < this.config.maxRetries) {
384
+ const retryAfterHeader = response.headers.get("Retry-After");
385
+ let delayMs = this.config.retryDelayMs;
386
+
387
+ if (retryAfterHeader) {
388
+ const retryAfterSeconds = Number.parseInt(retryAfterHeader, 10);
389
+ if (!Number.isNaN(retryAfterSeconds) && retryAfterSeconds > 0) {
390
+ delayMs = retryAfterSeconds * 1_000;
391
+ }
392
+ } else {
393
+ // Exponential backoff: 2^attempt * baseDelay, capped at 30 seconds
394
+ delayMs = Math.min(2 ** attempt * this.config.retryDelayMs, 30_000);
395
+ }
396
+
397
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
398
+ return this.requestWithRetry<T>(method, path, options, attempt + 1);
399
+ }
400
+
245
401
  status = "error";
246
- const errorText = await response.text().catch(() => "Unknown error");
247
- errorMessage = errorText;
402
+ errorMessage = `HTTP ${response.status}`;
248
403
  throw new Error(
249
- `Storage API error: ${response.status} ${response.statusText} - ${errorText}`,
404
+ `Storage API error: Rate limit exceeded after ${this.config.maxRetries} retries`,
250
405
  );
251
406
  }
252
407
 
408
+ if (!response.ok) {
409
+ status = "error";
410
+ errorMessage = `HTTP ${response.status}`;
411
+ throw new Error(`Storage API error: ${response.status} ${response.statusText}`);
412
+ }
413
+
253
414
  if (response.status === 204) {
254
415
  return undefined as T;
255
416
  }
@@ -340,9 +501,7 @@ export class StorageClient {
340
501
  "Content-Type": "application/octet-stream",
341
502
  };
342
503
 
343
- if (this.config.apiKey) {
344
- headers["Authorization"] = `Bearer ${this.config.apiKey}`;
345
- }
504
+ headers["Authorization"] = `Bearer ${this.config.apiKey}`;
346
505
 
347
506
  const bytesSent =
348
507
  data instanceof Buffer || data instanceof Uint8Array ? data.byteLength : undefined;
@@ -385,11 +544,8 @@ export class StorageClient {
385
544
 
386
545
  if (!response.ok) {
387
546
  status = "error";
388
- const errorText = await response.text().catch(() => "Unknown error");
389
- errorMessage = errorText;
390
- throw new Error(
391
- `Upload part error: ${response.status} ${response.statusText} - ${errorText}`,
392
- );
547
+ errorMessage = `HTTP ${response.status}`;
548
+ throw new Error(`Upload part error: ${response.status} ${response.statusText}`);
393
549
  }
394
550
 
395
551
  return (await response.json()) as UploadPartResult;
@@ -422,6 +578,7 @@ export class StorageClient {
422
578
  }
423
579
 
424
580
  async getUploadStatus(uploadId: string): Promise<UploadStatus> {
581
+ validateUUID(uploadId, "uploadId");
425
582
  // Use GET since we need the full status object
426
583
  return this.request<UploadStatus>("GET", `/storage/v1/uploads/${uploadId}`, {
427
584
  operation: "getUploadStatus",
@@ -430,6 +587,7 @@ export class StorageClient {
430
587
  }
431
588
 
432
589
  async completeUpload(uploadId: string): Promise<CompleteUploadResult> {
590
+ validateUUID(uploadId, "uploadId");
433
591
  return this.request("POST", `/storage/v1/uploads/${uploadId}/complete`, {
434
592
  operation: "completeUpload",
435
593
  telemetryRoute: "/storage/v1/uploads/:uploadId/complete",
@@ -510,9 +668,7 @@ export class StorageClient {
510
668
  headers["Range"] = options.range;
511
669
  }
512
670
 
513
- if (this.config.apiKey) {
514
- headers["Authorization"] = `Bearer ${this.config.apiKey}`;
515
- }
671
+ headers["Authorization"] = `Bearer ${this.config.apiKey}`;
516
672
 
517
673
  const controller = new AbortController();
518
674
  const timeoutId = setTimeout(() => controller.abort(), this.config.timeoutMs);
@@ -535,9 +691,8 @@ export class StorageClient {
535
691
 
536
692
  if (!response.ok) {
537
693
  status = "error";
538
- const errorText = await response.text().catch(() => "Unknown error");
539
- errorMessage = errorText;
540
- throw new Error(`Download error: ${response.status} ${response.statusText} - ${errorText}`);
694
+ errorMessage = `HTTP ${response.status}`;
695
+ throw new Error(`Download error: ${response.status} ${response.statusText}`);
541
696
  }
542
697
 
543
698
  const contentRange = response.headers.get("Content-Range");
@@ -612,6 +767,7 @@ export class StorageClient {
612
767
  * Requires x-tenant-id header.
613
768
  */
614
769
  async getDocumentPolicies(documentId: string): Promise<DocumentPolicies> {
770
+ validateUUID(documentId, "documentId");
615
771
  return this.request("GET", `/storage/v1/documents/${documentId}/policies`, {
616
772
  operation: "getDocumentPolicies",
617
773
  telemetryRoute: "/storage/v1/documents/:documentId/policies",
@@ -629,6 +785,7 @@ export class StorageClient {
629
785
  documentId: string,
630
786
  input: UpdatePoliciesRequest,
631
787
  ): Promise<DocumentPolicies> {
788
+ validateUUID(documentId, "documentId");
632
789
  return this.request("PATCH", `/storage/v1/documents/${documentId}/policies`, {
633
790
  body: input,
634
791
  operation: "updateDocumentPolicies",
@@ -651,6 +808,7 @@ export class StorageClient {
651
808
  readonly tenantId: string;
652
809
  readonly legalHolds: readonly string[];
653
810
  }> {
811
+ validateUUID(documentId, "documentId");
654
812
  return this.request("POST", `/storage/v1/documents/${documentId}/legal-holds`, {
655
813
  body: request,
656
814
  operation: "applyLegalHold",
@@ -666,6 +824,7 @@ export class StorageClient {
666
824
  * Requires x-tenant-id header.
667
825
  */
668
826
  async releaseLegalHold(documentId: string, holdId: string): Promise<void> {
827
+ validateUUID(documentId, "documentId");
669
828
  return this.request("DELETE", `/storage/v1/documents/${documentId}/legal-holds/${holdId}`, {
670
829
  operation: "releaseLegalHold",
671
830
  telemetryRoute: "/storage/v1/documents/:documentId/legal-holds/:holdId",
@@ -691,6 +850,7 @@ export class StorageClient {
691
850
  readonly transitionAfter?: string | null;
692
851
  };
693
852
  }> {
853
+ validateUUID(documentId, "documentId");
694
854
  return this.request("POST", `/storage/v1/documents/${documentId}/lifecycle/transitions`, {
695
855
  body: request,
696
856
  operation: "scheduleLifecycleTransition",
@@ -711,3 +871,4 @@ export class StorageClient {
711
871
 
712
872
  export * from "./events.js";
713
873
  export * from "./observability.js";
874
+ export * from "./validation.js";
@@ -0,0 +1,21 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * Validation schemas for storage-sdk inputs
5
+ */
6
+
7
+ export const uuidSchema = z.string().uuid("Invalid UUID format");
8
+
9
+ /**
10
+ * Validates a UUID string
11
+ */
12
+ export function validateUUID(value: string, fieldName: string): void {
13
+ try {
14
+ uuidSchema.parse(value);
15
+ } catch (error) {
16
+ if (error instanceof z.ZodError) {
17
+ throw new Error(`Invalid ${fieldName}: ${error.issues[0]?.message ?? "Invalid format"}`);
18
+ }
19
+ throw error;
20
+ }
21
+ }