@qnsp/storage-sdk 0.3.1 → 0.3.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/src/index.ts DELETED
@@ -1,916 +0,0 @@
1
- import { activateSdk, type SdkActivationConfig } from "@qnsp/sdk-activation";
2
-
3
- import type {
4
- StorageClientTelemetry,
5
- StorageClientTelemetryConfig,
6
- StorageClientTelemetryEvent,
7
- } from "./observability.js";
8
- import { createStorageClientTelemetry, isStorageClientTelemetry } from "./observability.js";
9
- import { validateUUID } from "./validation.js";
10
-
11
- /**
12
- * @qnsp/storage-sdk
13
- *
14
- * TypeScript SDK client for the QNSP storage-service API.
15
- * Provides a high-level interface for document upload, download, and management operations.
16
- * All cryptographic operations use tenant-specific PQC algorithms based on crypto policy.
17
- */
18
-
19
- /**
20
- * PQC metadata for cryptographic operations.
21
- */
22
- export interface PqcMetadata {
23
- readonly provider: string;
24
- readonly algorithm: string;
25
- readonly algorithmNist?: string;
26
- readonly keyId: string;
27
- }
28
-
29
- /**
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
33
- */
34
- export const ALGORITHM_TO_NIST: Record<string, string> = {
35
- // FIPS 203 — ML-KEM
36
- "kyber-512": "ML-KEM-512",
37
- "kyber-768": "ML-KEM-768",
38
- "kyber-1024": "ML-KEM-1024",
39
- // FIPS 204 — ML-DSA
40
- "dilithium-2": "ML-DSA-44",
41
- "dilithium-3": "ML-DSA-65",
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",
140
- };
141
-
142
- /**
143
- * Convert internal algorithm name to NIST standardized name.
144
- */
145
- export function toNistAlgorithmName(algorithm: string): string {
146
- return ALGORITHM_TO_NIST[algorithm] ?? algorithm;
147
- }
148
-
149
- export interface StorageClientConfig {
150
- readonly baseUrl: string;
151
- readonly apiKey: string;
152
- readonly tenantId: string;
153
- readonly timeoutMs?: number;
154
- readonly maxRetries?: number;
155
- readonly retryDelayMs?: number;
156
- readonly telemetry?: StorageClientTelemetry | StorageClientTelemetryConfig;
157
- }
158
-
159
- type InternalStorageClientConfig = {
160
- readonly baseUrl: string;
161
- readonly apiKey: string;
162
- readonly tenantId: string;
163
- readonly timeoutMs: number;
164
- readonly maxRetries: number;
165
- readonly retryDelayMs: number;
166
- };
167
-
168
- export interface InitiateUploadOptions {
169
- readonly name: string;
170
- readonly mimeType: string;
171
- readonly sizeBytes: number;
172
- readonly classification?: string;
173
- readonly metadata?: Record<string, unknown>;
174
- readonly tags?: readonly string[];
175
- readonly retentionPolicy?: {
176
- readonly mode?: "compliance" | "governance" | null;
177
- readonly retainUntil?: string | null;
178
- readonly legalHolds?: readonly string[];
179
- };
180
- }
181
-
182
- export interface UploadPartResult {
183
- readonly uploadId: string;
184
- readonly partId: number;
185
- readonly status: string;
186
- readonly sizeBytes: number;
187
- readonly checksumSha3: string;
188
- readonly retries: number;
189
- readonly totalParts: number;
190
- readonly completedParts: number;
191
- readonly bytesReceived: number;
192
- readonly lastPartNumber: number;
193
- readonly resumeToken: string | null;
194
- readonly scan?: {
195
- readonly status: string;
196
- readonly signature: string | null;
197
- readonly engine: string | null;
198
- };
199
- }
200
-
201
- export interface CompleteUploadResult {
202
- readonly documentId: string;
203
- readonly tenantId: string;
204
- readonly version: number;
205
- readonly sizeBytes: number;
206
- readonly checksumSha3: string;
207
- readonly parts: readonly {
208
- readonly partId: number;
209
- readonly checksumSha3: string;
210
- readonly sizeBytes: number;
211
- }[];
212
- readonly downloadManifest: unknown;
213
- readonly cdnDownload?: {
214
- readonly url: string;
215
- readonly expiresAt: string;
216
- readonly token: string;
217
- } | null;
218
- }
219
-
220
- export interface DownloadDescriptor {
221
- readonly documentId: string;
222
- readonly tenantId: string;
223
- readonly version: number;
224
- readonly sizeBytes: number;
225
- readonly checksumSha3: string;
226
- readonly manifest: unknown;
227
- }
228
-
229
- export interface UploadStatus {
230
- readonly uploadId: string;
231
- readonly documentId: string;
232
- readonly tenantId: string;
233
- readonly status: "pending" | "committed" | "aborted" | "quarantined";
234
- readonly totalParts: number;
235
- readonly completedParts: number;
236
- readonly bytesReceived: number;
237
- readonly lastPartNumber: number;
238
- readonly chunkSizeBytes: number;
239
- readonly totalSizeBytes: number;
240
- readonly expiresAt: string;
241
- readonly resumeToken: string | null;
242
- readonly createdAt: string;
243
- readonly updatedAt: string;
244
- }
245
-
246
- export interface DocumentPolicies {
247
- readonly documentId: string;
248
- readonly tenantId: string;
249
- readonly compliance: {
250
- readonly retentionMode: "compliance" | "governance" | null;
251
- readonly retainUntil: string | null;
252
- readonly legalHolds: readonly string[];
253
- readonly wormLockExpiresAt: string | null;
254
- };
255
- readonly lifecycle?: {
256
- readonly currentTier?: "hot" | "warm" | "cold" | "frozen";
257
- readonly targetTier?: "hot" | "warm" | "cold" | "frozen" | null;
258
- readonly transitionAfter?: string | null;
259
- };
260
- }
261
-
262
- export interface UpdatePoliciesRequest {
263
- readonly retentionMode?: "compliance" | "governance";
264
- readonly retainUntil?: string;
265
- readonly legalHolds?: readonly string[];
266
- readonly wormLockExpiresAt?: string;
267
- }
268
-
269
- export interface ApplyLegalHoldRequest {
270
- readonly holdId: string;
271
- }
272
-
273
- export interface ScheduleTransitionRequest {
274
- readonly targetTier: "hot" | "warm" | "cold" | "frozen";
275
- readonly transitionAfter: string;
276
- }
277
-
278
- interface RequestOptions {
279
- readonly body?: unknown;
280
- readonly headers?: Record<string, string>;
281
- readonly signal?: AbortSignal;
282
- readonly operation?: string;
283
- readonly telemetryRoute?: string;
284
- readonly telemetryTarget?: string;
285
- }
286
-
287
- export class StorageClient {
288
- private readonly config: InternalStorageClientConfig;
289
- private readonly telemetry: StorageClientTelemetry | null;
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
- }
300
-
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
-
339
- this.config = {
340
- baseUrl,
341
- apiKey: config.apiKey,
342
- tenantId: config.tenantId,
343
- timeoutMs: config.timeoutMs ?? 30_000,
344
- maxRetries: config.maxRetries ?? 3,
345
- retryDelayMs: config.retryDelayMs ?? 1_000,
346
- };
347
-
348
- this.telemetry = config.telemetry
349
- ? isStorageClientTelemetry(config.telemetry)
350
- ? config.telemetry
351
- : createStorageClientTelemetry(config.telemetry)
352
- : null;
353
-
354
- try {
355
- this.targetService = new URL(this.config.baseUrl).host;
356
- } catch {
357
- this.targetService = "storage-service";
358
- }
359
-
360
- this.activationConfig = {
361
- apiKey: config.apiKey,
362
- sdkId: "storage-sdk",
363
- sdkVersion: "0.3.0",
364
- platformUrl: config.baseUrl,
365
- };
366
- }
367
-
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> {
378
- const url = `${this.config.baseUrl}${path}`;
379
- const headers: Record<string, string> = {
380
- "Content-Type": "application/json",
381
- ...options?.headers,
382
- };
383
-
384
- headers["Authorization"] = `Bearer ${this.config.apiKey}`;
385
-
386
- const controller = new AbortController();
387
- const timeoutId = setTimeout(() => controller.abort(), this.config.timeoutMs);
388
- const signal = options?.signal ?? controller.signal;
389
- const route = options?.telemetryRoute ?? new URL(path, this.config.baseUrl).pathname;
390
- const target = options?.telemetryTarget ?? this.targetService;
391
- const start = performance.now();
392
- let status: "ok" | "error" = "ok";
393
- let httpStatus: number | undefined;
394
- let errorMessage: string | undefined;
395
-
396
- try {
397
- const init: RequestInit = {
398
- method,
399
- headers,
400
- signal,
401
- };
402
-
403
- if (options?.body !== undefined) {
404
- init.body = JSON.stringify(options.body);
405
- }
406
-
407
- const response = await fetch(url, init);
408
-
409
- clearTimeout(timeoutId);
410
- httpStatus = response.status;
411
-
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
-
432
- status = "error";
433
- errorMessage = `HTTP ${response.status}`;
434
- throw new Error(
435
- `Storage API error: Rate limit exceeded after ${this.config.maxRetries} retries`,
436
- );
437
- }
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
-
445
- if (response.status === 204) {
446
- return undefined as T;
447
- }
448
-
449
- return (await response.json()) as T;
450
- } catch (error) {
451
- clearTimeout(timeoutId);
452
- status = "error";
453
- if (!errorMessage && error instanceof Error) {
454
- errorMessage = error.message;
455
- }
456
- if (error instanceof Error && error.name === "AbortError") {
457
- errorMessage = `timeout after ${this.config.timeoutMs}ms`;
458
- throw new Error(`Request timeout after ${this.config.timeoutMs}ms`);
459
- }
460
- throw error;
461
- } finally {
462
- const durationMs = performance.now() - start;
463
- const event: StorageClientTelemetryEvent = {
464
- operation: options?.operation ?? `${method} ${route}`,
465
- method,
466
- route,
467
- target,
468
- status,
469
- durationMs,
470
- ...(typeof httpStatus === "number" ? { httpStatus } : {}),
471
- ...(status === "error" && errorMessage ? { error: errorMessage } : {}),
472
- };
473
- this.recordTelemetryEvent(event);
474
- }
475
- }
476
-
477
- async initiateUpload(options: InitiateUploadOptions): Promise<{
478
- readonly uploadId: string;
479
- readonly documentId: string;
480
- readonly tenantId: string;
481
- readonly chunkSizeBytes: number;
482
- readonly totalSizeBytes: number;
483
- readonly totalParts: number;
484
- readonly expiresAt: string;
485
- readonly resumeToken: string | null;
486
- readonly pqc: PqcMetadata;
487
- }> {
488
- await this.ensureActivated();
489
- const result = await this.request<{
490
- uploadId: string;
491
- documentId: string;
492
- tenantId: string;
493
- chunkSizeBytes: number;
494
- totalSizeBytes: number;
495
- totalParts: number;
496
- expiresAt: string;
497
- resumeToken: string | null;
498
- pqc: {
499
- provider: string;
500
- algorithm: string;
501
- keyId: string;
502
- };
503
- }>("POST", "/storage/v1/documents", {
504
- body: {
505
- name: options.name,
506
- mimeType: options.mimeType,
507
- sizeBytes: options.sizeBytes,
508
- classification: options.classification ?? "confidential",
509
- metadata: options.metadata ?? {},
510
- tags: options.tags ?? [],
511
- retentionPolicy: options.retentionPolicy,
512
- },
513
- operation: "initiateUpload",
514
- });
515
-
516
- // Enrich PQC metadata with NIST algorithm name
517
- return {
518
- ...result,
519
- pqc: {
520
- ...result.pqc,
521
- algorithmNist: toNistAlgorithmName(result.pqc.algorithm),
522
- },
523
- };
524
- }
525
-
526
- async uploadPart(
527
- uploadId: string,
528
- partId: number,
529
- data: ReadableStream<Uint8Array> | Buffer | Uint8Array,
530
- ): Promise<UploadPartResult> {
531
- await this.ensureActivated();
532
- const url = `${this.config.baseUrl}/storage/v1/uploads/${uploadId}/parts/${partId}`;
533
- const headers: Record<string, string> = {
534
- "Content-Type": "application/octet-stream",
535
- };
536
-
537
- headers["Authorization"] = `Bearer ${this.config.apiKey}`;
538
-
539
- const bytesSent =
540
- data instanceof Buffer || data instanceof Uint8Array ? data.byteLength : undefined;
541
- const route = "/storage/v1/uploads/:uploadId/parts/:partId";
542
- const start = performance.now();
543
- let status: "ok" | "error" = "ok";
544
- let httpStatus: number | undefined;
545
- let errorMessage: string | undefined;
546
-
547
- const body =
548
- data instanceof ReadableStream
549
- ? data
550
- : data instanceof Buffer
551
- ? new ReadableStream({
552
- start(controller) {
553
- controller.enqueue(new Uint8Array(data));
554
- controller.close();
555
- },
556
- })
557
- : new ReadableStream({
558
- start(controller) {
559
- controller.enqueue(data);
560
- controller.close();
561
- },
562
- });
563
-
564
- const controller = new AbortController();
565
- const timeoutId = setTimeout(() => controller.abort(), this.config.timeoutMs);
566
-
567
- try {
568
- const response = await fetch(url, {
569
- method: "PUT",
570
- headers,
571
- body,
572
- signal: controller.signal,
573
- });
574
-
575
- clearTimeout(timeoutId);
576
- httpStatus = response.status;
577
-
578
- if (!response.ok) {
579
- status = "error";
580
- errorMessage = `HTTP ${response.status}`;
581
- throw new Error(`Upload part error: ${response.status} ${response.statusText}`);
582
- }
583
-
584
- return (await response.json()) as UploadPartResult;
585
- } catch (error) {
586
- clearTimeout(timeoutId);
587
- status = "error";
588
- if (!errorMessage && error instanceof Error) {
589
- errorMessage = error.message;
590
- }
591
- if (error instanceof Error && error.name === "AbortError") {
592
- errorMessage = `timeout after ${this.config.timeoutMs}ms`;
593
- throw new Error(`Request timeout after ${this.config.timeoutMs}ms`);
594
- }
595
- throw error;
596
- } finally {
597
- const durationMs = performance.now() - start;
598
- const event: StorageClientTelemetryEvent = {
599
- operation: "uploadPart",
600
- method: "PUT",
601
- route,
602
- target: this.targetService,
603
- status,
604
- durationMs,
605
- ...(typeof httpStatus === "number" ? { httpStatus } : {}),
606
- ...(status === "error" && errorMessage ? { error: errorMessage } : {}),
607
- ...(typeof bytesSent === "number" ? { bytesSent } : {}),
608
- };
609
- this.recordTelemetryEvent(event);
610
- }
611
- }
612
-
613
- async getUploadStatus(uploadId: string): Promise<UploadStatus> {
614
- validateUUID(uploadId, "uploadId");
615
- await this.ensureActivated();
616
- // Use GET since we need the full status object
617
- return this.request<UploadStatus>("GET", `/storage/v1/uploads/${uploadId}`, {
618
- operation: "getUploadStatus",
619
- telemetryRoute: "/storage/v1/uploads/:uploadId",
620
- });
621
- }
622
-
623
- async completeUpload(uploadId: string): Promise<CompleteUploadResult> {
624
- validateUUID(uploadId, "uploadId");
625
- await this.ensureActivated();
626
- return this.request("POST", `/storage/v1/uploads/${uploadId}/complete`, {
627
- operation: "completeUpload",
628
- telemetryRoute: "/storage/v1/uploads/:uploadId/complete",
629
- });
630
- }
631
-
632
- async getDownloadDescriptor(
633
- documentId: string,
634
- version: number,
635
- options?: {
636
- readonly token?: string | null;
637
- readonly expiresAt?: number | null;
638
- readonly signature?: string | null;
639
- },
640
- ): Promise<DownloadDescriptor> {
641
- await this.ensureActivated();
642
- const params = new URLSearchParams({
643
- tenantId: this.config.tenantId,
644
- });
645
-
646
- if (options?.token) {
647
- params.set("token", options.token);
648
- }
649
- if (options?.expiresAt) {
650
- params.set("expiresAt", String(options.expiresAt));
651
- }
652
- if (options?.signature) {
653
- params.set("signature", options.signature);
654
- }
655
-
656
- return this.request(
657
- "GET",
658
- `/storage/v1/documents/${documentId}/versions/${version}/download?${params}`,
659
- {
660
- operation: "getDownloadDescriptor",
661
- telemetryRoute: "/storage/v1/documents/:documentId/versions/:version/download",
662
- },
663
- );
664
- }
665
-
666
- async downloadStream(
667
- documentId: string,
668
- version: number,
669
- options?: {
670
- readonly token?: string | null;
671
- readonly expiresAt?: number | null;
672
- readonly signature?: string | null;
673
- readonly range?: string | null;
674
- },
675
- ): Promise<{
676
- readonly stream: ReadableStream<Uint8Array>;
677
- readonly statusCode: 200 | 206;
678
- readonly totalSize: number;
679
- readonly contentLength: number;
680
- readonly range?: { readonly start: number; readonly end: number };
681
- readonly checksumSha3: string;
682
- }> {
683
- await this.ensureActivated();
684
- const params = new URLSearchParams({
685
- tenantId: this.config.tenantId,
686
- });
687
-
688
- if (options?.token) {
689
- params.set("token", options.token);
690
- }
691
- if (options?.expiresAt) {
692
- params.set("expiresAt", String(options.expiresAt));
693
- }
694
- if (options?.signature) {
695
- params.set("signature", options.signature);
696
- }
697
- if (options?.range) {
698
- params.set("range", options.range);
699
- }
700
-
701
- const url = `${this.config.baseUrl}/storage/v1/documents/${documentId}/versions/${version}/content?${params}`;
702
- const headers: Record<string, string> = {};
703
-
704
- if (options?.range) {
705
- headers["Range"] = options.range;
706
- }
707
-
708
- headers["Authorization"] = `Bearer ${this.config.apiKey}`;
709
-
710
- const controller = new AbortController();
711
- const timeoutId = setTimeout(() => controller.abort(), this.config.timeoutMs);
712
- const route = "/storage/v1/documents/:documentId/versions/:version/content";
713
- const start = performance.now();
714
- let status: "ok" | "error" = "ok";
715
- let httpStatus: number | undefined;
716
- let errorMessage: string | undefined;
717
- let bytesReceived: number | undefined;
718
-
719
- try {
720
- const response = await fetch(url, {
721
- method: "GET",
722
- headers,
723
- signal: controller.signal,
724
- });
725
-
726
- clearTimeout(timeoutId);
727
- httpStatus = response.status;
728
-
729
- if (!response.ok) {
730
- status = "error";
731
- errorMessage = `HTTP ${response.status}`;
732
- throw new Error(`Download error: ${response.status} ${response.statusText}`);
733
- }
734
-
735
- const contentRange = response.headers.get("Content-Range");
736
- const rangeMatch = contentRange?.startsWith("bytes ")
737
- ? contentRange.match(/bytes (\d+)-(\d+)\/(\d+)/)
738
- : null;
739
-
740
- const parsedRange =
741
- rangeMatch?.[1] && rangeMatch[2]
742
- ? {
743
- start: Number.parseInt(rangeMatch[1], 10),
744
- end: Number.parseInt(rangeMatch[2], 10),
745
- }
746
- : undefined;
747
-
748
- const totalSize = rangeMatch?.[3]
749
- ? Number.parseInt(rangeMatch[3], 10)
750
- : Number.parseInt(response.headers.get("X-Total-Size") ?? "0", 10);
751
- const contentLength = Number.parseInt(response.headers.get("Content-Length") ?? "0", 10);
752
- const checksumSha3 = response.headers.get("X-Checksum-Sha3") ?? "";
753
-
754
- const stream =
755
- response.body ??
756
- new ReadableStream<Uint8Array>({
757
- start(controller) {
758
- controller.close();
759
- },
760
- });
761
-
762
- bytesReceived = contentLength > 0 ? contentLength : totalSize > 0 ? totalSize : undefined;
763
-
764
- const statusCode = (response.status === 206 ? 206 : 200) as 200 | 206;
765
-
766
- return {
767
- stream,
768
- statusCode,
769
- totalSize: totalSize > 0 ? totalSize : contentLength,
770
- contentLength: contentLength > 0 ? contentLength : totalSize,
771
- checksumSha3,
772
- ...(parsedRange ? { range: parsedRange } : {}),
773
- };
774
- } catch (error) {
775
- clearTimeout(timeoutId);
776
- status = "error";
777
- if (!errorMessage && error instanceof Error) {
778
- errorMessage = error.message;
779
- }
780
- if (error instanceof Error && error.name === "AbortError") {
781
- errorMessage = `timeout after ${this.config.timeoutMs}ms`;
782
- throw new Error(`Request timeout after ${this.config.timeoutMs}ms`);
783
- }
784
- throw error;
785
- } finally {
786
- const durationMs = performance.now() - start;
787
- const event: StorageClientTelemetryEvent = {
788
- operation: "downloadStream",
789
- method: "GET",
790
- route,
791
- target: this.targetService,
792
- status,
793
- durationMs,
794
- ...(typeof httpStatus === "number" ? { httpStatus } : {}),
795
- ...(status === "error" && errorMessage ? { error: errorMessage } : {}),
796
- ...(typeof bytesReceived === "number" ? { bytesReceived } : {}),
797
- };
798
- this.recordTelemetryEvent(event);
799
- }
800
- }
801
-
802
- /**
803
- * Retrieve document policies (compliance + lifecycle summary).
804
- * Requires x-tenant-id header.
805
- */
806
- async getDocumentPolicies(documentId: string): Promise<DocumentPolicies> {
807
- validateUUID(documentId, "documentId");
808
- await this.ensureActivated();
809
- return this.request("GET", `/storage/v1/documents/${documentId}/policies`, {
810
- operation: "getDocumentPolicies",
811
- telemetryRoute: "/storage/v1/documents/:documentId/policies",
812
- headers: {
813
- "x-tenant-id": this.config.tenantId,
814
- },
815
- });
816
- }
817
-
818
- /**
819
- * Update document retention/WORM/legal hold list atomically.
820
- * Requires x-tenant-id header.
821
- */
822
- async updateDocumentPolicies(
823
- documentId: string,
824
- input: UpdatePoliciesRequest,
825
- ): Promise<DocumentPolicies> {
826
- validateUUID(documentId, "documentId");
827
- await this.ensureActivated();
828
- return this.request("PATCH", `/storage/v1/documents/${documentId}/policies`, {
829
- body: input,
830
- operation: "updateDocumentPolicies",
831
- telemetryRoute: "/storage/v1/documents/:documentId/policies",
832
- headers: {
833
- "x-tenant-id": this.config.tenantId,
834
- },
835
- });
836
- }
837
-
838
- /**
839
- * Apply a legal hold by id. Hold ids are caller-defined.
840
- * Requires x-tenant-id header.
841
- */
842
- async applyLegalHold(
843
- documentId: string,
844
- request: ApplyLegalHoldRequest,
845
- ): Promise<{
846
- readonly documentId: string;
847
- readonly tenantId: string;
848
- readonly legalHolds: readonly string[];
849
- }> {
850
- validateUUID(documentId, "documentId");
851
- await this.ensureActivated();
852
- return this.request("POST", `/storage/v1/documents/${documentId}/legal-holds`, {
853
- body: request,
854
- operation: "applyLegalHold",
855
- telemetryRoute: "/storage/v1/documents/:documentId/legal-holds",
856
- headers: {
857
- "x-tenant-id": this.config.tenantId,
858
- },
859
- });
860
- }
861
-
862
- /**
863
- * Release a legal hold by id.
864
- * Requires x-tenant-id header.
865
- */
866
- async releaseLegalHold(documentId: string, holdId: string): Promise<void> {
867
- validateUUID(documentId, "documentId");
868
- await this.ensureActivated();
869
- return this.request("DELETE", `/storage/v1/documents/${documentId}/legal-holds/${holdId}`, {
870
- operation: "releaseLegalHold",
871
- telemetryRoute: "/storage/v1/documents/:documentId/legal-holds/:holdId",
872
- headers: {
873
- "x-tenant-id": this.config.tenantId,
874
- },
875
- });
876
- }
877
-
878
- /**
879
- * Schedule a lifecycle tier transition for a document.
880
- * Requires x-tenant-id header.
881
- */
882
- async scheduleLifecycleTransition(
883
- documentId: string,
884
- request: ScheduleTransitionRequest,
885
- ): Promise<{
886
- readonly documentId: string;
887
- readonly tenantId: string;
888
- readonly lifecycle: {
889
- readonly currentTier?: "hot" | "warm" | "cold" | "frozen";
890
- readonly targetTier?: "hot" | "warm" | "cold" | "frozen" | null;
891
- readonly transitionAfter?: string | null;
892
- };
893
- }> {
894
- validateUUID(documentId, "documentId");
895
- await this.ensureActivated();
896
- return this.request("POST", `/storage/v1/documents/${documentId}/lifecycle/transitions`, {
897
- body: request,
898
- operation: "scheduleLifecycleTransition",
899
- telemetryRoute: "/storage/v1/documents/:documentId/lifecycle/transitions",
900
- headers: {
901
- "x-tenant-id": this.config.tenantId,
902
- },
903
- });
904
- }
905
-
906
- private recordTelemetryEvent(event: StorageClientTelemetryEvent): void {
907
- if (!this.telemetry) {
908
- return;
909
- }
910
- this.telemetry.record(event);
911
- }
912
- }
913
-
914
- export * from "./events.js";
915
- export * from "./observability.js";
916
- export * from "./validation.js";