@qnsp/storage-sdk 0.1.0 → 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.
package/src/index.ts CHANGED
@@ -1,24 +1,156 @@
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
12
11
  *
13
12
  * TypeScript SDK client for the QNSP storage-service API.
14
13
  * Provides a high-level interface for document upload, download, and management operations.
14
+ * All cryptographic operations use tenant-specific PQC algorithms based on crypto policy.
15
+ */
16
+
17
+ /**
18
+ * PQC metadata for cryptographic operations.
19
+ */
20
+ export interface PqcMetadata {
21
+ readonly provider: string;
22
+ readonly algorithm: string;
23
+ readonly algorithmNist?: string;
24
+ readonly keyId: string;
25
+ }
26
+
27
+ /**
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
31
+ */
32
+ export const ALGORITHM_TO_NIST: Record<string, string> = {
33
+ // FIPS 203 — ML-KEM
34
+ "kyber-512": "ML-KEM-512",
35
+ "kyber-768": "ML-KEM-768",
36
+ "kyber-1024": "ML-KEM-1024",
37
+ // FIPS 204 — ML-DSA
38
+ "dilithium-2": "ML-DSA-44",
39
+ "dilithium-3": "ML-DSA-65",
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",
138
+ };
139
+
140
+ /**
141
+ * Convert internal algorithm name to NIST standardized name.
15
142
  */
143
+ export function toNistAlgorithmName(algorithm: string): string {
144
+ return ALGORITHM_TO_NIST[algorithm] ?? algorithm;
145
+ }
16
146
 
17
147
  export interface StorageClientConfig {
18
148
  readonly baseUrl: string;
19
- readonly apiKey?: string;
149
+ readonly apiKey: string;
20
150
  readonly tenantId: string;
21
151
  readonly timeoutMs?: number;
152
+ readonly maxRetries?: number;
153
+ readonly retryDelayMs?: number;
22
154
  readonly telemetry?: StorageClientTelemetry | StorageClientTelemetryConfig;
23
155
  }
24
156
 
@@ -27,6 +159,8 @@ type InternalStorageClientConfig = {
27
159
  readonly apiKey: string;
28
160
  readonly tenantId: string;
29
161
  readonly timeoutMs: number;
162
+ readonly maxRetries: number;
163
+ readonly retryDelayMs: number;
30
164
  };
31
165
 
32
166
  export interface InitiateUploadOptions {
@@ -154,11 +288,37 @@ export class StorageClient {
154
288
  private readonly targetService: string;
155
289
 
156
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
+
157
315
  this.config = {
158
- baseUrl: config.baseUrl.replace(/\/$/, ""),
159
- apiKey: config.apiKey ?? "",
316
+ baseUrl,
317
+ apiKey: config.apiKey,
160
318
  tenantId: config.tenantId,
161
319
  timeoutMs: config.timeoutMs ?? 30_000,
320
+ maxRetries: config.maxRetries ?? 3,
321
+ retryDelayMs: config.retryDelayMs ?? 1_000,
162
322
  };
163
323
 
164
324
  this.telemetry = config.telemetry
@@ -175,15 +335,22 @@ export class StorageClient {
175
335
  }
176
336
 
177
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> {
178
347
  const url = `${this.config.baseUrl}${path}`;
179
348
  const headers: Record<string, string> = {
180
349
  "Content-Type": "application/json",
181
350
  ...options?.headers,
182
351
  };
183
352
 
184
- if (this.config.apiKey) {
185
- headers["Authorization"] = `Bearer ${this.config.apiKey}`;
186
- }
353
+ headers["Authorization"] = `Bearer ${this.config.apiKey}`;
187
354
 
188
355
  const controller = new AbortController();
189
356
  const timeoutId = setTimeout(() => controller.abort(), this.config.timeoutMs);
@@ -211,15 +378,39 @@ export class StorageClient {
211
378
  clearTimeout(timeoutId);
212
379
  httpStatus = response.status;
213
380
 
214
- 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
+
215
401
  status = "error";
216
- const errorText = await response.text().catch(() => "Unknown error");
217
- errorMessage = errorText;
402
+ errorMessage = `HTTP ${response.status}`;
218
403
  throw new Error(
219
- `Storage API error: ${response.status} ${response.statusText} - ${errorText}`,
404
+ `Storage API error: Rate limit exceeded after ${this.config.maxRetries} retries`,
220
405
  );
221
406
  }
222
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
+
223
414
  if (response.status === 204) {
224
415
  return undefined as T;
225
416
  }
@@ -261,13 +452,23 @@ export class StorageClient {
261
452
  readonly totalParts: number;
262
453
  readonly expiresAt: string;
263
454
  readonly resumeToken: string | null;
264
- readonly pqc: {
265
- readonly provider: string;
266
- readonly algorithm: string;
267
- readonly keyId: string;
268
- };
455
+ readonly pqc: PqcMetadata;
269
456
  }> {
270
- return this.request("POST", "/storage/v1/documents", {
457
+ const result = await this.request<{
458
+ uploadId: string;
459
+ documentId: string;
460
+ tenantId: string;
461
+ chunkSizeBytes: number;
462
+ totalSizeBytes: number;
463
+ totalParts: number;
464
+ expiresAt: string;
465
+ resumeToken: string | null;
466
+ pqc: {
467
+ provider: string;
468
+ algorithm: string;
469
+ keyId: string;
470
+ };
471
+ }>("POST", "/storage/v1/documents", {
271
472
  body: {
272
473
  name: options.name,
273
474
  mimeType: options.mimeType,
@@ -279,6 +480,15 @@ export class StorageClient {
279
480
  },
280
481
  operation: "initiateUpload",
281
482
  });
483
+
484
+ // Enrich PQC metadata with NIST algorithm name
485
+ return {
486
+ ...result,
487
+ pqc: {
488
+ ...result.pqc,
489
+ algorithmNist: toNistAlgorithmName(result.pqc.algorithm),
490
+ },
491
+ };
282
492
  }
283
493
 
284
494
  async uploadPart(
@@ -291,9 +501,7 @@ export class StorageClient {
291
501
  "Content-Type": "application/octet-stream",
292
502
  };
293
503
 
294
- if (this.config.apiKey) {
295
- headers["Authorization"] = `Bearer ${this.config.apiKey}`;
296
- }
504
+ headers["Authorization"] = `Bearer ${this.config.apiKey}`;
297
505
 
298
506
  const bytesSent =
299
507
  data instanceof Buffer || data instanceof Uint8Array ? data.byteLength : undefined;
@@ -336,11 +544,8 @@ export class StorageClient {
336
544
 
337
545
  if (!response.ok) {
338
546
  status = "error";
339
- const errorText = await response.text().catch(() => "Unknown error");
340
- errorMessage = errorText;
341
- throw new Error(
342
- `Upload part error: ${response.status} ${response.statusText} - ${errorText}`,
343
- );
547
+ errorMessage = `HTTP ${response.status}`;
548
+ throw new Error(`Upload part error: ${response.status} ${response.statusText}`);
344
549
  }
345
550
 
346
551
  return (await response.json()) as UploadPartResult;
@@ -373,6 +578,7 @@ export class StorageClient {
373
578
  }
374
579
 
375
580
  async getUploadStatus(uploadId: string): Promise<UploadStatus> {
581
+ validateUUID(uploadId, "uploadId");
376
582
  // Use GET since we need the full status object
377
583
  return this.request<UploadStatus>("GET", `/storage/v1/uploads/${uploadId}`, {
378
584
  operation: "getUploadStatus",
@@ -381,6 +587,7 @@ export class StorageClient {
381
587
  }
382
588
 
383
589
  async completeUpload(uploadId: string): Promise<CompleteUploadResult> {
590
+ validateUUID(uploadId, "uploadId");
384
591
  return this.request("POST", `/storage/v1/uploads/${uploadId}/complete`, {
385
592
  operation: "completeUpload",
386
593
  telemetryRoute: "/storage/v1/uploads/:uploadId/complete",
@@ -461,9 +668,7 @@ export class StorageClient {
461
668
  headers["Range"] = options.range;
462
669
  }
463
670
 
464
- if (this.config.apiKey) {
465
- headers["Authorization"] = `Bearer ${this.config.apiKey}`;
466
- }
671
+ headers["Authorization"] = `Bearer ${this.config.apiKey}`;
467
672
 
468
673
  const controller = new AbortController();
469
674
  const timeoutId = setTimeout(() => controller.abort(), this.config.timeoutMs);
@@ -486,9 +691,8 @@ export class StorageClient {
486
691
 
487
692
  if (!response.ok) {
488
693
  status = "error";
489
- const errorText = await response.text().catch(() => "Unknown error");
490
- errorMessage = errorText;
491
- 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}`);
492
696
  }
493
697
 
494
698
  const contentRange = response.headers.get("Content-Range");
@@ -563,6 +767,7 @@ export class StorageClient {
563
767
  * Requires x-tenant-id header.
564
768
  */
565
769
  async getDocumentPolicies(documentId: string): Promise<DocumentPolicies> {
770
+ validateUUID(documentId, "documentId");
566
771
  return this.request("GET", `/storage/v1/documents/${documentId}/policies`, {
567
772
  operation: "getDocumentPolicies",
568
773
  telemetryRoute: "/storage/v1/documents/:documentId/policies",
@@ -580,6 +785,7 @@ export class StorageClient {
580
785
  documentId: string,
581
786
  input: UpdatePoliciesRequest,
582
787
  ): Promise<DocumentPolicies> {
788
+ validateUUID(documentId, "documentId");
583
789
  return this.request("PATCH", `/storage/v1/documents/${documentId}/policies`, {
584
790
  body: input,
585
791
  operation: "updateDocumentPolicies",
@@ -602,6 +808,7 @@ export class StorageClient {
602
808
  readonly tenantId: string;
603
809
  readonly legalHolds: readonly string[];
604
810
  }> {
811
+ validateUUID(documentId, "documentId");
605
812
  return this.request("POST", `/storage/v1/documents/${documentId}/legal-holds`, {
606
813
  body: request,
607
814
  operation: "applyLegalHold",
@@ -617,6 +824,7 @@ export class StorageClient {
617
824
  * Requires x-tenant-id header.
618
825
  */
619
826
  async releaseLegalHold(documentId: string, holdId: string): Promise<void> {
827
+ validateUUID(documentId, "documentId");
620
828
  return this.request("DELETE", `/storage/v1/documents/${documentId}/legal-holds/${holdId}`, {
621
829
  operation: "releaseLegalHold",
622
830
  telemetryRoute: "/storage/v1/documents/:documentId/legal-holds/:holdId",
@@ -642,6 +850,7 @@ export class StorageClient {
642
850
  readonly transitionAfter?: string | null;
643
851
  };
644
852
  }> {
853
+ validateUUID(documentId, "documentId");
645
854
  return this.request("POST", `/storage/v1/documents/${documentId}/lifecycle/transitions`, {
646
855
  body: request,
647
856
  operation: "scheduleLifecycleTransition",
@@ -662,3 +871,4 @@ export class StorageClient {
662
871
 
663
872
  export * from "./events.js";
664
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
+ }