@lossless.org/objectstorage 9.0.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/ts/index.ts ADDED
@@ -0,0 +1,1932 @@
1
+ import * as plugins from './plugins.js';
2
+ import * as paths from './paths.js';
3
+
4
+ /**
5
+ * Authentication configuration
6
+ */
7
+ export interface IStorageCredential {
8
+ accessKeyId: string;
9
+ secretAccessKey: string;
10
+ bucketName?: string;
11
+ region?: string;
12
+ }
13
+
14
+ export interface IStorageCredentialMetadata {
15
+ accessKeyId: string;
16
+ bucketName?: string;
17
+ region?: string;
18
+ }
19
+
20
+ /**
21
+ * Authentication configuration
22
+ */
23
+ export interface IAuthConfig {
24
+ enabled: boolean;
25
+ credentials: IStorageCredential[];
26
+ }
27
+
28
+ /**
29
+ * CORS configuration
30
+ */
31
+ export interface ICorsConfig {
32
+ enabled: boolean;
33
+ allowedOrigins?: string[];
34
+ allowedMethods?: string[];
35
+ allowedHeaders?: string[];
36
+ exposedHeaders?: string[];
37
+ maxAge?: number;
38
+ allowCredentials?: boolean;
39
+ }
40
+
41
+ /**
42
+ * Logging configuration
43
+ */
44
+ export interface ILoggingConfig {
45
+ level?: 'error' | 'warn' | 'info' | 'debug';
46
+ format?: 'text' | 'json';
47
+ enabled?: boolean;
48
+ }
49
+
50
+ /**
51
+ * Request limits configuration
52
+ */
53
+ export interface ILimitsConfig {
54
+ maxObjectSize?: number;
55
+ maxMetadataSize?: number;
56
+ requestTimeout?: number;
57
+ }
58
+
59
+ /**
60
+ * Multipart upload configuration
61
+ */
62
+ export interface IMultipartConfig {
63
+ expirationDays?: number;
64
+ cleanupIntervalMinutes?: number;
65
+ }
66
+
67
+ /**
68
+ * Server configuration
69
+ */
70
+ export interface IServerConfig {
71
+ port?: number;
72
+ address?: string;
73
+ silent?: boolean;
74
+ region?: string;
75
+ }
76
+
77
+ /**
78
+ * Storage configuration
79
+ */
80
+ export interface ILocalFilesystemStoragePoolBackend {
81
+ kind: 'localFs';
82
+ }
83
+
84
+ export interface IMountedFilesystemStoragePoolBackend {
85
+ kind: 'mountedFs';
86
+ expectedFilesystemType: 'nfs' | 'smb';
87
+ expectedSource: string;
88
+ }
89
+
90
+ export type TStoragePoolBackend =
91
+ | ILocalFilesystemStoragePoolBackend
92
+ | IMountedFilesystemStoragePoolBackend;
93
+
94
+ export interface IStoragePoolConfig {
95
+ id: string;
96
+ directory: string;
97
+ backend: TStoragePoolBackend;
98
+ }
99
+
100
+ export interface IMountedStoragePoolConfig extends IStoragePoolConfig {
101
+ backend: IMountedFilesystemStoragePoolBackend;
102
+ }
103
+
104
+ export interface IStorageConfig {
105
+ directory?: string;
106
+ cleanSlate?: boolean;
107
+ /**
108
+ * One ObjectStorage process serves one pool. `directory` remains as a
109
+ * compatibility alias and must resolve to the same path when both are set.
110
+ */
111
+ pool?: IStoragePoolConfig;
112
+ }
113
+
114
+ /**
115
+ * Erasure coding configuration
116
+ */
117
+ export interface IErasureConfig {
118
+ dataShards?: number;
119
+ parityShards?: number;
120
+ chunkSizeBytes?: number;
121
+ }
122
+
123
+ /**
124
+ * Drive configuration for multi-drive support
125
+ */
126
+ export interface IDriveConfig {
127
+ paths: string[];
128
+ }
129
+
130
+ /**
131
+ * Cluster configuration for distributed mode
132
+ */
133
+ export interface IClusterConfig {
134
+ enabled: boolean;
135
+ nodeId?: string;
136
+ quicPort?: number;
137
+ seedNodes?: string[];
138
+ erasure?: IErasureConfig;
139
+ drives?: IDriveConfig;
140
+ heartbeatIntervalMs?: number;
141
+ heartbeatTimeoutMs?: number;
142
+ }
143
+
144
+ /**
145
+ * Complete objectstorage configuration
146
+ */
147
+ export interface IObjectStorageConfig {
148
+ server?: IServerConfig;
149
+ storage?: IStorageConfig;
150
+ auth?: IAuthConfig;
151
+ cors?: ICorsConfig;
152
+ logging?: ILoggingConfig;
153
+ limits?: ILimitsConfig;
154
+ multipart?: IMultipartConfig;
155
+ cluster?: IClusterConfig;
156
+ }
157
+
158
+ /**
159
+ * Logical bucket stats maintained by the Rust runtime.
160
+ * Values are initialized from native storage on startup and updated on objectstorage mutations.
161
+ */
162
+ export interface IBucketSummary {
163
+ name: string;
164
+ objectCount: number;
165
+ totalSizeBytes: number;
166
+ creationDate?: number;
167
+ }
168
+
169
+ /**
170
+ * Filesystem-level capacity snapshot for the storage directory or configured drive path.
171
+ */
172
+ export interface IStorageLocationSummary {
173
+ path: string;
174
+ totalBytes?: number;
175
+ availableBytes?: number;
176
+ usedBytes?: number;
177
+ pool?: IStoragePoolObservation;
178
+ }
179
+
180
+ export type TStoragePoolCheckStatus =
181
+ | 'passed'
182
+ | 'failed'
183
+ | 'notRun'
184
+ | 'notApplicable';
185
+
186
+ export interface IStoragePoolCheck {
187
+ status: TStoragePoolCheckStatus;
188
+ reason?: string;
189
+ }
190
+
191
+ export interface IStoragePoolChecks {
192
+ directoryExists: IStoragePoolCheck;
193
+ exactMount: IStoragePoolCheck;
194
+ filesystemType: IStoragePoolCheck;
195
+ source: IStoragePoolCheck;
196
+ symlinkConfinement: IStoragePoolCheck;
197
+ remoteLocking: IStoragePoolCheck;
198
+ rootOwnership: IStoragePoolCheck;
199
+ sameFilesystem: IStoragePoolCheck;
200
+ lockContention: IStoragePoolCheck;
201
+ atomicDirectoryExchange: IStoragePoolCheck;
202
+ directoryFsync: IStoragePoolCheck;
203
+ providerIdentity: IStoragePoolCheck;
204
+ mountStable: IStoragePoolCheck;
205
+ }
206
+
207
+ export interface IStoragePoolFilesystemObservation {
208
+ filesystemType?: string;
209
+ source?: string;
210
+ mountPoint?: string;
211
+ mountId?: number;
212
+ device?: string;
213
+ networkBacked: boolean;
214
+ blockSizeBytes?: number;
215
+ readSizeBytes?: number;
216
+ writeSizeBytes?: number;
217
+ symlinkFollowingDisabled?: boolean;
218
+ remoteLockingMode?:
219
+ | 'serverCoordinated'
220
+ | 'disabled'
221
+ | 'localOnly'
222
+ | 'unrecognized'
223
+ | 'notApplicable';
224
+ }
225
+
226
+ export type TStoragePoolFailureCode =
227
+ | 'pathUnavailable'
228
+ | 'mountInfoInvalid'
229
+ | 'exactMountRequired'
230
+ | 'filesystemTypeMismatch'
231
+ | 'sourceMismatch'
232
+ | 'mountChanged'
233
+ | 'descriptorMismatch'
234
+ | 'filesystemMagicMismatch'
235
+ | 'symlinkFollowingAllowed'
236
+ | 'lockingModeUnsafe'
237
+ | 'activationPoisoned';
238
+
239
+ export interface IStoragePoolObservation {
240
+ schemaVersion: 1;
241
+ poolId: string;
242
+ directory: string;
243
+ role: 'controlAndBlob';
244
+ backendKind: 'localFs' | 'mountedFs';
245
+ filesystem: IStoragePoolFilesystemObservation;
246
+ checks: IStoragePoolChecks;
247
+ supported: boolean | null;
248
+ productionEligible: boolean;
249
+ activationValid?: boolean;
250
+ poisoned?: boolean;
251
+ poisonReason?: string;
252
+ failure?: {
253
+ code: TStoragePoolFailureCode;
254
+ message: string;
255
+ };
256
+ }
257
+
258
+ /**
259
+ * Runtime storage stats served by the Rust core without issuing S3 list calls.
260
+ */
261
+ export interface IStorageStats {
262
+ bucketCount: number;
263
+ totalObjectCount: number;
264
+ totalStorageBytes: number;
265
+ buckets: IBucketSummary[];
266
+ storageDirectory: string;
267
+ storageLocations?: IStorageLocationSummary[];
268
+ }
269
+
270
+ export interface IStorageStopResult {
271
+ complete: true;
272
+ terminalError?: string;
273
+ }
274
+
275
+ export interface IBucketTenantInput {
276
+ bucketName: string;
277
+ accessKeyId?: string;
278
+ secretAccessKey?: string;
279
+ region?: string;
280
+ retention?: IBucketRetentionPolicyInput;
281
+ }
282
+
283
+ export interface IEnsureBucketTenantInput {
284
+ bucketName: string;
285
+ accessKeyId: string;
286
+ secretAccessKey: string;
287
+ region?: string;
288
+ retention?: IBucketRetentionPolicyInput;
289
+ fence?: IObjectStorageResourceFence;
290
+ }
291
+
292
+ export interface IDeleteBucketTenantInput {
293
+ bucketName: string;
294
+ accessKeyId?: string;
295
+ }
296
+
297
+ export interface IBucketTenantMetadata {
298
+ bucketName: string;
299
+ accessKeyId: string;
300
+ region?: string;
301
+ retention?: IBucketRetentionReceipt;
302
+ }
303
+
304
+ export interface IBucketRetentionPolicyInput {
305
+ mode: 'compliance';
306
+ policyId: string;
307
+ retentionDurationSeconds: number;
308
+ }
309
+
310
+ export interface IObjectRetentionReceipt {
311
+ mode: 'compliance';
312
+ policyId: string;
313
+ retentionDurationSeconds: number;
314
+ createdAt: number;
315
+ retainUntil: number;
316
+ payloadSha256: string;
317
+ metadataSha256: string;
318
+ etag: string;
319
+ }
320
+
321
+ export interface IBucketRetentionReceipt extends IBucketRetentionPolicyInput {
322
+ bucketName: string;
323
+ configuredAt: number;
324
+ sentinelKey: string;
325
+ sentinel: IObjectRetentionReceipt;
326
+ }
327
+
328
+ export interface IBucketRetentionCapability {
329
+ version: 1;
330
+ supported: boolean;
331
+ backend: 'standalone' | 'clustered';
332
+ modes: 'compliance'[];
333
+ atomicCreateOnly: boolean;
334
+ retainedMultipartSupported: false;
335
+ }
336
+
337
+ export interface IObjectStorageMigrationBinding {
338
+ migrationId: string;
339
+ authorityId: string;
340
+ logicalBucket: string;
341
+ sourceProviderIdentitySha256: string;
342
+ destinationProviderIdentitySha256: string;
343
+ destinationReceiptPublicKeyEd25519: string;
344
+ }
345
+
346
+ export type TObjectStorageSourceMigrationPhase =
347
+ | 'active'
348
+ | 'sealed'
349
+ | 'cutoverCommitted'
350
+ | 'retired'
351
+ | 'released';
352
+
353
+ export type TObjectStorageDestinationMigrationPhase =
354
+ | 'holding'
355
+ | 'verified'
356
+ | 'cutoverAuthorized'
357
+ | 'published'
358
+ | 'aborted';
359
+
360
+ export type TObjectStorageMigrationPhase =
361
+ | { role: 'source'; phase: TObjectStorageSourceMigrationPhase }
362
+ | { role: 'destination'; phase: TObjectStorageDestinationMigrationPhase };
363
+
364
+ export interface IObjectStorageMigrationCreateInput {
365
+ binding: IObjectStorageMigrationBinding;
366
+ fenceToken: number;
367
+ opaqueCapability: string;
368
+ /** Required for source creation; omitted for destination creation. */
369
+ sourceCleanupAccessKeyId?: string;
370
+ }
371
+
372
+ export interface IObjectStorageMigrationTerminalReceipt {
373
+ version: 1;
374
+ kind: 'smartstorage.bucket-migration.terminal-receipt.standalone-v1';
375
+ binding: IObjectStorageMigrationBinding;
376
+ providerIdentitySha256: string;
377
+ fenceToken: number;
378
+ requestDigestSha256: string;
379
+ phase: TObjectStorageMigrationPhase;
380
+ manifestDigestSha256?: string;
381
+ counterpartTerminalReceiptSha256?: string;
382
+ signingPublicKeyEd25519: string;
383
+ signatureEd25519: string;
384
+ receiptSha256: string;
385
+ }
386
+
387
+ export interface IObjectStorageMigrationTransitionReceipt {
388
+ version: 1;
389
+ kind: 'smartstorage.bucket-migration.transition-receipt.standalone-v1';
390
+ binding: IObjectStorageMigrationBinding;
391
+ providerIdentitySha256: string;
392
+ fenceToken: number;
393
+ requestDigestSha256: string;
394
+ phase: TObjectStorageMigrationPhase;
395
+ manifestDigestSha256?: string;
396
+ terminalReceiptSha256?: string;
397
+ receiptSha256: string;
398
+ }
399
+
400
+ export interface IObjectStorageSourceMigrationTransitionInput {
401
+ binding: IObjectStorageMigrationBinding;
402
+ fenceToken: number;
403
+ opaqueCapability: string;
404
+ targetPhase: Exclude<TObjectStorageSourceMigrationPhase, 'active'>;
405
+ manifestDigestSha256?: string;
406
+ destinationAbortReceipt?: IObjectStorageMigrationTerminalReceipt;
407
+ }
408
+
409
+ export interface IObjectStorageDestinationMigrationTransitionInput {
410
+ binding: IObjectStorageMigrationBinding;
411
+ fenceToken: number;
412
+ opaqueCapability: string;
413
+ targetPhase: Exclude<TObjectStorageDestinationMigrationPhase, 'holding'>;
414
+ manifestDigestSha256?: string;
415
+ }
416
+
417
+ export interface IObjectStorageSourceMigrationCleanupInput {
418
+ binding: IObjectStorageMigrationBinding;
419
+ cleanupFenceToken: number;
420
+ opaqueCapability: string;
421
+ sourceRetiredReceiptSha256: string;
422
+ destinationPublishedReceipt: IObjectStorageMigrationTerminalReceipt;
423
+ resourceFence: IObjectStorageResourceFence;
424
+ accessKeyId: string;
425
+ }
426
+
427
+ export interface IObjectStorageSourceMigrationCleanupReceipt {
428
+ version: 1;
429
+ kind: 'smartstorage.bucket-migration.source-cleanup-receipt.standalone-v1';
430
+ binding: IObjectStorageMigrationBinding;
431
+ providerIdentitySha256: string;
432
+ cleanupFenceToken: number;
433
+ requestDigestSha256: string;
434
+ sourceRetiredReceiptSha256: string;
435
+ destinationPublishedReceiptSha256: string;
436
+ resourceFenceScopeId: string;
437
+ resourceFenceToken: number;
438
+ resourceFenceMutationId: string;
439
+ resourceFencePayloadSha256: string;
440
+ accessKeyIdSha256: string;
441
+ deleteResultSha256: string;
442
+ receiptSha256: string;
443
+ }
444
+
445
+ export interface IObjectStorageSourceMigrationCleanupInspection {
446
+ phase: 'requested' | 'completed';
447
+ cleanupFenceToken: number;
448
+ requestDigestSha256: string;
449
+ sourceRetiredReceiptSha256: string;
450
+ destinationPublishedReceipt: IObjectStorageMigrationTerminalReceipt;
451
+ resourceFence: IObjectStorageResourceFence;
452
+ accessKeyIdSha256: string;
453
+ deleteResultSha256?: string;
454
+ receipt?: IObjectStorageSourceMigrationCleanupReceipt;
455
+ }
456
+
457
+ export interface IObjectStorageMigrationInspection {
458
+ binding: IObjectStorageMigrationBinding;
459
+ providerIdentitySha256: string;
460
+ highestFenceToken: number;
461
+ requestDigestSha256: string;
462
+ phase: TObjectStorageMigrationPhase;
463
+ manifestDigestSha256?: string;
464
+ boundDestinationAbortReceipt?: IObjectStorageMigrationTerminalReceipt;
465
+ terminalReceipt?: IObjectStorageMigrationTerminalReceipt;
466
+ lastReceipt: IObjectStorageMigrationTransitionReceipt;
467
+ sourceCleanupAccessKeyIdSha256?: string;
468
+ sourceCleanup?: IObjectStorageSourceMigrationCleanupInspection;
469
+ }
470
+
471
+ export interface IObjectStorageBucketMigrationCapability {
472
+ version: 1;
473
+ supported: boolean;
474
+ backend: 'standalone' | 'clustered';
475
+ providerIdentitySha256?: string;
476
+ receiptPublicKeyEd25519?: string;
477
+ sourceSealDrainsMutations: boolean;
478
+ sourceReadsRemainAvailableWhileSealed: boolean;
479
+ destinationPublicationRequiresControllerCredentialHold: true;
480
+ sourceRetirementCleanupSupported: boolean;
481
+ sourceRetirementCleanupControllerRetryRequired: true;
482
+ sourceRetirementCleanupAutomaticStartupDeletion: false;
483
+ reason?: string;
484
+ }
485
+
486
+ export interface IBucketTenantDescriptor extends plugins.tsclass.storage.IS3Descriptor {
487
+ endpoint: string;
488
+ port: number;
489
+ region: string;
490
+ bucket: string;
491
+ bucketName: string;
492
+ accessKeyId: string;
493
+ secretAccessKey: string;
494
+ accessKey: string;
495
+ accessSecret: string;
496
+ useSsl: boolean;
497
+ ssl: boolean;
498
+ env: Record<string, string>;
499
+ /** Present when ensureBucketTenant completed under a resource fence. */
500
+ resourceFence?: IObjectStorageResourceFenceReceipt;
501
+ /** Engine-verified immutable-retention evidence when configured for the bucket. */
502
+ retention?: IBucketRetentionReceipt;
503
+ }
504
+
505
+ export interface IBucketExportObject {
506
+ key: string;
507
+ size: number;
508
+ md5: string;
509
+ metadata: Record<string, string>;
510
+ dataHex: string;
511
+ }
512
+
513
+ export interface IBucketExport {
514
+ format: 'smartstorage.bucket.v1';
515
+ bucketName: string;
516
+ exportedAt: number;
517
+ objects: IBucketExportObject[];
518
+ }
519
+
520
+ export interface IObjectStorageReplaceEffectivePayloadV1 {
521
+ version: 1;
522
+ kind: 'smartstorage.bucket.replace.v1';
523
+ bucketName: string;
524
+ accessKeyId?: string;
525
+ /** Request a durable write-publication barrier after provider replacement. */
526
+ holdPublication?: boolean;
527
+ source: IBucketExport;
528
+ }
529
+
530
+ export interface IObjectStorageExactDeleteEffectivePayloadV1 {
531
+ version: 1;
532
+ kind: 'smartstorage.bucket.delete.v1';
533
+ bucketName: string;
534
+ accessKeyId: string;
535
+ /** Request a durable write-publication barrier after provider deletion. */
536
+ holdPublication?: boolean;
537
+ }
538
+
539
+ export interface IObjectStorageEnsureEffectivePayloadV1 {
540
+ version: 1;
541
+ kind: 'smartstorage.bucket.ensure.v1';
542
+ bucketName: string;
543
+ accessKeyId: string;
544
+ /** Hashed before it is incorporated into the effective payload digest. */
545
+ secretAccessKey: string;
546
+ /** The effective region applied by the provider. */
547
+ region: string;
548
+ /** Optional immutable-retention intent applied as part of the fenced ensure. */
549
+ retention?: IBucketRetentionPolicyInput;
550
+ }
551
+
552
+ export type TObjectStorageResourceFenceEffectivePayloadV1 =
553
+ | IObjectStorageReplaceEffectivePayloadV1
554
+ | IObjectStorageExactDeleteEffectivePayloadV1
555
+ | IObjectStorageEnsureEffectivePayloadV1;
556
+
557
+ const resourceFenceV1Limits = {
558
+ maximumObjects: 10_000,
559
+ maximumObjectBytes: 64 * 1024 * 1024,
560
+ maximumImportBytes: 64 * 1024 * 1024,
561
+ maximumKeyBytes: 1024,
562
+ maximumMetadataEntries: 64,
563
+ maximumMetadataBytes: 8 * 1024,
564
+ maximumTotalKeyBytes: 1024 * 1024,
565
+ maximumTotalMetadataBytes: 1024 * 1024,
566
+ maximumIdentifierBytes: 256,
567
+ } as const;
568
+
569
+ const resourceFenceV1Uint64 = (valueArg: number): Buffer => {
570
+ if (!Number.isSafeInteger(valueArg) || valueArg < 0) {
571
+ throw new Error('ObjectStorage resource fence v1 integers must be non-negative safe integers.');
572
+ }
573
+ const encoded = Buffer.alloc(8);
574
+ encoded.writeBigUInt64BE(BigInt(valueArg));
575
+ return encoded;
576
+ };
577
+
578
+ const resourceFenceV1Utf8 = (valueArg: string, fieldArg: string): Buffer => {
579
+ if (typeof valueArg !== 'string') {
580
+ throw new Error(`${fieldArg} must be a string.`);
581
+ }
582
+ const encoded = Buffer.from(valueArg, 'utf8');
583
+ if (encoded.toString('utf8') !== valueArg) {
584
+ throw new Error(`${fieldArg} must contain valid Unicode scalar values.`);
585
+ }
586
+ return encoded;
587
+ };
588
+
589
+ const compareResourceFenceV1Utf8 = (leftArg: string, rightArg: string): number =>
590
+ Buffer.compare(Buffer.from(leftArg, 'utf8'), Buffer.from(rightArg, 'utf8'));
591
+
592
+ const assertResourceFenceV1BucketName = (bucketNameArg: string): void => {
593
+ const encoded = resourceFenceV1Utf8(bucketNameArg, 'bucketName');
594
+ const ipv4Parts = bucketNameArg.split('.');
595
+ const isIpv4Address = ipv4Parts.length === 4 && ipv4Parts.every((part) =>
596
+ /^[0-9]+$/.test(part) && Number(part) <= 255
597
+ );
598
+ if (
599
+ encoded.length < 3
600
+ || encoded.length > 63
601
+ || !/^[a-z0-9][a-z0-9.-]*[a-z0-9]$/.test(bucketNameArg)
602
+ || bucketNameArg.includes('..')
603
+ || isIpv4Address
604
+ ) {
605
+ throw new Error('bucketName is not a canonical S3 bucket name.');
606
+ }
607
+ };
608
+
609
+ const assertResourceFenceV1Identifier = (valueArg: string, fieldArg: string): void => {
610
+ const encoded = resourceFenceV1Utf8(valueArg, fieldArg);
611
+ if (
612
+ encoded.length === 0
613
+ || encoded.length > resourceFenceV1Limits.maximumIdentifierBytes
614
+ || !/^[A-Za-z0-9][A-Za-z0-9._:@-]*$/.test(valueArg)
615
+ ) {
616
+ throw new Error(`${fieldArg} is not a canonical identifier.`);
617
+ }
618
+ };
619
+
620
+ const canonicalResourceFenceV1Metadata = (
621
+ metadataArg: Record<string, string>,
622
+ objectKeyArg: string,
623
+ ): Buffer => {
624
+ if (!metadataArg || typeof metadataArg !== 'object' || Array.isArray(metadataArg)) {
625
+ throw new Error(`Object '${objectKeyArg}' metadata must be an object.`);
626
+ }
627
+ const entries = Object.entries(metadataArg);
628
+ if (entries.length > resourceFenceV1Limits.maximumMetadataEntries) {
629
+ throw new Error(`Object '${objectKeyArg}' has too many metadata entries.`);
630
+ }
631
+ for (const [key, value] of entries) {
632
+ const keyBytes = resourceFenceV1Utf8(key, `Object '${objectKeyArg}' metadata key`);
633
+ const valueBytes = resourceFenceV1Utf8(value, `Object '${objectKeyArg}' metadata value`);
634
+ if (
635
+ keyBytes.length === 0
636
+ || keyBytes.length > 128
637
+ || valueBytes.length > 2048
638
+ || keyBytes.some((byte) => byte <= 0x1f || byte === 0x7f)
639
+ || valueBytes.some((byte) => (byte <= 0x1f && byte !== 0x09) || byte === 0x7f)
640
+ ) {
641
+ throw new Error(`Object '${objectKeyArg}' metadata is invalid.`);
642
+ }
643
+ }
644
+ entries.sort(([left], [right]) => compareResourceFenceV1Utf8(left, right));
645
+ const encoded = Buffer.from(
646
+ `{${entries.map(([key, value]) => `${JSON.stringify(key)}:${JSON.stringify(value)}`).join(',')}}`,
647
+ 'utf8',
648
+ );
649
+ if (encoded.length > resourceFenceV1Limits.maximumMetadataBytes) {
650
+ throw new Error(`Object '${objectKeyArg}' metadata exceeds its size limit.`);
651
+ }
652
+ return encoded;
653
+ };
654
+
655
+ /**
656
+ * Computes the canonical provider-side effective payload digest for resource
657
+ * fencing v1. This intentionally excludes backup timestamps and source bucket
658
+ * names, matching the Rust engine's replacement and exact-delete profiles.
659
+ */
660
+ export const computeObjectStorageResourceFenceEffectivePayloadSha256V1 = (
661
+ payloadArg: TObjectStorageResourceFenceEffectivePayloadV1,
662
+ ): string => {
663
+ if (payloadArg.version !== 1) {
664
+ throw new Error('ObjectStorage resource fence effective payload version is unsupported.');
665
+ }
666
+ assertResourceFenceV1BucketName(payloadArg.bucketName);
667
+ const hash = plugins.crypto.createHash('sha256');
668
+ const digestField = (valueArg: string | Buffer): void => {
669
+ const value = typeof valueArg === 'string'
670
+ ? resourceFenceV1Utf8(valueArg, 'resource fence digest field')
671
+ : valueArg;
672
+ hash.update(resourceFenceV1Uint64(value.length));
673
+ hash.update(value);
674
+ };
675
+
676
+ if (payloadArg.kind === 'smartstorage.bucket.delete.v1') {
677
+ assertResourceFenceV1Identifier(payloadArg.accessKeyId, 'accessKeyId');
678
+ digestField(payloadArg.kind);
679
+ digestField(payloadArg.bucketName);
680
+ digestField(payloadArg.accessKeyId);
681
+ if (payloadArg.holdPublication === true) {
682
+ digestField('smartstorage.resource-fence.hold-publication.v1');
683
+ digestField(Buffer.from([1]));
684
+ }
685
+ return hash.digest('hex');
686
+ }
687
+ if (payloadArg.kind === 'smartstorage.bucket.ensure.v1') {
688
+ assertResourceFenceV1Identifier(payloadArg.accessKeyId, 'accessKeyId');
689
+ const secretAccessKey = resourceFenceV1Utf8(
690
+ payloadArg.secretAccessKey,
691
+ 'secretAccessKey',
692
+ );
693
+ if (!payloadArg.secretAccessKey.trim()) {
694
+ throw new Error('secretAccessKey must not be empty.');
695
+ }
696
+ const region = resourceFenceV1Utf8(payloadArg.region, 'region');
697
+ if (!payloadArg.region.trim()) {
698
+ throw new Error('region must not be empty.');
699
+ }
700
+ digestField(payloadArg.kind);
701
+ digestField(payloadArg.bucketName);
702
+ digestField(payloadArg.accessKeyId);
703
+ digestField(plugins.crypto.createHash('sha256').update(secretAccessKey).digest('hex'));
704
+ digestField(region);
705
+ if (payloadArg.retention) {
706
+ if (
707
+ payloadArg.retention.mode !== 'compliance'
708
+ || !/^[A-Za-z0-9._-]{1,96}$/.test(payloadArg.retention.policyId)
709
+ || !Number.isSafeInteger(payloadArg.retention.retentionDurationSeconds)
710
+ || payloadArg.retention.retentionDurationSeconds <= 0
711
+ || payloadArg.retention.retentionDurationSeconds > 100 * 366 * 24 * 60 * 60
712
+ ) {
713
+ throw new Error('retention must contain canonical compliance-retention intent.');
714
+ }
715
+ digestField('smartstorage.bucket.retention.v1');
716
+ digestField(payloadArg.retention.mode);
717
+ digestField(payloadArg.retention.policyId);
718
+ digestField(resourceFenceV1Uint64(payloadArg.retention.retentionDurationSeconds));
719
+ }
720
+ return hash.digest('hex');
721
+ }
722
+ if (payloadArg.kind !== 'smartstorage.bucket.replace.v1') {
723
+ throw new Error('ObjectStorage resource fence effective payload kind is unsupported.');
724
+ }
725
+ if (payloadArg.accessKeyId !== undefined) {
726
+ assertResourceFenceV1Identifier(payloadArg.accessKeyId, 'accessKeyId');
727
+ }
728
+
729
+ const source = payloadArg.source;
730
+ if (source.format !== 'smartstorage.bucket.v1') {
731
+ throw new Error('source format must be smartstorage.bucket.v1.');
732
+ }
733
+ assertResourceFenceV1BucketName(source.bucketName);
734
+ if (!Number.isSafeInteger(source.exportedAt) || source.exportedAt < 0) {
735
+ throw new Error('source exportedAt must be a non-negative safe integer.');
736
+ }
737
+ if (!Array.isArray(source.objects) || source.objects.length > resourceFenceV1Limits.maximumObjects) {
738
+ throw new Error(`source exceeds the ${resourceFenceV1Limits.maximumObjects}-object limit.`);
739
+ }
740
+
741
+ const logicalKeys = new Set<string>();
742
+ let totalBytes = 0;
743
+ let totalKeyBytes = 0;
744
+ let totalMetadataBytes = 0;
745
+ const prepared = source.objects.map((object) => {
746
+ const keyBytes = resourceFenceV1Utf8(object.key, 'object key');
747
+ if (
748
+ keyBytes.length === 0
749
+ || keyBytes.length > resourceFenceV1Limits.maximumKeyBytes
750
+ ) {
751
+ throw new Error(
752
+ `Object key '${object.key}' must contain between 1 and ${resourceFenceV1Limits.maximumKeyBytes} UTF-8 bytes.`,
753
+ );
754
+ }
755
+ if (logicalKeys.has(object.key)) {
756
+ throw new Error(`source contains duplicate object key '${object.key}'.`);
757
+ }
758
+ logicalKeys.add(object.key);
759
+ totalKeyBytes += keyBytes.length;
760
+ if (totalKeyBytes > resourceFenceV1Limits.maximumTotalKeyBytes) {
761
+ throw new Error(`source exceeds the ${resourceFenceV1Limits.maximumTotalKeyBytes}-byte aggregate key limit.`);
762
+ }
763
+
764
+ if (
765
+ !Number.isSafeInteger(object.size)
766
+ || object.size < 0
767
+ || object.size > resourceFenceV1Limits.maximumObjectBytes
768
+ || object.dataHex.length !== object.size * 2
769
+ || !/^[0-9a-f]*$/.test(object.dataHex)
770
+ ) {
771
+ throw new Error(`Object '${object.key}' dataHex or size is not canonical.`);
772
+ }
773
+ const data = Buffer.from(object.dataHex, 'hex');
774
+ if (data.length !== object.size) {
775
+ throw new Error(`Object '${object.key}' size does not match dataHex.`);
776
+ }
777
+ if (!/^[0-9a-f]{32}$/.test(object.md5)) {
778
+ throw new Error(`Object '${object.key}' md5 is not canonical.`);
779
+ }
780
+ const actualMd5 = plugins.crypto.createHash('md5').update(data).digest('hex');
781
+ if (actualMd5 !== object.md5) {
782
+ throw new Error(`Object '${object.key}' md5 does not match payload.`);
783
+ }
784
+
785
+ const metadata = canonicalResourceFenceV1Metadata(object.metadata, object.key);
786
+ totalMetadataBytes += metadata.length;
787
+ if (totalMetadataBytes > resourceFenceV1Limits.maximumTotalMetadataBytes) {
788
+ throw new Error(`source exceeds the ${resourceFenceV1Limits.maximumTotalMetadataBytes}-byte aggregate metadata limit.`);
789
+ }
790
+ totalBytes += object.size;
791
+ if (totalBytes > resourceFenceV1Limits.maximumImportBytes) {
792
+ throw new Error(`source exceeds the ${resourceFenceV1Limits.maximumImportBytes}-byte import limit.`);
793
+ }
794
+ return {
795
+ key: object.key,
796
+ size: object.size,
797
+ md5: object.md5,
798
+ metadata,
799
+ dataSha256: plugins.crypto.createHash('sha256').update(data).digest('hex'),
800
+ };
801
+ });
802
+ prepared.sort((left, right) => compareResourceFenceV1Utf8(left.key, right.key));
803
+
804
+ digestField(payloadArg.kind);
805
+ digestField(payloadArg.bucketName);
806
+ if (payloadArg.accessKeyId !== undefined) {
807
+ digestField(payloadArg.accessKeyId);
808
+ }
809
+ digestField(resourceFenceV1Uint64(prepared.length));
810
+ for (const object of prepared) {
811
+ digestField(object.key);
812
+ digestField(resourceFenceV1Uint64(object.size));
813
+ digestField(object.md5);
814
+ digestField(object.metadata);
815
+ digestField(object.dataSha256);
816
+ }
817
+ if (payloadArg.holdPublication === true) {
818
+ digestField('smartstorage.resource-fence.hold-publication.v1');
819
+ digestField(Buffer.from([1]));
820
+ }
821
+ return hash.digest('hex');
822
+ };
823
+
824
+ export interface IExportBucketInput {
825
+ bucketName: string;
826
+ }
827
+
828
+ export interface IImportBucketInput {
829
+ bucketName: string;
830
+ source: IBucketExport;
831
+ /** Exact exclusive bucket credential required by the fenced replacement. */
832
+ accessKeyId?: string;
833
+ fence?: IObjectStorageResourceFence;
834
+ /** Keep target writes closed until commitResourcePublication succeeds. */
835
+ holdPublication?: boolean;
836
+ }
837
+
838
+ export interface IDeleteBucketInput {
839
+ bucketName: string;
840
+ /**
841
+ * Exact bucket-scoped credential to revoke. When supplied, the access key
842
+ * is included in the durable mutation identity and retries treat an already
843
+ * absent key as a successful idempotent revocation. Omitting it retains the
844
+ * legacy data-only deletion behavior that preserves credentials and policy.
845
+ */
846
+ accessKeyId?: string;
847
+ fence: IObjectStorageResourceFence;
848
+ /** Keep target writes closed until commitResourcePublication succeeds. */
849
+ holdPublication?: boolean;
850
+ }
851
+
852
+ export interface IObjectStorageResourceFence {
853
+ version: 1;
854
+ scopeId: string;
855
+ token: number;
856
+ mutationId: string;
857
+ payloadSha256: string;
858
+ }
859
+
860
+ export interface IObjectStorageResourceFenceReceipt
861
+ extends IObjectStorageResourceFence {
862
+ kind:
863
+ | 'smartstorage.bucket.replace.v1'
864
+ | 'smartstorage.bucket.delete.v1'
865
+ | 'smartstorage.bucket.ensure.v1';
866
+ effectivePayloadSha256: string;
867
+ }
868
+
869
+ export interface IObjectStorageResourcePublicationBarrier {
870
+ version: 1;
871
+ kind: 'smartstorage.bucket.write-publication.v1';
872
+ providerRootIdentitySha256: string;
873
+ bucketName: string;
874
+ resourceIdSha256: string;
875
+ scopeId: string;
876
+ token: number;
877
+ mutationId: string;
878
+ mutationKind: 'smartstorage.bucket.replace.v1' | 'smartstorage.bucket.delete.v1';
879
+ payloadSha256: string;
880
+ effectivePayloadSha256: string;
881
+ mutationReceiptSha256: string;
882
+ /** Opaque CSPRNG capability. Never log or persist outside protected control state. */
883
+ commitCapability: string;
884
+ }
885
+
886
+ export interface IObjectStorageResourcePublicationReleaseReceipt {
887
+ version: 1;
888
+ kind: 'smartstorage.bucket.write-publication.release.v1';
889
+ providerRootIdentitySha256: string;
890
+ bucketName: string;
891
+ resourceIdSha256: string;
892
+ scopeId: string;
893
+ token: number;
894
+ mutationId: string;
895
+ mutationKind: 'smartstorage.bucket.replace.v1' | 'smartstorage.bucket.delete.v1';
896
+ payloadSha256: string;
897
+ effectivePayloadSha256: string;
898
+ mutationReceiptSha256: string;
899
+ barrierSha256: string;
900
+ }
901
+
902
+ export interface ICommitObjectStorageResourcePublicationInput {
903
+ barrier: IObjectStorageResourcePublicationBarrier;
904
+ resourceFenceReceipt: IObjectStorageResourceFenceReceipt;
905
+ }
906
+
907
+ export interface IObjectStorageBucketMutationResult {
908
+ bucketName: string;
909
+ objects?: number;
910
+ totalBytes?: number;
911
+ bucketDeleted?: boolean;
912
+ /**
913
+ * Credentials removed during the completing attempt. A crash-recovery
914
+ * retry can report zero after the exact credential was already persisted
915
+ * as absent.
916
+ */
917
+ deletedCredentials?: number;
918
+ credentialsPreserved?: boolean;
919
+ policyPreserved?: boolean;
920
+ resourceFence?: IObjectStorageResourceFenceReceipt;
921
+ resourcePublicationBarrier?: IObjectStorageResourcePublicationBarrier;
922
+ resourcePublicationRelease?: IObjectStorageResourcePublicationReleaseReceipt;
923
+ }
924
+
925
+ export interface IObjectStorageResourceFencingCapability {
926
+ version: 1;
927
+ supported: boolean;
928
+ requiresDrain: true;
929
+ publicationHoldVersion: 1;
930
+ publicationHoldSupported: boolean;
931
+ publicationHoldRequiresExternalDrain: false;
932
+ reason?: string;
933
+ }
934
+
935
+ export interface IObjectStorageHealth {
936
+ ok: boolean;
937
+ running: boolean;
938
+ storageDirectory: string;
939
+ storageLocations?: IStorageLocationSummary[];
940
+ auth: {
941
+ enabled: boolean;
942
+ credentialCount: number;
943
+ tenantCredentialCount: number;
944
+ };
945
+ bucketCount: number;
946
+ objectCount: number;
947
+ totalBytes: number;
948
+ cluster: IClusterHealth;
949
+ resourceFencingVersion: 1;
950
+ resourceFencingSupported: boolean;
951
+ resourceFencingRequiresDrain: true;
952
+ resourcePublicationHoldVersion: 1;
953
+ resourcePublicationHoldSupported: boolean;
954
+ resourcePublicationHoldRequiresExternalDrain: false;
955
+ resourceFencingReason?: string;
956
+ }
957
+
958
+ export const objectStorageResourceFenceErrorCodes = [
959
+ 'EFENCE_REQUIRED',
960
+ 'EFENCE_STALE',
961
+ 'EFENCE_SCOPE_MISMATCH',
962
+ 'EFENCE_UNSUPPORTED',
963
+ 'EFENCE_MUTATION_MISMATCH',
964
+ 'EFENCE_STATE_CORRUPT',
965
+ 'EFENCE_RECEIPT_LIMIT',
966
+ 'EFENCE_BUSY',
967
+ 'EFENCE_OWNERSHIP_MISMATCH',
968
+ 'EFENCE_PUBLICATION_HELD',
969
+ 'EFENCE_PUBLICATION_MISMATCH',
970
+ 'EFENCE_PUBLICATION_CAPABILITY',
971
+ ] as const;
972
+
973
+ export type TObjectStorageResourceFenceErrorCode =
974
+ (typeof objectStorageResourceFenceErrorCodes)[number];
975
+
976
+ export class ObjectStorageResourceFenceError extends Error {
977
+ public readonly code: TObjectStorageResourceFenceErrorCode;
978
+
979
+ public constructor(
980
+ codeArg: TObjectStorageResourceFenceErrorCode,
981
+ messageArg: string,
982
+ causeArg?: unknown,
983
+ ) {
984
+ super(`${codeArg}: ${messageArg}`, { cause: causeArg });
985
+ this.name = 'ObjectStorageResourceFenceError';
986
+ this.code = codeArg;
987
+ }
988
+ }
989
+
990
+ const normalizeResourceFenceError = (errorArg: unknown): Error => {
991
+ const error = errorArg instanceof Error ? errorArg : new Error(String(errorArg));
992
+ const match = /(EFENCE_[A-Z_]+):\s*(.*)$/s.exec(error.message);
993
+ if (
994
+ !match ||
995
+ !objectStorageResourceFenceErrorCodes.includes(
996
+ match[1] as TObjectStorageResourceFenceErrorCode,
997
+ )
998
+ ) {
999
+ return error;
1000
+ }
1001
+ return new ObjectStorageResourceFenceError(
1002
+ match[1] as TObjectStorageResourceFenceErrorCode,
1003
+ match[2],
1004
+ error,
1005
+ );
1006
+ };
1007
+
1008
+ export interface IObjectStorageMetrics {
1009
+ bucketCount: number;
1010
+ objectCount: number;
1011
+ totalBytes: number;
1012
+ authCredentialCount: number;
1013
+ tenantCredentialCount: number;
1014
+ clusterEnabled: boolean;
1015
+ prometheusText: string;
1016
+ }
1017
+
1018
+ /**
1019
+ * Known peer status from the local node's current cluster view.
1020
+ */
1021
+ export interface IClusterPeerHealth {
1022
+ nodeId: string;
1023
+ status: 'online' | 'suspect' | 'offline';
1024
+ quicAddress?: string;
1025
+ s3Address?: string;
1026
+ driveCount?: number;
1027
+ lastHeartbeat?: number;
1028
+ missedHeartbeats?: number;
1029
+ }
1030
+
1031
+ /**
1032
+ * Local drive health as measured by objectstorage's runtime probes.
1033
+ */
1034
+ export interface IClusterDriveHealth {
1035
+ index: number;
1036
+ path: string;
1037
+ status: 'online' | 'degraded' | 'offline' | 'healing';
1038
+ totalBytes?: number;
1039
+ usedBytes?: number;
1040
+ availableBytes?: number;
1041
+ errorCount?: number;
1042
+ lastError?: string;
1043
+ lastCheck?: number;
1044
+ erasureSetId?: number;
1045
+ }
1046
+
1047
+ export interface IClusterErasureHealth {
1048
+ dataShards: number;
1049
+ parityShards: number;
1050
+ chunkSizeBytes: number;
1051
+ totalShards: number;
1052
+ readQuorum: number;
1053
+ writeQuorum: number;
1054
+ erasureSetCount: number;
1055
+ }
1056
+
1057
+ export interface IClusterRepairHealth {
1058
+ active: boolean;
1059
+ scanIntervalMs?: number;
1060
+ lastRunStartedAt?: number;
1061
+ lastRunCompletedAt?: number;
1062
+ lastDurationMs?: number;
1063
+ shardsChecked?: number;
1064
+ shardsHealed?: number;
1065
+ failed?: number;
1066
+ lastError?: string;
1067
+ }
1068
+
1069
+ /**
1070
+ * Cluster runtime health from the Rust core.
1071
+ * When clustering is disabled, the response is `{ enabled: false }`.
1072
+ */
1073
+ export interface IClusterHealth {
1074
+ enabled: boolean;
1075
+ nodeId?: string;
1076
+ quorumHealthy?: boolean;
1077
+ majorityHealthy?: boolean;
1078
+ peers?: IClusterPeerHealth[];
1079
+ drives?: IClusterDriveHealth[];
1080
+ erasure?: IClusterErasureHealth;
1081
+ repairs?: IClusterRepairHealth;
1082
+ }
1083
+
1084
+ /**
1085
+ * Default configuration values
1086
+ */
1087
+ const DEFAULT_CONFIG: IObjectStorageConfig = {
1088
+ server: {
1089
+ port: 3000,
1090
+ address: '0.0.0.0',
1091
+ silent: false,
1092
+ region: 'us-east-1',
1093
+ },
1094
+ storage: {
1095
+ directory: paths.bucketsDir,
1096
+ cleanSlate: false,
1097
+ },
1098
+ auth: {
1099
+ enabled: false,
1100
+ credentials: [
1101
+ {
1102
+ accessKeyId: 'STORAGE',
1103
+ secretAccessKey: 'STORAGE',
1104
+ },
1105
+ ],
1106
+ },
1107
+ cors: {
1108
+ enabled: false,
1109
+ allowedOrigins: ['*'],
1110
+ allowedMethods: ['GET', 'POST', 'PUT', 'DELETE', 'HEAD', 'OPTIONS'],
1111
+ allowedHeaders: ['*'],
1112
+ exposedHeaders: ['ETag', 'x-amz-request-id', 'x-amz-version-id'],
1113
+ maxAge: 86400,
1114
+ allowCredentials: false,
1115
+ },
1116
+ logging: {
1117
+ level: 'info',
1118
+ format: 'text',
1119
+ enabled: true,
1120
+ },
1121
+ limits: {
1122
+ maxObjectSize: 5 * 1024 * 1024 * 1024, // 5GB
1123
+ maxMetadataSize: 2048,
1124
+ requestTimeout: 300000, // 5 minutes
1125
+ },
1126
+ multipart: {
1127
+ expirationDays: 7,
1128
+ cleanupIntervalMinutes: 60,
1129
+ },
1130
+ };
1131
+
1132
+ // A fenced import may contain 64 MiB of raw object data encoded as hexadecimal.
1133
+ // Keep this aligned with the bounded Rust management-line reader.
1134
+ const MAXIMUM_MANAGEMENT_PAYLOAD_BYTES = 144 * 1024 * 1024;
1135
+
1136
+ function assertExactMountedDirectory(directoryArg: string, fieldArg: string): void {
1137
+ if (
1138
+ directoryArg.length === 0 ||
1139
+ !plugins.path.isAbsolute(directoryArg) ||
1140
+ plugins.path.normalize(directoryArg) !== directoryArg ||
1141
+ plugins.path.resolve(directoryArg) !== directoryArg
1142
+ ) {
1143
+ throw new Error(
1144
+ `${fieldArg} must be a non-empty absolute lexically exact mountpoint without '.', '..', duplicate separators, or a trailing separator.`,
1145
+ );
1146
+ }
1147
+ }
1148
+
1149
+ /**
1150
+ * Merge user config with defaults (deep merge)
1151
+ */
1152
+ function mergeConfig(userConfig: IObjectStorageConfig): Required<IObjectStorageConfig> {
1153
+ const userStorage = userConfig.storage || {};
1154
+ const defaultDirectory = DEFAULT_CONFIG.storage!.directory!;
1155
+ const legacyDirectory = userStorage.directory;
1156
+ const configuredPool = userStorage.pool;
1157
+ const configuredMountedPool = configuredPool?.backend.kind === 'mountedFs';
1158
+
1159
+ if (configuredMountedPool) {
1160
+ assertExactMountedDirectory(
1161
+ configuredPool.directory,
1162
+ 'storage.pool.directory',
1163
+ );
1164
+ if (legacyDirectory !== undefined) {
1165
+ assertExactMountedDirectory(legacyDirectory, 'storage.directory');
1166
+ if (legacyDirectory !== configuredPool.directory) {
1167
+ throw new Error(
1168
+ 'storage.directory and storage.pool.directory must be the same exact mountedFs path.',
1169
+ );
1170
+ }
1171
+ }
1172
+ }
1173
+
1174
+ const effectiveDirectory =
1175
+ configuredPool?.directory || legacyDirectory || defaultDirectory;
1176
+
1177
+ if (
1178
+ configuredPool &&
1179
+ !configuredMountedPool &&
1180
+ legacyDirectory &&
1181
+ plugins.path.resolve(configuredPool.directory) !==
1182
+ plugins.path.resolve(legacyDirectory)
1183
+ ) {
1184
+ throw new Error(
1185
+ 'storage.directory and storage.pool.directory must resolve to the same path.',
1186
+ );
1187
+ }
1188
+
1189
+ const pool: IStoragePoolConfig = configuredPool
1190
+ ? {
1191
+ ...configuredPool,
1192
+ directory: plugins.path.resolve(effectiveDirectory),
1193
+ backend: { ...configuredPool.backend },
1194
+ }
1195
+ : {
1196
+ id: 'default',
1197
+ directory: plugins.path.resolve(effectiveDirectory),
1198
+ backend: { kind: 'localFs' },
1199
+ };
1200
+
1201
+ if (!/^[a-z][a-z0-9-]{0,62}$/.test(pool.id)) {
1202
+ throw new Error(
1203
+ 'storage.pool.id must start with a lowercase letter and contain at most 63 lowercase letters, digits, or hyphens.',
1204
+ );
1205
+ }
1206
+ if (
1207
+ pool.backend.kind === 'mountedFs' &&
1208
+ pool.backend.expectedSource.trim().length === 0
1209
+ ) {
1210
+ throw new Error(
1211
+ 'storage.pool.backend.expectedSource must not be empty for mountedFs.',
1212
+ );
1213
+ }
1214
+ if (pool.backend.kind === 'mountedFs' && userStorage.cleanSlate) {
1215
+ throw new Error('storage.cleanSlate is not permitted for mountedFs pools.');
1216
+ }
1217
+
1218
+ return {
1219
+ server: {
1220
+ ...DEFAULT_CONFIG.server!,
1221
+ ...(userConfig.server || {}),
1222
+ },
1223
+ storage: {
1224
+ ...DEFAULT_CONFIG.storage!,
1225
+ ...userStorage,
1226
+ directory: pool.directory,
1227
+ pool,
1228
+ },
1229
+ auth: {
1230
+ ...DEFAULT_CONFIG.auth!,
1231
+ ...(userConfig.auth || {}),
1232
+ },
1233
+ cors: {
1234
+ ...DEFAULT_CONFIG.cors!,
1235
+ ...(userConfig.cors || {}),
1236
+ },
1237
+ logging: {
1238
+ ...DEFAULT_CONFIG.logging!,
1239
+ ...(userConfig.logging || {}),
1240
+ },
1241
+ limits: {
1242
+ ...DEFAULT_CONFIG.limits!,
1243
+ ...(userConfig.limits || {}),
1244
+ },
1245
+ multipart: {
1246
+ ...DEFAULT_CONFIG.multipart!,
1247
+ ...(userConfig.multipart || {}),
1248
+ },
1249
+ ...(userConfig.cluster ? { cluster: userConfig.cluster } : {}),
1250
+ } as Required<IObjectStorageConfig>;
1251
+ }
1252
+
1253
+ function createAccessKeyId(): string {
1254
+ return `SS${plugins.crypto.randomBytes(10).toString('hex').toUpperCase()}`;
1255
+ }
1256
+
1257
+ function createSecretAccessKey(): string {
1258
+ return plugins.crypto.randomBytes(32).toString('hex');
1259
+ }
1260
+
1261
+ function getRustStorageBinaryNames(): string[] {
1262
+ const platformTargetMap: Record<string, string> = {
1263
+ 'linux:x64': 'linux_amd64',
1264
+ 'linux:arm64': 'linux_arm64',
1265
+ 'darwin:x64': 'macos_amd64',
1266
+ 'darwin:arm64': 'macos_arm64',
1267
+ };
1268
+ const platformTarget = platformTargetMap[`${process.platform}:${process.arch}`];
1269
+ return [platformTarget ? `ruststorage_${platformTarget}` : null, 'ruststorage']
1270
+ .filter((binaryNameArg): binaryNameArg is string => Boolean(binaryNameArg));
1271
+ }
1272
+
1273
+ /**
1274
+ * IPC command type map for RustBridge
1275
+ */
1276
+ type TRustStorageCommands = {
1277
+ probeStoragePool: {
1278
+ params: { pool: IMountedStoragePoolConfig };
1279
+ result: IStoragePoolObservation;
1280
+ };
1281
+ getStoragePoolDiagnostic: {
1282
+ params: {};
1283
+ result: IStoragePoolObservation | null;
1284
+ };
1285
+ start: { params: { config: Required<IObjectStorageConfig> }; result: {} };
1286
+ stop: { params: {}; result: IStorageStopResult };
1287
+ createBucket: { params: { name: string }; result: {} };
1288
+ createBucketTenant: {
1289
+ params: {
1290
+ bucketName: string;
1291
+ accessKeyId: string;
1292
+ secretAccessKey: string;
1293
+ region?: string;
1294
+ retention?: IBucketRetentionPolicyInput;
1295
+ };
1296
+ result: IStorageCredential & { retention?: IBucketRetentionReceipt };
1297
+ };
1298
+ ensureBucketTenant: {
1299
+ params: IEnsureBucketTenantInput;
1300
+ result: IStorageCredential & {
1301
+ resourceFence?: IObjectStorageResourceFenceReceipt;
1302
+ retention?: IBucketRetentionReceipt;
1303
+ };
1304
+ };
1305
+ deleteBucketTenant: {
1306
+ params: { bucketName: string; accessKeyId?: string };
1307
+ result: {};
1308
+ };
1309
+ rotateBucketTenantCredentials: {
1310
+ params: {
1311
+ bucketName: string;
1312
+ accessKeyId: string;
1313
+ secretAccessKey: string;
1314
+ region?: string;
1315
+ };
1316
+ result: IStorageCredential & { retention?: IBucketRetentionReceipt };
1317
+ };
1318
+ listBucketTenants: { params: {}; result: IBucketTenantMetadata[] };
1319
+ getBucketTenantCredential: {
1320
+ params: { bucketName: string };
1321
+ result: IStorageCredential & { retention?: IBucketRetentionReceipt };
1322
+ };
1323
+ getBucketRetentionCapability: { params: {}; result: IBucketRetentionCapability };
1324
+ getBucketRetentionReceipt: {
1325
+ params: { bucketName: string };
1326
+ result: IBucketRetentionReceipt | null;
1327
+ };
1328
+ getBucketMigrationCapability: {
1329
+ params: {};
1330
+ result: IObjectStorageBucketMigrationCapability;
1331
+ };
1332
+ createSourceMigration: {
1333
+ params: IObjectStorageMigrationCreateInput;
1334
+ result: IObjectStorageMigrationTransitionReceipt;
1335
+ };
1336
+ createDestinationMigration: {
1337
+ params: IObjectStorageMigrationCreateInput;
1338
+ result: IObjectStorageMigrationTransitionReceipt;
1339
+ };
1340
+ transitionSourceMigration: {
1341
+ params: IObjectStorageSourceMigrationTransitionInput;
1342
+ result: IObjectStorageMigrationTransitionReceipt;
1343
+ };
1344
+ transitionDestinationMigration: {
1345
+ params: IObjectStorageDestinationMigrationTransitionInput;
1346
+ result: IObjectStorageMigrationTransitionReceipt;
1347
+ };
1348
+ cleanupSourceMigration: {
1349
+ params: IObjectStorageSourceMigrationCleanupInput;
1350
+ result: IObjectStorageSourceMigrationCleanupReceipt;
1351
+ };
1352
+ inspectBucketMigration: {
1353
+ params: { migrationId: string };
1354
+ result: IObjectStorageMigrationInspection | null;
1355
+ };
1356
+ exportBucket: { params: { bucketName: string }; result: IBucketExport };
1357
+ importBucket: {
1358
+ params: IImportBucketInput;
1359
+ result: IObjectStorageBucketMutationResult;
1360
+ };
1361
+ deleteBucket: {
1362
+ params: IDeleteBucketInput;
1363
+ result: IObjectStorageBucketMutationResult;
1364
+ };
1365
+ commitResourcePublication: {
1366
+ params: ICommitObjectStorageResourcePublicationInput;
1367
+ result: IObjectStorageResourcePublicationReleaseReceipt;
1368
+ };
1369
+ getResourceFencingCapability: {
1370
+ params: {};
1371
+ result: IObjectStorageResourceFencingCapability;
1372
+ };
1373
+ getStorageStats: { params: {}; result: IStorageStats };
1374
+ listBucketSummaries: { params: {}; result: IBucketSummary[] };
1375
+ listCredentials: { params: {}; result: IStorageCredentialMetadata[] };
1376
+ replaceCredentials: { params: { credentials: IStorageCredential[] }; result: {} };
1377
+ getClusterHealth: { params: {}; result: IClusterHealth };
1378
+ };
1379
+
1380
+ /**
1381
+ * Main ObjectStorage class - production-ready S3-compatible storage server
1382
+ */
1383
+ export class ObjectStorage {
1384
+ // STATIC
1385
+ public static async createAndStart(configArg: IObjectStorageConfig = {}) {
1386
+ const objectStorageInstance = new ObjectStorage(configArg);
1387
+ await objectStorageInstance.start();
1388
+ return objectStorageInstance;
1389
+ }
1390
+
1391
+ public static async probeStoragePool(
1392
+ poolArg: IMountedStoragePoolConfig,
1393
+ ): Promise<IStoragePoolObservation> {
1394
+ const objectStorageInstance = new ObjectStorage({
1395
+ server: { silent: true },
1396
+ storage: {
1397
+ directory: poolArg.directory,
1398
+ cleanSlate: false,
1399
+ pool: poolArg,
1400
+ },
1401
+ });
1402
+ await objectStorageInstance.ensureLocalBinariesExecutable();
1403
+ const spawned = await objectStorageInstance.bridge.spawn();
1404
+ if (!spawned) {
1405
+ throw new Error(
1406
+ 'Failed to spawn ruststorage binary. Make sure it is compiled (pnpm build).',
1407
+ );
1408
+ }
1409
+ try {
1410
+ return await objectStorageInstance.bridge.sendCommand('probeStoragePool', {
1411
+ pool: objectStorageInstance.config.storage
1412
+ .pool as IMountedStoragePoolConfig,
1413
+ });
1414
+ } finally {
1415
+ await objectStorageInstance.bridge.terminate();
1416
+ }
1417
+ }
1418
+
1419
+ // INSTANCE
1420
+ public config: Required<IObjectStorageConfig>;
1421
+ private localBinaryPaths: string[];
1422
+ private bridge: InstanceType<typeof plugins.RustBridge<TRustStorageCommands>>;
1423
+ private running = false;
1424
+ private completedStopResult: IStorageStopResult | undefined;
1425
+ private startPromise: Promise<void> | undefined;
1426
+ private stopPromise: Promise<void> | undefined;
1427
+ private bridgeOwned = false;
1428
+ private stopRequested = false;
1429
+ private stopCompleted = false;
1430
+
1431
+ constructor(configArg: IObjectStorageConfig = {}) {
1432
+ this.config = mergeConfig(configArg);
1433
+ this.localBinaryPaths = getRustStorageBinaryNames().map((binaryNameArg) =>
1434
+ plugins.path.join(paths.packageDir, 'dist_rust', binaryNameArg),
1435
+ );
1436
+ this.bridge = new plugins.RustBridge<TRustStorageCommands>({
1437
+ binaryName: 'ruststorage',
1438
+ localPaths: this.localBinaryPaths,
1439
+ readyTimeoutMs: 30000,
1440
+ requestTimeoutMs: 300000,
1441
+ maxPayloadSize: MAXIMUM_MANAGEMENT_PAYLOAD_BYTES,
1442
+ });
1443
+ }
1444
+
1445
+ private async ensureLocalBinariesExecutable() {
1446
+ for (const localBinaryPath of this.localBinaryPaths) {
1447
+ const binaryStat = await plugins.fs.stat(localBinaryPath).catch((errorArg: NodeJS.ErrnoException) => {
1448
+ if (errorArg.code !== 'ENOENT') {
1449
+ throw errorArg;
1450
+ }
1451
+ return null;
1452
+ });
1453
+ if (!binaryStat || (binaryStat.mode & 0o111) !== 0) {
1454
+ continue;
1455
+ }
1456
+ await plugins.fs.chmod(localBinaryPath, binaryStat.mode | 0o755);
1457
+ }
1458
+ }
1459
+
1460
+ public start(): Promise<void> {
1461
+ if (this.stopRequested && !this.stopCompleted) {
1462
+ return Promise.reject(new Error('Storage shutdown must complete before restarting.'));
1463
+ }
1464
+ if (this.startPromise) {
1465
+ return this.startPromise;
1466
+ }
1467
+ if (this.running) {
1468
+ return Promise.resolve();
1469
+ }
1470
+ if (this.bridgeOwned) {
1471
+ return Promise.reject(new Error('Storage startup cleanup must complete before restarting.'));
1472
+ }
1473
+ this.stopRequested = false;
1474
+ this.stopCompleted = false;
1475
+ this.stopPromise = undefined;
1476
+ this.completedStopResult = undefined;
1477
+ const start = this.startInternal();
1478
+ this.startPromise = start;
1479
+ const releaseStart = () => {
1480
+ if (this.startPromise === start) {
1481
+ this.startPromise = undefined;
1482
+ }
1483
+ };
1484
+ start.then(releaseStart, releaseStart);
1485
+ return start;
1486
+ }
1487
+
1488
+ private async startInternal(): Promise<void> {
1489
+ try {
1490
+ await this.ensureLocalBinariesExecutable();
1491
+ // A failed spawn can still own a transport whose termination needs retrying.
1492
+ this.bridgeOwned = true;
1493
+ const spawned = await this.bridge.spawn();
1494
+ if (!spawned) {
1495
+ throw new Error('Failed to spawn ruststorage binary. Make sure it is compiled (pnpm build).');
1496
+ }
1497
+ await this.bridge.sendCommand('start', { config: this.config });
1498
+ } catch (error) {
1499
+ try {
1500
+ if (this.bridgeOwned) {
1501
+ await this.bridge.terminate();
1502
+ this.bridgeOwned = false;
1503
+ }
1504
+ } catch (terminationError) {
1505
+ throw new AggregateError(
1506
+ [error, terminationError],
1507
+ 'Storage startup failed and Rust bridge termination could not be confirmed.',
1508
+ { cause: error },
1509
+ );
1510
+ }
1511
+ throw error;
1512
+ }
1513
+ this.running = true;
1514
+
1515
+ if (!this.config.server.silent) {
1516
+ console.log('storage server is running');
1517
+ }
1518
+ }
1519
+
1520
+ public async getStorageDescriptor(
1521
+ optionsArg?: Partial<plugins.tsclass.storage.IS3Descriptor>,
1522
+ ): Promise<plugins.tsclass.storage.IS3Descriptor> {
1523
+ const cred = this.config.auth.credentials[0] || {
1524
+ accessKeyId: 'STORAGE',
1525
+ secretAccessKey: 'STORAGE',
1526
+ };
1527
+
1528
+ const descriptor: plugins.tsclass.storage.IS3Descriptor = {
1529
+ endpoint: this.config.server.address === '0.0.0.0' ? 'localhost' : this.config.server.address!,
1530
+ port: this.config.server.port!,
1531
+ useSsl: false,
1532
+ accessKey: cred.accessKeyId,
1533
+ accessSecret: cred.secretAccessKey,
1534
+ bucketName: '',
1535
+ };
1536
+
1537
+ return {
1538
+ ...descriptor,
1539
+ ...(optionsArg ? optionsArg : {}),
1540
+ };
1541
+ }
1542
+
1543
+ private getEndpoint(): string {
1544
+ return this.config.server.address === '0.0.0.0' ? 'localhost' : this.config.server.address!;
1545
+ }
1546
+
1547
+ private buildBucketTenantDescriptor(
1548
+ credential: IStorageCredential & {
1549
+ resourceFence?: IObjectStorageResourceFenceReceipt;
1550
+ retention?: IBucketRetentionReceipt;
1551
+ },
1552
+ bucketNameArg: string,
1553
+ ): IBucketTenantDescriptor {
1554
+ const bucketName = credential.bucketName || bucketNameArg;
1555
+ const region = credential.region || this.config.server.region || 'us-east-1';
1556
+ const endpoint = this.getEndpoint();
1557
+ const port = this.config.server.port!;
1558
+ const useSsl = false;
1559
+
1560
+ const descriptor: IBucketTenantDescriptor = {
1561
+ endpoint,
1562
+ port,
1563
+ region,
1564
+ bucket: bucketName,
1565
+ bucketName,
1566
+ accessKeyId: credential.accessKeyId,
1567
+ secretAccessKey: credential.secretAccessKey,
1568
+ accessKey: credential.accessKeyId,
1569
+ accessSecret: credential.secretAccessKey,
1570
+ useSsl,
1571
+ ssl: useSsl,
1572
+ env: {
1573
+ S3_ENDPOINT: endpoint,
1574
+ S3_PORT: String(port),
1575
+ S3_REGION: region,
1576
+ S3_BUCKET: bucketName,
1577
+ S3_ACCESS_KEY_ID: credential.accessKeyId,
1578
+ S3_SECRET_ACCESS_KEY: credential.secretAccessKey,
1579
+ S3_USE_SSL: String(useSsl),
1580
+ AWS_ACCESS_KEY_ID: credential.accessKeyId,
1581
+ AWS_SECRET_ACCESS_KEY: credential.secretAccessKey,
1582
+ AWS_REGION: region,
1583
+ },
1584
+ };
1585
+ if (credential.resourceFence) {
1586
+ descriptor.resourceFence = credential.resourceFence;
1587
+ }
1588
+ if (credential.retention) {
1589
+ descriptor.retention = credential.retention;
1590
+ }
1591
+ return descriptor;
1592
+ }
1593
+
1594
+ private assertTenantAuthEnabled(): void {
1595
+ if (!this.config.auth.enabled) {
1596
+ throw new Error('Bucket tenant APIs require auth.enabled=true.');
1597
+ }
1598
+ }
1599
+
1600
+ public async createBucket(bucketNameArg: string) {
1601
+ try {
1602
+ await this.bridge.sendCommand('createBucket', { name: bucketNameArg });
1603
+ return { name: bucketNameArg };
1604
+ } catch (error) {
1605
+ throw normalizeResourceFenceError(error);
1606
+ }
1607
+ }
1608
+
1609
+ public async createBucketTenant(
1610
+ tenantArg: IBucketTenantInput,
1611
+ ): Promise<IBucketTenantDescriptor> {
1612
+ this.assertTenantAuthEnabled();
1613
+ try {
1614
+ const credential = await this.bridge.sendCommand('createBucketTenant', {
1615
+ bucketName: tenantArg.bucketName,
1616
+ accessKeyId: tenantArg.accessKeyId || createAccessKeyId(),
1617
+ secretAccessKey: tenantArg.secretAccessKey || createSecretAccessKey(),
1618
+ region: tenantArg.region || this.config.server.region,
1619
+ ...(tenantArg.retention ? { retention: tenantArg.retention } : {}),
1620
+ });
1621
+ return this.buildBucketTenantDescriptor(credential, tenantArg.bucketName);
1622
+ } catch (error) {
1623
+ throw normalizeResourceFenceError(error);
1624
+ }
1625
+ }
1626
+
1627
+ public async ensureBucketTenant(
1628
+ tenantArg: IEnsureBucketTenantInput,
1629
+ ): Promise<IBucketTenantDescriptor> {
1630
+ this.assertTenantAuthEnabled();
1631
+ try {
1632
+ const credential = await this.bridge.sendCommand('ensureBucketTenant', {
1633
+ ...tenantArg,
1634
+ region: tenantArg.region || this.config.server.region,
1635
+ });
1636
+ return this.buildBucketTenantDescriptor(credential, tenantArg.bucketName);
1637
+ } catch (error) {
1638
+ throw normalizeResourceFenceError(error);
1639
+ }
1640
+ }
1641
+
1642
+ public async deleteBucketTenant(tenantArg: IDeleteBucketTenantInput): Promise<void> {
1643
+ this.assertTenantAuthEnabled();
1644
+ try {
1645
+ await this.bridge.sendCommand('deleteBucketTenant', tenantArg);
1646
+ } catch (error) {
1647
+ throw normalizeResourceFenceError(error);
1648
+ }
1649
+ }
1650
+
1651
+ public async rotateBucketTenantCredentials(
1652
+ tenantArg: IBucketTenantInput,
1653
+ ): Promise<IBucketTenantDescriptor> {
1654
+ this.assertTenantAuthEnabled();
1655
+ try {
1656
+ const credential = await this.bridge.sendCommand('rotateBucketTenantCredentials', {
1657
+ bucketName: tenantArg.bucketName,
1658
+ accessKeyId: tenantArg.accessKeyId || createAccessKeyId(),
1659
+ secretAccessKey: tenantArg.secretAccessKey || createSecretAccessKey(),
1660
+ region: tenantArg.region || this.config.server.region,
1661
+ });
1662
+ return this.buildBucketTenantDescriptor(credential, tenantArg.bucketName);
1663
+ } catch (error) {
1664
+ throw normalizeResourceFenceError(error);
1665
+ }
1666
+ }
1667
+
1668
+ public async listBucketTenants(): Promise<IBucketTenantMetadata[]> {
1669
+ this.assertTenantAuthEnabled();
1670
+ return this.bridge.sendCommand('listBucketTenants', {});
1671
+ }
1672
+
1673
+ public async getBucketTenantDescriptor(optionsArg: {
1674
+ bucketName: string;
1675
+ }): Promise<IBucketTenantDescriptor> {
1676
+ this.assertTenantAuthEnabled();
1677
+ const credential = await this.bridge.sendCommand('getBucketTenantCredential', {
1678
+ bucketName: optionsArg.bucketName,
1679
+ });
1680
+ return this.buildBucketTenantDescriptor(credential, optionsArg.bucketName);
1681
+ }
1682
+
1683
+ public async getBucketRetentionCapability(): Promise<IBucketRetentionCapability> {
1684
+ return this.bridge.sendCommand('getBucketRetentionCapability', {});
1685
+ }
1686
+
1687
+ public async getBucketRetentionReceipt(optionsArg: {
1688
+ bucketName: string;
1689
+ }): Promise<IBucketRetentionReceipt | null> {
1690
+ return this.bridge.sendCommand('getBucketRetentionReceipt', optionsArg);
1691
+ }
1692
+
1693
+ public async getBucketMigrationCapability(): Promise<IObjectStorageBucketMigrationCapability> {
1694
+ return this.bridge.sendCommand('getBucketMigrationCapability', {});
1695
+ }
1696
+
1697
+ public async createSourceMigration(
1698
+ optionsArg: IObjectStorageMigrationCreateInput,
1699
+ ): Promise<IObjectStorageMigrationTransitionReceipt> {
1700
+ return this.bridge.sendCommand('createSourceMigration', optionsArg);
1701
+ }
1702
+
1703
+ public async createDestinationMigration(
1704
+ optionsArg: IObjectStorageMigrationCreateInput,
1705
+ ): Promise<IObjectStorageMigrationTransitionReceipt> {
1706
+ return this.bridge.sendCommand('createDestinationMigration', optionsArg);
1707
+ }
1708
+
1709
+ public async transitionSourceMigration(
1710
+ optionsArg: IObjectStorageSourceMigrationTransitionInput,
1711
+ ): Promise<IObjectStorageMigrationTransitionReceipt> {
1712
+ return this.bridge.sendCommand('transitionSourceMigration', optionsArg);
1713
+ }
1714
+
1715
+ public async transitionDestinationMigration(
1716
+ optionsArg: IObjectStorageDestinationMigrationTransitionInput,
1717
+ ): Promise<IObjectStorageMigrationTransitionReceipt> {
1718
+ return this.bridge.sendCommand('transitionDestinationMigration', optionsArg);
1719
+ }
1720
+
1721
+ public async cleanupSourceMigration(
1722
+ optionsArg: IObjectStorageSourceMigrationCleanupInput,
1723
+ ): Promise<IObjectStorageSourceMigrationCleanupReceipt> {
1724
+ try {
1725
+ return await this.bridge.sendCommand('cleanupSourceMigration', optionsArg);
1726
+ } catch (error) {
1727
+ throw normalizeResourceFenceError(error);
1728
+ }
1729
+ }
1730
+
1731
+ public async inspectBucketMigration(optionsArg: {
1732
+ migrationId: string;
1733
+ }): Promise<IObjectStorageMigrationInspection | null> {
1734
+ return this.bridge.sendCommand('inspectBucketMigration', optionsArg);
1735
+ }
1736
+
1737
+ public async exportBucket(optionsArg: IExportBucketInput): Promise<IBucketExport> {
1738
+ return this.bridge.sendCommand('exportBucket', { bucketName: optionsArg.bucketName });
1739
+ }
1740
+
1741
+ public async importBucket(
1742
+ optionsArg: IImportBucketInput,
1743
+ ): Promise<IObjectStorageBucketMutationResult> {
1744
+ try {
1745
+ return await this.bridge.sendCommand('importBucket', optionsArg);
1746
+ } catch (error) {
1747
+ throw normalizeResourceFenceError(error);
1748
+ }
1749
+ }
1750
+
1751
+ public async deleteBucket(
1752
+ optionsArg: IDeleteBucketInput,
1753
+ ): Promise<IObjectStorageBucketMutationResult> {
1754
+ try {
1755
+ return await this.bridge.sendCommand('deleteBucket', optionsArg);
1756
+ } catch (error) {
1757
+ throw normalizeResourceFenceError(error);
1758
+ }
1759
+ }
1760
+
1761
+ public async commitResourcePublication(
1762
+ optionsArg: ICommitObjectStorageResourcePublicationInput,
1763
+ ): Promise<IObjectStorageResourcePublicationReleaseReceipt> {
1764
+ try {
1765
+ return await this.bridge.sendCommand('commitResourcePublication', optionsArg);
1766
+ } catch (error) {
1767
+ throw normalizeResourceFenceError(error);
1768
+ }
1769
+ }
1770
+
1771
+ public async getResourceFencingCapability(): Promise<IObjectStorageResourceFencingCapability> {
1772
+ return this.bridge.sendCommand('getResourceFencingCapability', {});
1773
+ }
1774
+
1775
+ public async getStorageStats(): Promise<IStorageStats> {
1776
+ return this.bridge.sendCommand('getStorageStats', {});
1777
+ }
1778
+
1779
+ public async getStoragePoolDiagnostic(): Promise<IStoragePoolObservation | null> {
1780
+ return this.bridge.sendCommand('getStoragePoolDiagnostic', {});
1781
+ }
1782
+
1783
+ public async listBucketSummaries(): Promise<IBucketSummary[]> {
1784
+ return this.bridge.sendCommand('listBucketSummaries', {});
1785
+ }
1786
+
1787
+ public async listCredentials(): Promise<IStorageCredentialMetadata[]> {
1788
+ return this.bridge.sendCommand('listCredentials', {});
1789
+ }
1790
+
1791
+ /**
1792
+ * Trusted process-local administrative override. Resource lifecycle code
1793
+ * must use fenced ensure/delete instead; completed exact receipts fail closed
1794
+ * if this override introduces ownership or credential-material drift.
1795
+ */
1796
+ public async replaceCredentials(credentials: IStorageCredential[]): Promise<void> {
1797
+ await this.bridge.sendCommand('replaceCredentials', { credentials });
1798
+ this.config.auth.credentials = credentials.map((credential) => ({ ...credential }));
1799
+ }
1800
+
1801
+ public async getClusterHealth(): Promise<IClusterHealth> {
1802
+ return this.bridge.sendCommand('getClusterHealth', {});
1803
+ }
1804
+
1805
+ public async getHealth(): Promise<IObjectStorageHealth> {
1806
+ if (!this.running) {
1807
+ return {
1808
+ ok: false,
1809
+ running: false,
1810
+ storageDirectory: this.config.storage.directory || paths.bucketsDir,
1811
+ storageLocations: [],
1812
+ auth: {
1813
+ enabled: this.config.auth.enabled,
1814
+ credentialCount: this.config.auth.credentials.length,
1815
+ tenantCredentialCount: 0,
1816
+ },
1817
+ bucketCount: 0,
1818
+ objectCount: 0,
1819
+ totalBytes: 0,
1820
+ cluster: { enabled: false },
1821
+ resourceFencingVersion: 1,
1822
+ resourceFencingSupported: false,
1823
+ resourceFencingRequiresDrain: true,
1824
+ resourcePublicationHoldVersion: 1,
1825
+ resourcePublicationHoldSupported: false,
1826
+ resourcePublicationHoldRequiresExternalDrain: false,
1827
+ };
1828
+ }
1829
+
1830
+ const [stats, credentials, tenants, cluster, resourceFencing] = await Promise.all([
1831
+ this.getStorageStats(),
1832
+ this.listCredentials(),
1833
+ this.config.auth.enabled ? this.listBucketTenants() : Promise.resolve([]),
1834
+ this.getClusterHealth(),
1835
+ this.getResourceFencingCapability(),
1836
+ ]);
1837
+ return {
1838
+ ok: true,
1839
+ running: true,
1840
+ storageDirectory: stats.storageDirectory,
1841
+ storageLocations: stats.storageLocations || [],
1842
+ auth: {
1843
+ enabled: this.config.auth.enabled,
1844
+ credentialCount: credentials.length,
1845
+ tenantCredentialCount: tenants.length,
1846
+ },
1847
+ bucketCount: stats.bucketCount,
1848
+ objectCount: stats.totalObjectCount,
1849
+ totalBytes: stats.totalStorageBytes,
1850
+ cluster,
1851
+ resourceFencingVersion: resourceFencing.version,
1852
+ resourceFencingSupported: resourceFencing.supported,
1853
+ resourceFencingRequiresDrain: resourceFencing.requiresDrain,
1854
+ resourcePublicationHoldVersion: resourceFencing.publicationHoldVersion,
1855
+ resourcePublicationHoldSupported: resourceFencing.publicationHoldSupported,
1856
+ resourcePublicationHoldRequiresExternalDrain:
1857
+ resourceFencing.publicationHoldRequiresExternalDrain,
1858
+ resourceFencingReason: resourceFencing.reason,
1859
+ };
1860
+ }
1861
+
1862
+ public async getMetrics(): Promise<IObjectStorageMetrics> {
1863
+ const health = await this.getHealth();
1864
+ const clusterEnabled = health.cluster.enabled;
1865
+ return {
1866
+ bucketCount: health.bucketCount,
1867
+ objectCount: health.objectCount,
1868
+ totalBytes: health.totalBytes,
1869
+ authCredentialCount: health.auth.credentialCount,
1870
+ tenantCredentialCount: health.auth.tenantCredentialCount,
1871
+ clusterEnabled,
1872
+ prometheusText: [
1873
+ '# HELP objectstorage_buckets_total Runtime bucket count.',
1874
+ '# TYPE objectstorage_buckets_total gauge',
1875
+ `objectstorage_buckets_total ${health.bucketCount}`,
1876
+ '# HELP objectstorage_objects_total Runtime object count.',
1877
+ '# TYPE objectstorage_objects_total gauge',
1878
+ `objectstorage_objects_total ${health.objectCount}`,
1879
+ '# HELP objectstorage_storage_bytes_total Runtime storage bytes.',
1880
+ '# TYPE objectstorage_storage_bytes_total gauge',
1881
+ `objectstorage_storage_bytes_total ${health.totalBytes}`,
1882
+ '# HELP objectstorage_tenant_credentials_total Scoped bucket tenant credential count.',
1883
+ '# TYPE objectstorage_tenant_credentials_total gauge',
1884
+ `objectstorage_tenant_credentials_total ${health.auth.tenantCredentialCount}`,
1885
+ '# HELP objectstorage_cluster_enabled Cluster mode enabled.',
1886
+ '# TYPE objectstorage_cluster_enabled gauge',
1887
+ `objectstorage_cluster_enabled ${clusterEnabled ? 1 : 0}`,
1888
+ ].join('\n'),
1889
+ };
1890
+ }
1891
+
1892
+ /**
1893
+ * Retains the Rust bridge when shutdown is incomplete so a later call can
1894
+ * retry ownership draining. Completed terminal errors kill the bridge before
1895
+ * they are rethrown.
1896
+ */
1897
+ public stop(): Promise<void> {
1898
+ if (this.stopPromise) {
1899
+ return this.stopPromise;
1900
+ }
1901
+ this.stopRequested = true;
1902
+ const stop = this.stopInternal();
1903
+ this.stopPromise = stop;
1904
+ stop.then(undefined, () => {
1905
+ if (!this.stopCompleted && this.stopPromise === stop) {
1906
+ this.stopPromise = undefined;
1907
+ }
1908
+ });
1909
+ return stop;
1910
+ }
1911
+
1912
+ private async stopInternal(): Promise<void> {
1913
+ if (this.startPromise) {
1914
+ // Startup keeps its own rejection; stop joins only its resource ownership.
1915
+ await Promise.allSettled([this.startPromise]);
1916
+ }
1917
+ if (this.bridgeOwned) {
1918
+ if (this.running) {
1919
+ this.completedStopResult ??= await this.bridge.sendCommand('stop', {});
1920
+ }
1921
+ await this.bridge.terminate();
1922
+ this.bridgeOwned = false;
1923
+ this.running = false;
1924
+ }
1925
+ this.stopCompleted = true;
1926
+ if (this.completedStopResult?.terminalError) {
1927
+ throw new Error(
1928
+ `Storage server shutdown completed with terminal errors: ${this.completedStopResult.terminalError}`,
1929
+ );
1930
+ }
1931
+ }
1932
+ }