@qnsp/storage-sdk 0.2.1 → 0.3.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/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { performance } from "node:perf_hooks";
1
+ import { activateSdk, type SdkActivationConfig } from "@qnsp/sdk-activation";
2
2
 
3
3
  import type {
4
4
  StorageClientTelemetry,
@@ -6,6 +6,7 @@ import type {
6
6
  StorageClientTelemetryEvent,
7
7
  } from "./observability.js";
8
8
  import { createStorageClientTelemetry, isStorageClientTelemetry } from "./observability.js";
9
+ import { validateUUID } from "./validation.js";
9
10
 
10
11
  /**
11
12
  * @qnsp/storage-sdk
@@ -26,15 +27,116 @@ export interface PqcMetadata {
26
27
  }
27
28
 
28
29
  /**
29
- * Mapping from internal algorithm names to NIST standardized names.
30
+ * Mapping from internal algorithm names to NIST/standards display names.
31
+ * Covers all 90 PQC algorithms supported by QNSP.
32
+ * Canonical source: @qnsp/cryptography pqc-standards.ts ALGORITHM_NIST_NAMES
30
33
  */
31
34
  export const ALGORITHM_TO_NIST: Record<string, string> = {
35
+ // FIPS 203 — ML-KEM
32
36
  "kyber-512": "ML-KEM-512",
33
37
  "kyber-768": "ML-KEM-768",
34
38
  "kyber-1024": "ML-KEM-1024",
39
+ // FIPS 204 — ML-DSA
35
40
  "dilithium-2": "ML-DSA-44",
36
41
  "dilithium-3": "ML-DSA-65",
37
42
  "dilithium-5": "ML-DSA-87",
43
+ // FIPS 205 — SLH-DSA (SHA-2 variants)
44
+ "sphincs-sha2-128f-simple": "SLH-DSA-SHA2-128f",
45
+ "sphincs-sha2-128s-simple": "SLH-DSA-SHA2-128s",
46
+ "sphincs-sha2-192f-simple": "SLH-DSA-SHA2-192f",
47
+ "sphincs-sha2-192s-simple": "SLH-DSA-SHA2-192s",
48
+ "sphincs-sha2-256f-simple": "SLH-DSA-SHA2-256f",
49
+ "sphincs-sha2-256s-simple": "SLH-DSA-SHA2-256s",
50
+ // FIPS 205 — SLH-DSA (SHAKE variants)
51
+ "sphincs-shake-128f-simple": "SLH-DSA-SHAKE-128f",
52
+ "sphincs-shake-128s-simple": "SLH-DSA-SHAKE-128s",
53
+ "sphincs-shake-192f-simple": "SLH-DSA-SHAKE-192f",
54
+ "sphincs-shake-192s-simple": "SLH-DSA-SHAKE-192s",
55
+ "sphincs-shake-256f-simple": "SLH-DSA-SHAKE-256f",
56
+ "sphincs-shake-256s-simple": "SLH-DSA-SHAKE-256s",
57
+ // FN-DSA (FIPS 206 draft)
58
+ "falcon-512": "FN-DSA-512",
59
+ "falcon-1024": "FN-DSA-1024",
60
+ // HQC (NIST selected March 2025)
61
+ "hqc-128": "HQC-128",
62
+ "hqc-192": "HQC-192",
63
+ "hqc-256": "HQC-256",
64
+ // BIKE (NIST Round 4)
65
+ "bike-l1": "BIKE-L1",
66
+ "bike-l3": "BIKE-L3",
67
+ "bike-l5": "BIKE-L5",
68
+ // Classic McEliece (ISO standard)
69
+ "mceliece-348864": "Classic-McEliece-348864",
70
+ "mceliece-460896": "Classic-McEliece-460896",
71
+ "mceliece-6688128": "Classic-McEliece-6688128",
72
+ "mceliece-6960119": "Classic-McEliece-6960119",
73
+ "mceliece-8192128": "Classic-McEliece-8192128",
74
+ // FrodoKEM (ISO standard)
75
+ "frodokem-640-aes": "FrodoKEM-640-AES",
76
+ "frodokem-640-shake": "FrodoKEM-640-SHAKE",
77
+ "frodokem-976-aes": "FrodoKEM-976-AES",
78
+ "frodokem-976-shake": "FrodoKEM-976-SHAKE",
79
+ "frodokem-1344-aes": "FrodoKEM-1344-AES",
80
+ "frodokem-1344-shake": "FrodoKEM-1344-SHAKE",
81
+ // NTRU (lattice-based, re-added in liboqs 0.15)
82
+ "ntru-hps-2048-509": "NTRU-HPS-2048-509",
83
+ "ntru-hps-2048-677": "NTRU-HPS-2048-677",
84
+ "ntru-hps-4096-821": "NTRU-HPS-4096-821",
85
+ "ntru-hps-4096-1229": "NTRU-HPS-4096-1229",
86
+ "ntru-hrss-701": "NTRU-HRSS-701",
87
+ "ntru-hrss-1373": "NTRU-HRSS-1373",
88
+ // NTRU-Prime
89
+ sntrup761: "sntrup761",
90
+ // MAYO (NIST Additional Signatures Round 2)
91
+ "mayo-1": "MAYO-1",
92
+ "mayo-2": "MAYO-2",
93
+ "mayo-3": "MAYO-3",
94
+ "mayo-5": "MAYO-5",
95
+ // CROSS (NIST Additional Signatures Round 2)
96
+ "cross-rsdp-128-balanced": "CROSS-RSDP-128-balanced",
97
+ "cross-rsdp-128-fast": "CROSS-RSDP-128-fast",
98
+ "cross-rsdp-128-small": "CROSS-RSDP-128-small",
99
+ "cross-rsdp-192-balanced": "CROSS-RSDP-192-balanced",
100
+ "cross-rsdp-192-fast": "CROSS-RSDP-192-fast",
101
+ "cross-rsdp-192-small": "CROSS-RSDP-192-small",
102
+ "cross-rsdp-256-balanced": "CROSS-RSDP-256-balanced",
103
+ "cross-rsdp-256-fast": "CROSS-RSDP-256-fast",
104
+ "cross-rsdp-256-small": "CROSS-RSDP-256-small",
105
+ "cross-rsdpg-128-balanced": "CROSS-RSDPG-128-balanced",
106
+ "cross-rsdpg-128-fast": "CROSS-RSDPG-128-fast",
107
+ "cross-rsdpg-128-small": "CROSS-RSDPG-128-small",
108
+ "cross-rsdpg-192-balanced": "CROSS-RSDPG-192-balanced",
109
+ "cross-rsdpg-192-fast": "CROSS-RSDPG-192-fast",
110
+ "cross-rsdpg-192-small": "CROSS-RSDPG-192-small",
111
+ "cross-rsdpg-256-balanced": "CROSS-RSDPG-256-balanced",
112
+ "cross-rsdpg-256-fast": "CROSS-RSDPG-256-fast",
113
+ "cross-rsdpg-256-small": "CROSS-RSDPG-256-small",
114
+ // UOV (NIST Additional Signatures Round 2)
115
+ "ov-Is": "UOV-Is",
116
+ "ov-Ip": "UOV-Ip",
117
+ "ov-III": "UOV-III",
118
+ "ov-V": "UOV-V",
119
+ "ov-Is-pkc": "UOV-Is-pkc",
120
+ "ov-Ip-pkc": "UOV-Ip-pkc",
121
+ "ov-III-pkc": "UOV-III-pkc",
122
+ "ov-V-pkc": "UOV-V-pkc",
123
+ "ov-Is-pkc-skc": "UOV-Is-pkc-skc",
124
+ "ov-Ip-pkc-skc": "UOV-Ip-pkc-skc",
125
+ "ov-III-pkc-skc": "UOV-III-pkc-skc",
126
+ "ov-V-pkc-skc": "UOV-V-pkc-skc",
127
+ // SNOVA (NIST Additional Signatures Round 2, liboqs 0.14+)
128
+ "snova-24-5-4": "SNOVA-24-5-4",
129
+ "snova-24-5-4-shake": "SNOVA-24-5-4-SHAKE",
130
+ "snova-24-5-4-esk": "SNOVA-24-5-4-ESK",
131
+ "snova-24-5-4-shake-esk": "SNOVA-24-5-4-SHAKE-ESK",
132
+ "snova-25-8-3": "SNOVA-25-8-3",
133
+ "snova-37-17-2": "SNOVA-37-17-2",
134
+ "snova-37-8-4": "SNOVA-37-8-4",
135
+ "snova-24-5-5": "SNOVA-24-5-5",
136
+ "snova-56-25-2": "SNOVA-56-25-2",
137
+ "snova-49-11-3": "SNOVA-49-11-3",
138
+ "snova-60-10-4": "SNOVA-60-10-4",
139
+ "snova-29-6-5": "SNOVA-29-6-5",
38
140
  };
39
141
 
40
142
  /**
@@ -46,9 +148,11 @@ export function toNistAlgorithmName(algorithm: string): string {
46
148
 
47
149
  export interface StorageClientConfig {
48
150
  readonly baseUrl: string;
49
- readonly apiKey?: string;
151
+ readonly apiKey: string;
50
152
  readonly tenantId: string;
51
153
  readonly timeoutMs?: number;
154
+ readonly maxRetries?: number;
155
+ readonly retryDelayMs?: number;
52
156
  readonly telemetry?: StorageClientTelemetry | StorageClientTelemetryConfig;
53
157
  }
54
158
 
@@ -57,6 +161,8 @@ type InternalStorageClientConfig = {
57
161
  readonly apiKey: string;
58
162
  readonly tenantId: string;
59
163
  readonly timeoutMs: number;
164
+ readonly maxRetries: number;
165
+ readonly retryDelayMs: number;
60
166
  };
61
167
 
62
168
  export interface InitiateUploadOptions {
@@ -182,13 +288,61 @@ export class StorageClient {
182
288
  private readonly config: InternalStorageClientConfig;
183
289
  private readonly telemetry: StorageClientTelemetry | null;
184
290
  private readonly targetService: string;
291
+ private activationPromise: Promise<void> | null = null;
292
+ private readonly activationConfig: SdkActivationConfig;
293
+
294
+ private async ensureActivated(): Promise<void> {
295
+ if (!this.activationPromise) {
296
+ this.activationPromise = activateSdk(this.activationConfig).then(() => undefined);
297
+ }
298
+ return this.activationPromise;
299
+ }
185
300
 
186
301
  constructor(config: StorageClientConfig) {
302
+ if (!config.apiKey || config.apiKey.trim().length === 0) {
303
+ throw new Error(
304
+ "QNSP Storage SDK: apiKey is required. " +
305
+ "Get your free API key at https://cloud.qnsp.cuilabs.io/signup — " +
306
+ "no credit card required (FREE tier: 10 GB storage, 50,000 API calls/month). " +
307
+ "Docs: https://docs.qnsp.cuilabs.io/sdk/storage-sdk",
308
+ );
309
+ }
310
+
311
+ const baseUrl = config.baseUrl.replace(/\/$/, "");
312
+
313
+ // Enforce HTTPS in production (allow HTTP for localhost in development and
314
+ // for internal service-mesh hostnames — e.g. *.internal — which are on a
315
+ // private VPC network and do not require TLS termination at the transport layer).
316
+ if (!baseUrl.startsWith("https://")) {
317
+ const isLocalhost =
318
+ baseUrl.startsWith("http://localhost") || baseUrl.startsWith("http://127.0.0.1");
319
+ let isInternalService = false;
320
+ try {
321
+ const parsed = new URL(baseUrl);
322
+ isInternalService =
323
+ parsed.protocol === "http:" &&
324
+ (parsed.hostname.endsWith(".internal") ||
325
+ parsed.hostname === "localhost" ||
326
+ parsed.hostname === "127.0.0.1");
327
+ } catch {
328
+ // ignore; invalid URL will be caught later by fetch
329
+ }
330
+ const isDevelopment =
331
+ process.env["NODE_ENV"] === "development" || process.env["NODE_ENV"] === "test";
332
+ if ((!isLocalhost || !isDevelopment) && !isInternalService) {
333
+ throw new Error(
334
+ "baseUrl must use HTTPS in production. HTTP is only allowed for localhost in development.",
335
+ );
336
+ }
337
+ }
338
+
187
339
  this.config = {
188
- baseUrl: config.baseUrl.replace(/\/$/, ""),
189
- apiKey: config.apiKey ?? "",
340
+ baseUrl,
341
+ apiKey: config.apiKey,
190
342
  tenantId: config.tenantId,
191
343
  timeoutMs: config.timeoutMs ?? 30_000,
344
+ maxRetries: config.maxRetries ?? 3,
345
+ retryDelayMs: config.retryDelayMs ?? 1_000,
192
346
  };
193
347
 
194
348
  this.telemetry = config.telemetry
@@ -202,18 +356,32 @@ export class StorageClient {
202
356
  } catch {
203
357
  this.targetService = "storage-service";
204
358
  }
359
+
360
+ this.activationConfig = {
361
+ apiKey: config.apiKey,
362
+ sdkId: "storage-sdk",
363
+ sdkVersion: "0.3.0",
364
+ platformUrl: config.baseUrl,
365
+ };
205
366
  }
206
367
 
207
368
  private async request<T>(method: string, path: string, options?: RequestOptions): Promise<T> {
369
+ return this.requestWithRetry<T>(method, path, options, 0);
370
+ }
371
+
372
+ private async requestWithRetry<T>(
373
+ method: string,
374
+ path: string,
375
+ options: RequestOptions | undefined,
376
+ attempt: number,
377
+ ): Promise<T> {
208
378
  const url = `${this.config.baseUrl}${path}`;
209
379
  const headers: Record<string, string> = {
210
380
  "Content-Type": "application/json",
211
381
  ...options?.headers,
212
382
  };
213
383
 
214
- if (this.config.apiKey) {
215
- headers["Authorization"] = `Bearer ${this.config.apiKey}`;
216
- }
384
+ headers["Authorization"] = `Bearer ${this.config.apiKey}`;
217
385
 
218
386
  const controller = new AbortController();
219
387
  const timeoutId = setTimeout(() => controller.abort(), this.config.timeoutMs);
@@ -241,15 +409,39 @@ export class StorageClient {
241
409
  clearTimeout(timeoutId);
242
410
  httpStatus = response.status;
243
411
 
244
- if (!response.ok) {
412
+ // Handle rate limiting (429) with retry logic
413
+ if (response.status === 429) {
414
+ if (attempt < this.config.maxRetries) {
415
+ const retryAfterHeader = response.headers.get("Retry-After");
416
+ let delayMs = this.config.retryDelayMs;
417
+
418
+ if (retryAfterHeader) {
419
+ const retryAfterSeconds = Number.parseInt(retryAfterHeader, 10);
420
+ if (!Number.isNaN(retryAfterSeconds) && retryAfterSeconds > 0) {
421
+ delayMs = retryAfterSeconds * 1_000;
422
+ }
423
+ } else {
424
+ // Exponential backoff: 2^attempt * baseDelay, capped at 30 seconds
425
+ delayMs = Math.min(2 ** attempt * this.config.retryDelayMs, 30_000);
426
+ }
427
+
428
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
429
+ return this.requestWithRetry<T>(method, path, options, attempt + 1);
430
+ }
431
+
245
432
  status = "error";
246
- const errorText = await response.text().catch(() => "Unknown error");
247
- errorMessage = errorText;
433
+ errorMessage = `HTTP ${response.status}`;
248
434
  throw new Error(
249
- `Storage API error: ${response.status} ${response.statusText} - ${errorText}`,
435
+ `Storage API error: Rate limit exceeded after ${this.config.maxRetries} retries`,
250
436
  );
251
437
  }
252
438
 
439
+ if (!response.ok) {
440
+ status = "error";
441
+ errorMessage = `HTTP ${response.status}`;
442
+ throw new Error(`Storage API error: ${response.status} ${response.statusText}`);
443
+ }
444
+
253
445
  if (response.status === 204) {
254
446
  return undefined as T;
255
447
  }
@@ -293,6 +485,7 @@ export class StorageClient {
293
485
  readonly resumeToken: string | null;
294
486
  readonly pqc: PqcMetadata;
295
487
  }> {
488
+ await this.ensureActivated();
296
489
  const result = await this.request<{
297
490
  uploadId: string;
298
491
  documentId: string;
@@ -335,14 +528,13 @@ export class StorageClient {
335
528
  partId: number,
336
529
  data: ReadableStream<Uint8Array> | Buffer | Uint8Array,
337
530
  ): Promise<UploadPartResult> {
531
+ await this.ensureActivated();
338
532
  const url = `${this.config.baseUrl}/storage/v1/uploads/${uploadId}/parts/${partId}`;
339
533
  const headers: Record<string, string> = {
340
534
  "Content-Type": "application/octet-stream",
341
535
  };
342
536
 
343
- if (this.config.apiKey) {
344
- headers["Authorization"] = `Bearer ${this.config.apiKey}`;
345
- }
537
+ headers["Authorization"] = `Bearer ${this.config.apiKey}`;
346
538
 
347
539
  const bytesSent =
348
540
  data instanceof Buffer || data instanceof Uint8Array ? data.byteLength : undefined;
@@ -385,11 +577,8 @@ export class StorageClient {
385
577
 
386
578
  if (!response.ok) {
387
579
  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
- );
580
+ errorMessage = `HTTP ${response.status}`;
581
+ throw new Error(`Upload part error: ${response.status} ${response.statusText}`);
393
582
  }
394
583
 
395
584
  return (await response.json()) as UploadPartResult;
@@ -422,6 +611,8 @@ export class StorageClient {
422
611
  }
423
612
 
424
613
  async getUploadStatus(uploadId: string): Promise<UploadStatus> {
614
+ validateUUID(uploadId, "uploadId");
615
+ await this.ensureActivated();
425
616
  // Use GET since we need the full status object
426
617
  return this.request<UploadStatus>("GET", `/storage/v1/uploads/${uploadId}`, {
427
618
  operation: "getUploadStatus",
@@ -430,6 +621,8 @@ export class StorageClient {
430
621
  }
431
622
 
432
623
  async completeUpload(uploadId: string): Promise<CompleteUploadResult> {
624
+ validateUUID(uploadId, "uploadId");
625
+ await this.ensureActivated();
433
626
  return this.request("POST", `/storage/v1/uploads/${uploadId}/complete`, {
434
627
  operation: "completeUpload",
435
628
  telemetryRoute: "/storage/v1/uploads/:uploadId/complete",
@@ -445,6 +638,7 @@ export class StorageClient {
445
638
  readonly signature?: string | null;
446
639
  },
447
640
  ): Promise<DownloadDescriptor> {
641
+ await this.ensureActivated();
448
642
  const params = new URLSearchParams({
449
643
  tenantId: this.config.tenantId,
450
644
  });
@@ -486,6 +680,7 @@ export class StorageClient {
486
680
  readonly range?: { readonly start: number; readonly end: number };
487
681
  readonly checksumSha3: string;
488
682
  }> {
683
+ await this.ensureActivated();
489
684
  const params = new URLSearchParams({
490
685
  tenantId: this.config.tenantId,
491
686
  });
@@ -510,9 +705,7 @@ export class StorageClient {
510
705
  headers["Range"] = options.range;
511
706
  }
512
707
 
513
- if (this.config.apiKey) {
514
- headers["Authorization"] = `Bearer ${this.config.apiKey}`;
515
- }
708
+ headers["Authorization"] = `Bearer ${this.config.apiKey}`;
516
709
 
517
710
  const controller = new AbortController();
518
711
  const timeoutId = setTimeout(() => controller.abort(), this.config.timeoutMs);
@@ -535,9 +728,8 @@ export class StorageClient {
535
728
 
536
729
  if (!response.ok) {
537
730
  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}`);
731
+ errorMessage = `HTTP ${response.status}`;
732
+ throw new Error(`Download error: ${response.status} ${response.statusText}`);
541
733
  }
542
734
 
543
735
  const contentRange = response.headers.get("Content-Range");
@@ -612,6 +804,8 @@ export class StorageClient {
612
804
  * Requires x-tenant-id header.
613
805
  */
614
806
  async getDocumentPolicies(documentId: string): Promise<DocumentPolicies> {
807
+ validateUUID(documentId, "documentId");
808
+ await this.ensureActivated();
615
809
  return this.request("GET", `/storage/v1/documents/${documentId}/policies`, {
616
810
  operation: "getDocumentPolicies",
617
811
  telemetryRoute: "/storage/v1/documents/:documentId/policies",
@@ -629,6 +823,8 @@ export class StorageClient {
629
823
  documentId: string,
630
824
  input: UpdatePoliciesRequest,
631
825
  ): Promise<DocumentPolicies> {
826
+ validateUUID(documentId, "documentId");
827
+ await this.ensureActivated();
632
828
  return this.request("PATCH", `/storage/v1/documents/${documentId}/policies`, {
633
829
  body: input,
634
830
  operation: "updateDocumentPolicies",
@@ -651,6 +847,8 @@ export class StorageClient {
651
847
  readonly tenantId: string;
652
848
  readonly legalHolds: readonly string[];
653
849
  }> {
850
+ validateUUID(documentId, "documentId");
851
+ await this.ensureActivated();
654
852
  return this.request("POST", `/storage/v1/documents/${documentId}/legal-holds`, {
655
853
  body: request,
656
854
  operation: "applyLegalHold",
@@ -666,6 +864,8 @@ export class StorageClient {
666
864
  * Requires x-tenant-id header.
667
865
  */
668
866
  async releaseLegalHold(documentId: string, holdId: string): Promise<void> {
867
+ validateUUID(documentId, "documentId");
868
+ await this.ensureActivated();
669
869
  return this.request("DELETE", `/storage/v1/documents/${documentId}/legal-holds/${holdId}`, {
670
870
  operation: "releaseLegalHold",
671
871
  telemetryRoute: "/storage/v1/documents/:documentId/legal-holds/:holdId",
@@ -691,6 +891,8 @@ export class StorageClient {
691
891
  readonly transitionAfter?: string | null;
692
892
  };
693
893
  }> {
894
+ validateUUID(documentId, "documentId");
895
+ await this.ensureActivated();
694
896
  return this.request("POST", `/storage/v1/documents/${documentId}/lifecycle/transitions`, {
695
897
  body: request,
696
898
  operation: "scheduleLifecycleTransition",
@@ -711,3 +913,4 @@ export class StorageClient {
711
913
 
712
914
  export * from "./events.js";
713
915
  export * from "./observability.js";
916
+ 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
+ }