@lossless.org/objectstorage 10.7.1 โ†’ 11.1.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.
Files changed (51) hide show
  1. package/.smartconfig.json +30 -10
  2. package/dist_rust/ruststorage_linux_amd64 +0 -0
  3. package/dist_rust/ruststorage_linux_amd64.tsrust-build.json +5 -5
  4. package/dist_rust/ruststorage_linux_amd64_musl +0 -0
  5. package/dist_rust/ruststorage_linux_amd64_musl.tsrust-build.json +5 -5
  6. package/dist_rust/ruststorage_linux_arm64 +0 -0
  7. package/dist_rust/ruststorage_linux_arm64.tsrust-build.json +5 -5
  8. package/dist_rust/ruststorage_linux_arm64_musl +0 -0
  9. package/dist_rust/ruststorage_linux_arm64_musl.tsrust-build.json +5 -5
  10. package/dist_rust/ruststorage_macos_amd64 +0 -0
  11. package/dist_rust/ruststorage_macos_amd64.tsrust-build.json +5 -5
  12. package/dist_rust/ruststorage_macos_arm64 +0 -0
  13. package/dist_rust/ruststorage_macos_arm64.tsrust-build.json +5 -5
  14. package/dist_ts/00_commitinfo_data.js +1 -1
  15. package/dist_ts/classes.dashboard.d.ts +49 -0
  16. package/dist_ts/classes.dashboard.js +234 -0
  17. package/dist_ts/dashboard.bundled.d.ts +4 -0
  18. package/dist_ts/dashboard.bundled.js +20 -0
  19. package/dist_ts/dashboard.interfaces.d.ts +132 -0
  20. package/dist_ts/dashboard.interfaces.js +2 -0
  21. package/dist_ts/dashboard.plugins.d.ts +5 -0
  22. package/dist_ts/dashboard.plugins.js +10 -0
  23. package/dist_ts/index.d.ts +219 -1
  24. package/dist_ts/index.js +209 -8
  25. package/package.json +17 -16
  26. package/readme.md +711 -30
  27. package/third-party-notices.md +21 -5
  28. package/third_party_licenses/borsh-1.8.1-license.txt +23 -0
  29. package/third_party_licenses/borsh-derive-1.8.1-apache-2.0.txt +201 -0
  30. package/third_party_licenses/cargo-dependencies.html +420 -0
  31. package/third_party_licenses/dyn-clone-1.0.20-license.txt +23 -0
  32. package/third_party_licenses/equivalent-1.0.3-license.txt +25 -0
  33. package/third_party_licenses/hashbrown-0.17.1-license.txt +25 -0
  34. package/third_party_licenses/indexmap-2.14.2-license.txt +25 -0
  35. package/third_party_licenses/inventory.json +310 -8
  36. package/third_party_licenses/proc-macro-crate-3.5.0-license.txt +23 -0
  37. package/third_party_licenses/ref-cast-1.0.27-license.txt +23 -0
  38. package/third_party_licenses/ref-cast-impl-1.0.27-license.txt +23 -0
  39. package/third_party_licenses/schemars-1.2.3-license.txt +21 -0
  40. package/third_party_licenses/syn-3.0.9-license.txt +23 -0
  41. package/third_party_licenses/toml_datetime-1.1.2-license.txt +19 -0
  42. package/third_party_licenses/toml_edit-0.25.17-license.txt +19 -0
  43. package/third_party_licenses/toml_parser-1.1.5-license.txt +19 -0
  44. package/third_party_licenses/winnow-1.0.4-license.txt +18 -0
  45. package/ts/00_commitinfo_data.ts +1 -1
  46. package/ts/classes.dashboard.ts +280 -0
  47. package/ts/dashboard.bundled.ts.third-party-notices.json +1741 -0
  48. package/ts/dashboard.bundled.ts.third-party-notices.txt +5015 -0
  49. package/ts/dashboard.interfaces.ts +148 -0
  50. package/ts/dashboard.plugins.ts +13 -0
  51. package/ts/index.ts +473 -14
package/readme.md CHANGED
@@ -69,13 +69,13 @@ described below.
69
69
  ### Clustering Features
70
70
 
71
71
  - ๐Ÿ”— **Erasure coding** โ€” Reed-Solomon (configurable k data + m parity shards) for storage efficiency and fault tolerance
72
- - ๐Ÿš„ **QUIC transport** โ€” multiplexed, encrypted inter-node communication via `quinn` with zero head-of-line blocking
72
+ - ๐Ÿš„ **QUIC transport** โ€” multiplexed inter-node communication via `quinn` with zero head-of-line blocking. Mutual TLS with a cluster CA when `cluster.tls` is set; without it, encrypted but not authenticated, for trusted networks only (see Cluster transport trust).
73
73
  - ๐Ÿ’ฝ **Multi-drive awareness** โ€” each node manages multiple independent storage paths with health monitoring
74
74
  - ๐Ÿฉบ **Cluster health introspection** โ€” query native node, drive, quorum, and healing status for product dashboards
75
75
  - ๐Ÿค **Cluster membership** โ€” static seed config + runtime join, heartbeat-based failure detection
76
76
  - โœ๏ธ **Quorum writes** โ€” data is only acknowledged after k+1 shards are persisted
77
77
  - ๐Ÿ“– **Quorum reads** โ€” reconstruct from any k available shards, local-first fast path
78
- - ๐Ÿฉน **Self-healing** โ€” background scanner detects and reconstructs missing/corrupt shards
78
+ - ๐Ÿฉน **Self-healing** โ€” background scanner rebuilds missing shards on the drives their manifest names
79
79
 
80
80
  ## Installation
81
81
 
@@ -114,7 +114,7 @@ glibc LGPL terms, Cargo licenses and native distribution inventory.
114
114
  ## Native builds and verification
115
115
 
116
116
  `pnpm build` builds the current host architecture (GNU and musl on Linux), then
117
- builds the TypeScript facade. Test preparation uses `tsrust --configured-targets`
117
+ builds the TypeScript facade. Test preparation uses `rsrepo --configured-targets`
118
118
  to cover all configured host-OS binaries in the packaging checks. Rust 1.95.0 is
119
119
  pinned in `rust-toolchain.toml`. Mac builders
120
120
  need Xcode command-line tools, both Darwin Rust targets and Rosetta 2 for Intel
@@ -126,7 +126,7 @@ with aliases from their full `*-unknown-linux-musl-gcc` names to the names above
126
126
  Rust supplies its pinned, patched musl runtime. Native runtime notices are included
127
127
  in the package; retain them when distributing standalone binaries.
128
128
 
129
- Releases use `tsrust matrix build` to assemble all six binaries from one clean
129
+ Releases use `rsrepo matrix build` to assemble all six binaries from one clean
130
130
  Git commit. Configure these environment variables on the release coordinator:
131
131
 
132
132
  ```sh
@@ -184,6 +184,11 @@ Cluster mode requires the control root and every drive root to exist before
184
184
  startup. The process user must own them, no path component may be a symlink,
185
185
  and group/world write permission must be disabled.
186
186
 
187
+ Without `cluster.tls` the cluster transport does not authenticate nodes: run
188
+ cluster mode only on a network where every host that can reach `quicPort` is a
189
+ cluster node, or set `cluster.tls` on every node (see
190
+ [Cluster transport trust](#cluster-transport-trust)).
191
+
187
192
  ```typescript
188
193
  import { ObjectStorage } from '@lossless.org/objectstorage';
189
194
 
@@ -211,6 +216,21 @@ const storage = await ObjectStorage.createAndStart({
211
216
 
212
217
  Objects are automatically split into chunks (default 4 MB), erasure-coded into 6 shards (4 data + 2 parity), and distributed across drives/nodes. Any 4 of 6 shards can reconstruct the original data.
213
218
 
219
+ Each drive root counts as one independent drive, so startup refuses two drive
220
+ roots on the same device (`st_dev`). Only the device number is checked:
221
+ partitions of one disk, LVM volumes, btrfs subvolumes and datasets of one ZFS
222
+ pool report distinct devices, are not detected, and keeping them apart is the
223
+ operator's responsibility. The control root may share a device with a drive.
224
+ For dev and test clusters whose drive roots are directories on one filesystem,
225
+ opt in explicitly; startup then logs which roots share a device:
226
+
227
+ ```typescript
228
+ drives: {
229
+ paths: ['/srv/objectstorage-dev/drive-1', '/srv/objectstorage-dev/drive-2', '/srv/objectstorage-dev/drive-3'],
230
+ allowSharedDevice: true, // dev/test only: these roots are not independent drives
231
+ },
232
+ ```
233
+
214
234
  ## Configuration
215
235
 
216
236
  All config fields are optional โ€” sensible defaults are applied automatically.
@@ -263,22 +283,35 @@ const config: IObjectStorageConfig = {
263
283
  multipart: {
264
284
  expirationDays: 7, // Must be greater than zero
265
285
  cleanupIntervalMinutes: 60, // Must be greater than zero
286
+ maxUploadsPerPrefixScope: 100, // Open uploads per prefix credential scope; must be non-zero
266
287
  },
267
288
  cluster: { // Optional โ€” omit for standalone mode
268
289
  enabled: true,
269
- nodeId: 'node-1', // Auto-generated UUID if omitted
290
+ nodeId: 'node-1', // Auto-generated UUID if omitted; required with tls
270
291
  quicPort: 4000, // Default: 4000
271
292
  seedNodes: [], // Addresses of existing cluster members
272
293
  erasure: {
273
294
  dataShards: 4, // Default: 4
274
295
  parityShards: 2, // Default: 2
275
- chunkSizeBytes: 4194304, // Default: 4 MB
296
+ chunkSizeBytes: 4194304, // Default: 4 MB; between 1 and 64 MiB
276
297
  },
277
298
  drives: {
278
299
  paths: ['/mnt/disk1', '/mnt/disk2'],
300
+ allowSharedDevice: false, // Default: false; true only for dev/test roots on one device
279
301
  },
280
302
  heartbeatIntervalMs: 5000, // Default: 5000
281
303
  heartbeatTimeoutMs: 30000, // Default: 30000
304
+ getDeadlineMs: 21600000, // Default: 6 h; 60000 (1 min) to 604800000 (7 d)
305
+ tls: { // Optional mutual TLS; set on every node or on none
306
+ caFile: '/etc/objectstorage/tls/ca.pem',
307
+ certFile: '/etc/objectstorage/tls/node-1.pem', // its DNS SAN is nodeId
308
+ keyFile: '/etc/objectstorage/tls/node-1.key', // mode 0600 or 0400
309
+ },
310
+ },
311
+ dashboard: { // Optional read-only dashboard; off unless enabled
312
+ enabled: true,
313
+ address: '127.0.0.1', // Default: '127.0.0.1'
314
+ port: 3001, // Default: 3001; 0 allocates an ephemeral TCP port; never the S3 port
282
315
  },
283
316
  };
284
317
 
@@ -460,6 +493,7 @@ interface IStorageCredential {
460
493
  - Requests that were already authenticated before the replacement keep running; auth is evaluated when each request starts.
461
494
  - No restart is required, and runtime-created credentials survive restart unless `storage.cleanSlate` clears the bounded storage contents; the owned root, maintenance-lock inode, and any durable provider/pool identity are preserved.
462
495
  - Replacement input must contain at least one credential, each `accessKeyId` and `secretAccessKey` must be non-empty, and `accessKeyId` values must be unique.
496
+ - A new `accessKeyId` must not be held by a live or revoked prefix credential (`ECREDENTIAL_CONFLICT`); the same check applies to bucket tenant create, ensure and rotate. Prefix credentials are not part of `listCredentials()`; see [Prefix Credentials](#prefix-credentials).
463
497
 
464
498
  ## Bucket Tenants
465
499
 
@@ -499,6 +533,207 @@ const tenants = await storage.listBucketTenants();
499
533
  - Tenant credentials can list, read, write, and delete objects in their assigned bucket, but cannot list all buckets, access other buckets, copy from other buckets, delete buckets, or mutate bucket policies.
500
534
  - Bucket tenant APIs require `auth.enabled: true`.
501
535
 
536
+ ## Prefix Credentials
537
+
538
+ A prefix credential is an ordinary SigV4 key that reaches only the keys below one
539
+ prefix of one bucket, and only until it expires. It is meant for handing a
540
+ short-lived key to a guest (a VM, a job, a mounted drive) that must see one
541
+ folder and nothing else.
542
+
543
+ ```typescript
544
+ const credential = await storage.createPrefixCredential({
545
+ bucketName: 'drives',
546
+ prefix: 'drives/user-42/',
547
+ ttlSeconds: 3600, // 1 to 43200 (12 h)
548
+ });
549
+ // { accessKeyId, secretAccessKey, bucketName, prefix, region, createdAt, expiresAt }
550
+
551
+ const client = new S3Client({
552
+ endpoint: 'http://localhost:3000',
553
+ region: credential.region,
554
+ credentials: {
555
+ accessKeyId: credential.accessKeyId,
556
+ secretAccessKey: credential.secretAccessKey,
557
+ },
558
+ forcePathStyle: true,
559
+ });
560
+
561
+ await storage.revokePrefixCredential({ accessKeyId: credential.accessKeyId }); // { revoked: true }
562
+ const live = await storage.listPrefixCredentials({ bucketName: 'drives' }); // metadata, no secrets
563
+ ```
564
+
565
+ ```typescript
566
+ interface IPrefixCredentialInput {
567
+ bucketName: string;
568
+ prefix: string;
569
+ ttlSeconds?: number; // first node: lifetime in seconds
570
+ expiresAt?: number; // further nodes: the first node's expiresAt (epoch ms)
571
+ accessKeyId?: string; // supplied together with secretAccessKey and expiresAt only
572
+ secretAccessKey?: string;
573
+ region?: string; // default: server.region
574
+ }
575
+ ```
576
+
577
+ **Input rules.**
578
+ - Exactly one of `ttlSeconds` (an integer, 1 to 43 200) and `expiresAt` (an
579
+ integer epoch-ms time after now and at most 43 200 s ahead).
580
+ - Key material is generated when omitted (`SP` plus 20 uppercase hex
581
+ characters, a 64-hex-character secret). Supplied material needs `expiresAt`,
582
+ and both `accessKeyId` and `secretAccessKey`: an ID of 16 to 128 characters
583
+ of `A-Z0-9`, and a secret of 32 to 128 printable ASCII characters without
584
+ spaces.
585
+ - The prefix is validated, never normalised. It is 1 to 1023 bytes of
586
+ `A-Z a-z 0-9 - . _ ~ /` (RFC 3986 unreserved characters plus `/`, no
587
+ percent-encoding). It ends with `/` and does not start with `/`, and has no
588
+ empty, `.` or `..` segment.
589
+ - The bucket must exist. The access key ID must not belong to a static or bucket
590
+ tenant credential, and it must not have been revoked.
591
+ - The secret appears only in the create result. It is never listed, logged, or
592
+ stored in `storage.config`.
593
+ - Errors are `ObjectStorageCredentialError` with a `code`:
594
+
595
+ | Code | Meaning |
596
+ |---|---|
597
+ | `ECREDENTIAL_AUTH_DISABLED` | `auth.enabled` is false |
598
+ | `ECREDENTIAL_INVALID_PREFIX` | the prefix fails the grammar |
599
+ | `ECREDENTIAL_INVALID_TTL` | not exactly one of `ttlSeconds` and `expiresAt`, or `ttlSeconds` out of range |
600
+ | `ECREDENTIAL_INVALID_EXPIRY` | `expiresAt` is not in the future, or is more than 12 h ahead |
601
+ | `ECREDENTIAL_INVALID_KEY` | the key material or the region is malformed, or only part of it was supplied |
602
+ | `ECREDENTIAL_NO_SUCH_BUCKET` | the bucket does not exist |
603
+ | `ECREDENTIAL_BUCKET_UNAVAILABLE` | the bucket is a sealed or retired migration source |
604
+ | `ECREDENTIAL_CONFLICT` | the ID is held by another credential, or a replay differs from the stored record |
605
+ | `ECREDENTIAL_REVOKED` | the ID was revoked |
606
+ | `ECREDENTIAL_NOT_PREFIX` | a revoke named a static or bucket tenant credential |
607
+ | `ECREDENTIAL_LIMIT` | 10 000 live credentials and revocations are stored |
608
+ | `ECREDENTIAL_PERSISTENCE` | the store could not be written |
609
+ | `ECREDENTIAL_STORE_UNAVAILABLE` | the store file is unreadable (see below) |
610
+
611
+ **Access.** A key is covered when its bytes start with the prefix; a key equal
612
+ to the prefix (its folder marker) is covered.
613
+ - **Objects.** GetObject, HeadObject, PutObject, DeleteObject and the multipart
614
+ operations work on covered keys.
615
+ - Writes also refuse keys with `.`, `..` or empty segments after the prefix,
616
+ and keys with control characters.
617
+ - CopyObject and UploadPartCopy need the source to be covered too.
618
+ - Object requests accept only the query parameters the operation uses:
619
+ `uploads`, `uploadId`, `partNumber`, `max-parts` and `part-number-marker`,
620
+ the six `response-*` header overrides on GetObject and HeadObject, plus
621
+ the `x-id` operation label that AWS SDKs append. Any other object query
622
+ (`?acl`, `?tagging`, `?versionId`, `?retention`, โ€ฆ) is refused with
623
+ `AccessDenied` (403), also a name S3 does not define (`?v=1`), and so is
624
+ any repeated query parameter. A prefix
625
+ credential never names a version: `?versionId` is refused even as `null`,
626
+ and so is a copy source with a `?versionId=` suffix.
627
+ - Requests that pass these checks still go through the router's allowlist
628
+ (see [Supported S3 Operations](#supported-s3-operations)), so a parameter
629
+ the operation does not implement gets `NotImplemented` (501).
630
+ - **DeleteObjects.** Every member is checked on its own: covered keys are
631
+ deleted, and the others get a per-key `AccessDenied`.
632
+ - **Listings.** ListObjects V1, ListObjectsV2, ListObjectVersions and
633
+ ListMultipartUploads need a `prefix` parameter that is covered. A missing or
634
+ wider prefix is refused, never clamped.
635
+ - **Bucket level.**
636
+ - ListBuckets returns only the credential's bucket.
637
+ - HeadBucket and `GET /{bucket}?location` work on that bucket.
638
+ - Any other bucket-level query (`?acl`, `?versioning`, `?lifecycle`, โ€ฆ) is
639
+ refused with `AccessDenied`, and so is any repeated query parameter.
640
+ - Creating or deleting buckets and bucket policy operations are refused.
641
+ - **Policies.** The scope check runs before bucket policies, so a policy Allow
642
+ (including `Principal: "*"`) never widens a prefix credential. A policy Deny
643
+ still applies.
644
+
645
+ **Expiry and deadlines.**
646
+ - A credential authenticates while the engine clock is before `expiresAt`.
647
+ After that, or after revocation, requests get `InvalidAccessKeyId` (403).
648
+ - Requests that were admitted end at `expiresAt` as well: a trickled upload
649
+ fails with nothing stored, and a slowly read download is cut off.
650
+ - Revocation affects only requests admitted after it. Requests already running
651
+ continue until they finish or until `expiresAt`.
652
+ - There is no extend operation. To refresh, create a new key and revoke the old
653
+ one.
654
+ - **Multipart cap.** All prefix credentials of one scope (the same bucket and
655
+ prefix) together hold at most `multipart.maxUploadsPerPrefixScope`
656
+ (default 100, must be non-zero) open multipart uploads. The next
657
+ CreateMultipartUpload of that scope gets `AccessDenied` (403) until one of
658
+ them ends: completed or aborted by any credential, or removed by the
659
+ multipart expiry cleanup. Revoking, expiring or replacing a credential
660
+ frees none of its scope's slots, so issuing new keys does not raise the cap.
661
+ The count is held in memory and resets when the engine restarts. The
662
+ engine-wide limit of 100 000 uploads still applies.
663
+
664
+ **Bucket deletion.** Deletion attempts revoke first; callers re-issue.
665
+ - Every path that deletes a bucket revokes the bucket's prefix credentials
666
+ before it removes anything: S3 DeleteBucket, `deleteBucket()`,
667
+ `deleteBucketTenant()` without an `accessKeyId`, and migration source
668
+ cleanup. A recreated bucket of the same name is not reachable with them.
669
+ - S3 DeleteBucket checks that the bucket is empty before it revokes, so a
670
+ `BucketNotEmpty` (409) keeps the credentials.
671
+ - If the revocation cannot be stored, the deletion is refused.
672
+ - A deletion that fails or is refused after the revocation (for example a
673
+ stale resource fence, `EFENCE_STALE`) returns that error, never a success,
674
+ and leaves the credentials revoked. Callers that keep the bucket create new
675
+ prefix credentials.
676
+
677
+ **Persistence.**
678
+ - Credentials and revocations live in `.smartstorage/prefix-credentials.json`,
679
+ under the same root as `credentials.json`. The engine writes the file
680
+ atomically with mode `0600`, and `storage.cleanSlate` clears it.
681
+ - A sweep every 60 seconds drops expired entries.
682
+ - Engines older than prefix credentials never read the file, so a rollback
683
+ disables every prefix credential rather than widening it.
684
+ - **Fail closed.** If the file is unreadable, larger than 32 MiB, malformed,
685
+ or inconsistent (duplicate IDs, an ID held by a static credential, a record
686
+ that fails validation), the store is unavailable:
687
+ - every prefix credential is refused;
688
+ - create, revoke and list fail with `ECREDENTIAL_STORE_UNAVAILABLE`;
689
+ - no new static or tenant access key ID can be introduced, because the ID
690
+ collision check cannot run: `replaceCredentials()`,
691
+ `createBucketTenant()`, `ensureBucketTenant()` and
692
+ `rotateBucketTenantCredentials()` throw `ObjectStorageCredentialError`
693
+ with code `ECREDENTIAL_STORE_UNAVAILABLE` when they would add an access
694
+ key ID that is not in the current set;
695
+ - bucket deletion is refused, because the bucket's credentials cannot be
696
+ revoked: `deleteBucket()` and `deleteBucketTenant()` without an
697
+ `accessKeyId` throw `ObjectStorageCredentialError` with code
698
+ `ECREDENTIAL_STORE_UNAVAILABLE`, and S3 DeleteBucket returns
699
+ `InvalidRequest` (400) with a message starting
700
+ `ECREDENTIAL_STORE_UNAVAILABLE:`;
701
+ - static and tenant credentials keep working;
702
+ - `getHealth().auth.prefixCredentialStoreAvailable` is `false`;
703
+ - the engine logs the condition without secrets and leaves the file as found.
704
+
705
+ To recover, repair or delete the file and restart. Deleting it revokes every
706
+ prefix credential and forgets every revocation, so never reuse a revoked ID
707
+ afterwards. Generated IDs are random.
708
+
709
+ **Cluster mode.** Prefix credentials are node-local, like runtime credentials:
710
+ nothing replicates them, and the caller fans out.
711
+ 1. Create on one node with `ttlSeconds`.
712
+ 2. Create on every other node with the first node's `accessKeyId`,
713
+ `secretAccessKey`, `expiresAt` and `region`.
714
+ - Each node records its own `createdAt`; every other field matches the first
715
+ node's record.
716
+ - An identical replay returns the stored record unchanged. Any difference
717
+ gives `ECREDENTIAL_CONFLICT`.
718
+ - A late create never extends the lifetime.
719
+ 3. To revoke, call `revokePrefixCredential` on every node and retry until each
720
+ node acknowledges.
721
+ - A node that never saw the ID records the revocation for 12 h, so a create
722
+ that reaches it later is refused with `ECREDENTIAL_REVOKED`.
723
+ - `revoked` is `true` only where a live credential was removed.
724
+
725
+ **Revocation guarantee.**
726
+ - Once a node has acknowledged a revocation, it refuses every request admitted
727
+ after the acknowledgement. It also refuses any later create for that ID until
728
+ the ID could no longer be created anyway: an `expiresAt` create must lie
729
+ within 12 h of the first node's create, and the revocation is kept at least
730
+ that long.
731
+ - A node that never acknowledged serves the key until the caller's retry
732
+ reaches it, or until `expiresAt`.
733
+ - The hard bound is `expiresAt`, at most 12 h after the first create. Each node
734
+ judges expiry by its own clock, so clock offsets shift these instants.
735
+ - `listPrefixCredentials()` lets the caller reconcile nodes.
736
+
502
737
  ## Bucket Backup/Restore
503
738
 
504
739
  ```typescript
@@ -716,7 +951,9 @@ const metrics = await storage.getMetrics();
716
951
  ```
717
952
 
718
953
  - `getHealth()` reports running state, storage directory and pool observation, auth enabled state, credential counts, bucket count, object count, total bytes, and cluster health.
719
- - `getMetrics()` returns numeric counters and a Prometheus text snippet for bucket, object, byte, tenant credential, and cluster-enabled metrics.
954
+ - `getHealth().auth` also reports `prefixCredentialCount` (live prefix credentials), `prefixCredentialTombstoneCount` (unexpired revocations) and `prefixCredentialStoreAvailable` (`false` while the prefix credential file is unreadable and every prefix credential is refused).
955
+ - `getMetrics()` returns numeric counters and a Prometheus text snippet for bucket, object, byte, tenant credential, prefix credential (`objectstorage_prefix_credentials_total`, `objectstorage_prefix_credential_tombstones_total`, `objectstorage_prefix_credential_store_available`), and cluster-enabled metrics.
956
+ - `getHealth()` reads the prefix credential figures through the `getPrefixCredentialStatus` IPC command (no TypeScript method of its own), and `getMetrics()` derives its prefix metrics from `getHealth()`. The command takes no parameters and returns `{ available, credentialCount, tombstoneCount }`: counts and the store state only, never an access key ID, secret, bucket or prefix.
720
957
 
721
958
  ## Runtime Stats
722
959
 
@@ -785,6 +1022,23 @@ interface IClusterHealth {
785
1022
  drives?: IClusterDriveHealth[];
786
1023
  erasure?: IClusterErasureHealth;
787
1024
  repairs?: IClusterRepairHealth;
1025
+ transport?: IClusterTransportHealth;
1026
+ }
1027
+
1028
+ interface IClusterTransportHealth {
1029
+ mode: 'mutualTls' | 'unauthenticated';
1030
+ certificate?: { sha256: string; notAfter: number }; // only with mutualTls
1031
+ caCertificates?: { sha256: string; notAfter: number }[]; // file order; only with mutualTls
1032
+ }
1033
+
1034
+ type TDriveMedia = 'rotational' | 'smr' | 'flash' | 'zoned';
1035
+
1036
+ interface IClusterDriveHealth {
1037
+ // index, path, status, capacity, error and erasure set fields, plus:
1038
+ media: {
1039
+ declared?: TDriveMedia; // not reported yet: drives cannot declare their media
1040
+ detected: TDriveMedia | 'unknown'; // detected once at startup
1041
+ };
788
1042
  }
789
1043
  ```
790
1044
 
@@ -792,8 +1046,203 @@ interface IClusterHealth {
792
1046
  - Standalone mode returns `{ enabled: false }`.
793
1047
  - Peer status is the local node's current view of cluster membership and heartbeats, so it is best-effort and may lag real network state.
794
1048
  - Drive health is based on live native probe checks on the configured local drive paths. Capacity values are cheap filesystem snapshots.
1049
+ - `drives[].media.detected` is the medium the engine detected for each drive once at startup, never per health call. On Linux it follows the drive root's filesystem to the whole disk behind it (`st_dev` through `/sys/dev/block`; a partition counts as its disk) and reads the disk's `queue/zoned` and `queue/rotational`: `zoned` for host-managed or host-aware devices (SMR or ZNS), otherwise `rotational` or `flash`, as the disk's driver sets `queue/rotational`. Virtual and network block devices report the flag their driver or hypervisor sets: QEMU disks show `rotational` unless they are configured with `rotation_rate=1`, and NVMe volumes show `flash`. Declaring a drive's media, which is planned, is what will correct such a reading. It is `unknown`, never a guess, for filesystems without a block device of their own (tmpfs, overlay, network and FUSE filesystems, btrfs subvolumes), for device-mapper, md, loop and other virtual block devices (LVM, dm-crypt and software RAID included), when sysfs gives no usable answer, and on every platform but Linux. Drive-managed SMR reports itself as rotational and cannot be detected (`smr` can only ever be declared). The detected media change no behaviour today.
795
1050
  - `quorumHealthy` means the local node currently sees majority quorum and enough available placements in every erasure set to satisfy the configured write quorum.
1051
+ - `transport.mode` is `mutualTls` when `cluster.tls` is set and `unauthenticated` otherwise. With mutual TLS, `certificate` and `caCertificates` describe the files the engine loaded at startup: the SHA-256 of each DER certificate (lowercase hex) and its `notAfter` in milliseconds since the Unix epoch. The files are read only at startup, so during a certificate or CA rotation these show what each node actually runs.
796
1052
  - Repair fields expose the background healer's currently available runtime state. They are best-effort and limited to what the engine tracks today, such as whether a scan is active, the last completed run, and the last error.
1053
+ - `repairs.shardsChecked`, `shardsHealed` and `failed` count the last completed scan's placement checks, shards written and failures. `repairs.shardsPending` counts the pending shards (see [Self-Healing](#self-healing)) that scan left pending because their drive was out of reach, refused the write, or their object was busy. While a scan runs, `lastRunStartedAt` is the running scan's start and the counts are still the previous scan's; `runHealingScan()` returns one scan's start and counts together.
1054
+
1055
+ ## Dashboard
1056
+
1057
+ Every node can serve a small read-only dashboard for quick visibility: the
1058
+ cluster's nodes, drives, erasure layout and quorum, object and byte totals,
1059
+ request, error and byte rates, healing state and recent engine events. It is
1060
+ off by default:
1061
+
1062
+ ```typescript
1063
+ const storage = await ObjectStorage.createAndStart({
1064
+ // ...
1065
+ dashboard: { enabled: true, address: '10.0.0.5', port: 3001 },
1066
+ });
1067
+ console.log(storage.dashboardPort); // the bound port; undefined while off
1068
+ ```
1069
+
1070
+ Open `http://<address>:<port>/` in a browser. The page is bundled into the
1071
+ package (`ts_web/`, built into `ts/dashboard.bundled.ts`), so nothing is
1072
+ fetched from elsewhere, and it updates itself over one TypedSocket connection
1073
+ to the same origin.
1074
+
1075
+ - **Configuration.** `dashboard.enabled` must be set. `address` defaults to
1076
+ `127.0.0.1` and `port` to `3001`; `port: 0` asks the OS for a port. A port
1077
+ equal to `server.port` is refused at construction. A dashboard that is
1078
+ enabled but cannot bind fails `start()`, and the engine is stopped: it never
1079
+ runs silently without it. The `dashboard` section is not sent to the engine.
1080
+ - **What it shows.** Nodes (status and last heartbeat in this node's view,
1081
+ start time); local drives of every node (status, usage, errors,
1082
+ last probe); the erasure layout and quorum; objects and bytes per node and
1083
+ summed; request, error and byte rates per node and summed, with two-minute
1084
+ sparklines kept in the browser; healing per node (last scan, checked,
1085
+ healed, pending shards, failures); and the newest events of each node (peer
1086
+ status changes, drive status changes, healing scan results, engine start).
1087
+ A standalone node shows its totals, rates and events. Rates are derived
1088
+ from counters of the S3 listener; byte counts are taken at the TCP stream
1089
+ and include HTTP headers. Nothing is stored: a reload starts the sparklines
1090
+ again.
1091
+ - **Where the data comes from.** One management command, `getClusterSnapshot`
1092
+ (also `ObjectStorage.getClusterSnapshot()`), built by the engine from memory
1093
+ only: the drive states the last heartbeat probe left, the in-memory runtime
1094
+ stats, healing state, S3 listener counters and a ring of the newest 64
1095
+ events. It never probes a drive, syncs, walks a directory or reads the
1096
+ control store. A cluster node asks each online peer for that peer's own
1097
+ snapshot over QUIC (`NodeSnapshot`), in parallel, with one second per peer;
1098
+ a peer that is not online or does not answer in time shows as unavailable in
1099
+ that sample. Any node with the dashboard on therefore shows the whole
1100
+ cluster, and the other nodes need no dashboard of their own.
1101
+ - **Cost.** With no dashboard connected there is no timer, no IPC and no QUIC
1102
+ traffic. While at least one is connected, one shared sampler takes a
1103
+ snapshot every two seconds, keeps at most one in flight and skips ticks
1104
+ while one runs, and pushes the same sample (a few KB per node) to every
1105
+ connected dashboard. At most 16 dashboard sockets are admitted at once; the
1106
+ 17th is refused with `503`. On the engine side, request and byte counting is
1107
+ a relaxed atomic add per response and per socket read or write. An enabled
1108
+ dashboard with nobody watching runs no timer and sends no traffic; on a
1109
+ three-node cluster against 11.0.0 its CPU cost per request was within 0.4 %,
1110
+ and the engine's memory was unchanged. Its server modules and bundled page
1111
+ add about 50 MiB to the Node.js process of each node once, at start, and
1112
+ that does not grow with time or stored objects. With 16 dashboards open on
1113
+ every node, large uploads ran about 1.5 % slower and the p99 latency was
1114
+ about 5 % higher.
1115
+ - **Security.** The dashboard is read-only by construction: its socket
1116
+ registers one read handler and nothing else, so it cannot start a healing
1117
+ scan, change configuration or touch S3 traffic or stored data. It has no
1118
+ authentication: protect it with the bind address and a firewall, for
1119
+ example by binding it to a private interface only. It shows node IDs,
1120
+ addresses, drive paths, counts and event messages, never credentials or
1121
+ object keys.
1122
+ - **Versions.** Run every node on the same version, as clusters already
1123
+ require. An engine that predates `NodeSnapshot` cannot decode the request,
1124
+ logs a stream error and closes the stream, and the asking node shows it as
1125
+ unavailable.
1126
+
1127
+ `getClusterHealth()` probes every local drive (a write, sync and read of a
1128
+ probe file) on each call; poll `getClusterSnapshot()` instead when you need a
1129
+ cheap periodic view.
1130
+
1131
+ ## Supported S3 Operations
1132
+
1133
+ The router fails closed, in standalone and cluster mode alike. A request
1134
+ selects one operation from its method, its path (`/`, `/{bucket}`,
1135
+ `/{bucket}/{key}`) and its query names, and each query name must be one that
1136
+ operation implements. Anything else gets `NotImplemented` (501, with the S3 XML
1137
+ error body) before the request takes a lock, reads its body or touches a bucket
1138
+ or object. A credential the request is not allowed for still gets
1139
+ `AccessDenied` (403) first. Until 11.0.0 such requests were served as another
1140
+ operation: `PUT ?tagging` overwrote the object with the XML body,
1141
+ `DELETE ?lifecycle` deleted an empty bucket, `DELETE ?versionId=โ€ฆ` deleted the
1142
+ current object, and `GET ?versioning` answered with an object listing.
1143
+
1144
+ | Operation | Request | Query names besides the selector |
1145
+ |---|---|---|
1146
+ | ListBuckets | `GET /` | none |
1147
+ | CreateBucket, DeleteBucket, HeadBucket | `PUT`, `DELETE`, `HEAD /{bucket}` | none |
1148
+ | ListObjects | `GET /{bucket}` | `prefix`, `delimiter`, `max-keys`, `marker`, `encoding-type` |
1149
+ | ListObjectsV2 | `GET /{bucket}?list-type=2` | `prefix`, `delimiter`, `max-keys`, `continuation-token`, `start-after`, `fetch-owner`, `encoding-type` |
1150
+ | ListObjectVersions | `GET /{bucket}?versions` | `prefix`, `delimiter`, `max-keys`, `key-marker`, `version-id-marker`, `encoding-type` |
1151
+ | ListMultipartUploads | `GET /{bucket}?uploads` | `prefix`, `encoding-type` |
1152
+ | GetBucketLocation | `GET /{bucket}?location` | none |
1153
+ | GetBucketVersioning | `GET /{bucket}?versioning` | none |
1154
+ | GetBucketLifecycleConfiguration | `GET /{bucket}?lifecycle` | none |
1155
+ | GetBucketPolicy, PutBucketPolicy, DeleteBucketPolicy | `GET`, `PUT`, `DELETE /{bucket}?policy` | none |
1156
+ | DeleteObjects | `POST /{bucket}?delete` | none |
1157
+ | GetObject, HeadObject | `GET`, `HEAD /{bucket}/{key}` | `versionId`, `response-cache-control`, `response-content-disposition`, `response-content-encoding`, `response-content-language`, `response-content-type`, `response-expires` |
1158
+ | DeleteObject | `DELETE /{bucket}/{key}` | `versionId` |
1159
+ | PutObject, CopyObject | `PUT /{bucket}/{key}`, CopyObject with `x-amz-copy-source` | none |
1160
+ | CreateMultipartUpload | `POST /{bucket}/{key}?uploads` | none |
1161
+ | UploadPart, UploadPartCopy | `PUT /{bucket}/{key}?partNumber=โ€ฆ&uploadId=โ€ฆ` | none |
1162
+ | CompleteMultipartUpload | `POST /{bucket}/{key}?uploadId=โ€ฆ` | none |
1163
+ | AbortMultipartUpload | `DELETE /{bucket}/{key}?uploadId=โ€ฆ` | none |
1164
+ | ListParts | `GET /{bucket}/{key}?uploadId=โ€ฆ` | `max-parts`, `part-number-marker` |
1165
+
1166
+ Every operation also accepts the `x-id` label that AWS SDKs append and the SigV4
1167
+ query-signing parameters of a presigned URL (`X-Amz-Algorithm`,
1168
+ `X-Amz-Credential`, `X-Amz-Date`, `X-Amz-Expires`, `X-Amz-SignedHeaders`,
1169
+ `X-Amz-Signature`, `X-Amz-Security-Token`, `X-Amz-Content-Sha256`).
1170
+ Query-string signatures are not verified: a presigned request is served as an
1171
+ unsigned one, which is anonymous when auth is enabled.
1172
+
1173
+ An object GET or HEAD (GetObject, HeadObject, ListParts) ignores query names S3
1174
+ does not define, as S3 does, so links with cache-busters (`?v=1`) or tracking
1175
+ parameters (`?fbclid=โ€ฆ`) keep working. A name S3 defines that the operation
1176
+ does not implement (`?acl`, `?partNumber=1`, โ€ฆ) is still refused there, and
1177
+ every other request (bucket level, every write and delete) refuses unknown
1178
+ names too. The set of names S3 defines is taken from the AWS SDK v3 model
1179
+ (3.1039.0). With CORS enabled, the 501 and 405 responses carry the CORS headers.
1180
+
1181
+ The `response-*` parameters of GetObject and HeadObject replace the
1182
+ `Cache-Control`, `Content-Disposition`, `Content-Encoding`, `Content-Language`,
1183
+ `Content-Type` and `Expires` headers of a 200 or 206 response with the given
1184
+ value, verbatim. A value that is not a valid HTTP header value (a control
1185
+ character such as CR or LF) returns `InvalidArgument` (400). With auth enabled, an anonymous
1186
+ request that carries one returns `InvalidRequest` (400), as S3 does, because an
1187
+ override such as `response-content-type=text/html` would let a public object
1188
+ serve script from the storage origin; a presigned URL is such a request. With
1189
+ auth disabled the overrides apply.
1190
+
1191
+ Versioning is never enabled on a bucket:
1192
+ - `GET ?versioning` returns the empty `<VersioningConfiguration/>` of a bucket
1193
+ that was never versioned. `PUT ?versioning` is `NotImplemented`.
1194
+ - `GET ?lifecycle` returns `NoSuchLifecycleConfiguration` (404). `PUT` and
1195
+ `DELETE ?lifecycle` are `NotImplemented`.
1196
+ - `GET ?versions` lists every current object as its S3 null version
1197
+ (`VersionId` `null`, `IsLatest` true), with `CommonPrefixes` and paging on
1198
+ `key-marker` and `version-id-marker`. A truncated page returns the last key
1199
+ or common prefix as `NextKeyMarker`, plus `NextVersionIdMarker` `null` after a
1200
+ key. A `version-id-marker` needs a `key-marker`.
1201
+ - `versionId` on GET, HEAD and DELETE, and a `?versionId=` after the copy source
1202
+ of CopyObject and UploadPartCopy: `null` is the current object. A
1203
+ well-formed id (a lowercase UUID) names no version and returns
1204
+ `NoSuchVersion` (404; `NoSuchBucket` when the bucket is missing). Anything
1205
+ else, the empty string included, returns `InvalidArgument` (400). A `?`
1206
+ inside a copy-source key must be encoded as `%3F`.
1207
+ - These reads are authorized as `s3:GetBucketVersioning`,
1208
+ `s3:GetLifecycleConfiguration` and `s3:ListBucketVersions`. A `versionId`,
1209
+ `null` included, is authorized as `s3:GetObjectVersion` (GET, HEAD, and a
1210
+ copy source's `?versionId=`) or `s3:DeleteObjectVersion` (DELETE), not as
1211
+ `s3:GetObject` or `s3:DeleteObject`, so a policy that allows DeleteObject and
1212
+ denies DeleteObjectVersion keeps protecting history once buckets are
1213
+ versioned. Bucket tenants may use both actions in their bucket.
1214
+
1215
+ `encoding-type=url` percent-encodes, on ListObjects, ListObjectsV2,
1216
+ ListObjectVersions and ListMultipartUploads, every UTF-8 byte outside
1217
+ `A-Z a-z 0-9 - . _ ~` and `/` (a space is `%20`, never `+`) in the elements S3
1218
+ encodes for that operation, and the response carries
1219
+ `<EncodingType>url</EncodingType>`:
1220
+ - ListObjects: `Prefix`, `Marker`, `NextMarker`, `Delimiter`, `Key` and
1221
+ `CommonPrefixes/Prefix`.
1222
+ - ListObjectsV2: `Prefix`, `Delimiter`, `StartAfter`, `Key` and
1223
+ `CommonPrefixes/Prefix`; `ContinuationToken` and `NextContinuationToken`
1224
+ are opaque tokens, the same with or without `encoding-type`.
1225
+ - ListObjectVersions: `Prefix`, `KeyMarker`, `NextKeyMarker`, `Delimiter`,
1226
+ `Key` and `CommonPrefixes/Prefix`.
1227
+ - ListMultipartUploads: `Prefix` and `Key`.
1228
+
1229
+ Without the parameter keys are written as they are and no `EncodingType` is
1230
+ returned; any other value returns `InvalidArgument` (400). An object key may
1231
+ hold any UTF-8 character, also those XML 1.0 cannot carry (U+0001 and the other
1232
+ control characters except tab, line feed and carriage return): a listing that
1233
+ contains such a key is valid XML only with `encoding-type=url`. ListObjectsV2
1234
+ continuation tokens use only `A-Z a-z 0-9 - _`, so they never break the XML.
1235
+
1236
+ Accepted but not applied: owners are never returned (`fetch-owner`); responses
1237
+ say so by omitting `Owner`. Refused with `NotImplemented` although S3
1238
+ defines them: every other subresource (`?acl`, `?tagging`, `?retention`,
1239
+ `?legal-hold`, `?cors`, `?website`, `?object-lock`, `?attributes`, `?restore`,
1240
+ `?select`, โ€ฆ), `partNumber` on GET and HEAD, the paging parameters of
1241
+ ListMultipartUploads (`key-marker`, `upload-id-marker`, `max-uploads`,
1242
+ `delimiter`), ListObjects V1 with V2 cursors, the ListBuckets parameters
1243
+ (`prefix`, `max-buckets`, `continuation-token`, `bucket-region`), headers
1244
+ hoisted into a presigned URL's query (`x-amz-checksum-crc32=โ€ฆ`) on anything but
1245
+ an object read, and any `POST` that is not one of the operations above.
797
1246
 
798
1247
  ## Usage with AWS SDK v3
799
1248
 
@@ -999,7 +1448,23 @@ Client โ”€โ”€HTTP PUTโ”€โ”€โ–ถ Node A (coordinator)
999
1448
  2. **Objects are chunked** โ€” large objects split into fixed-size pieces (default 4 MB)
1000
1449
  3. **Each chunk is erasure-coded** โ€” Reed-Solomon produces k data + m parity shards
1001
1450
  4. **Shards are distributed** โ€” placed across different nodes and drives for fault isolation
1002
- 5. **Quorum guarantees consistency** โ€” writes need k+1 acks, reads need k shards
1451
+ 5. **Quorum guarantees consistency** โ€” writes need k+1 acks, reads need k shards.
1452
+ A chunk with fewer than k+m written shards is published with the missing
1453
+ ones as pending shards that healing completes on their assigned drives.
1454
+ A node that membership marks offline is never contacted: its shards of a
1455
+ write are pending at once, reads take their shards from the other nodes,
1456
+ and below either quorum a request fails at once. Until a stopped node is
1457
+ marked offline (five missed heartbeat rounds), each chunk that needs it
1458
+ waits up to the QUIC idle timeout (10 s). Cluster QUIC connections send
1459
+ keep-alives every 2 s from both ends, so a slow but live peer stays
1460
+ connected however long a request takes; there is no whole-request
1461
+ deadline. A heartbeat to a silent peer therefore fails after 10 s. Each
1462
+ heartbeat round goes to every peer at once, so stopped nodes are marked
1463
+ offline after about 50 s by default, however many stop together (a whole
1464
+ location, for example). A node whose restart takes longer than that comes
1465
+ back to pending shards and healing work. `heartbeatTimeoutMs` (default 30 s) still bounds how long a live
1466
+ peer may take to answer a heartbeat, but cannot extend the wait for a
1467
+ silent one beyond 10 s.
1003
1468
 
1004
1469
  ### Cluster Root and Upgrade Requirements
1005
1470
 
@@ -1007,7 +1472,8 @@ Cluster mode never creates configured control or drive roots. Before startup,
1007
1472
  create `storage.directory` and every `cluster.drives.paths` entry, make them
1008
1473
  owned by the ObjectStorage process user, and remove group/world write permission.
1009
1474
  Every configured root must be a real directory with no symlink path component,
1010
- and every drive root must resolve to a distinct device/inode identity. The
1475
+ and every drive root must resolve to a distinct device/inode identity and,
1476
+ unless `cluster.drives.allowSharedDevice` is set, to a distinct device. The
1011
1477
  control root may be distinct or may be the same identity as one drive root; it
1012
1478
  cannot make two drive entries aliases of one another. Every root must support
1013
1479
  the filesystem durability and locking semantics validated at startup.
@@ -1027,6 +1493,17 @@ has written or migrated the new state, do not restart an older binary and do not
1027
1493
  run a mixed-version cluster. Back up the cluster roots before upgrading; rollback
1028
1494
  requires restoring those roots from the pre-upgrade backup.
1029
1495
 
1496
+ Manifests with pending shards (written while a drive refused writes, see
1497
+ [Self-Healing](#self-healing)) are refused by engines that predate pending
1498
+ shards, because their chunks are short of placements. Before rolling a cluster
1499
+ back to such an engine, stop writing, complete or abort every open multipart
1500
+ upload (part descriptors record pending shards too, the scan does not count
1501
+ them, and an older engine drops them, so such an upload could not complete
1502
+ after the rollback), and then, on every node, run
1503
+ `runHealingScan()` until it reports `shardsPending` 0. Its scan starts after
1504
+ the request arrives, so it covers every write that finished before the call; a
1505
+ scan that could not list every manifest fails instead of reporting counts.
1506
+
1030
1507
  ### Erasure Coding
1031
1508
 
1032
1509
  With the default `4+2` configuration:
@@ -1040,14 +1517,124 @@ With the default `4+2` configuration:
1040
1517
  | 6+3 | 9 | 50% | 3 failures | 5 |
1041
1518
  | 2+1 | 3 | 50% | 1 failure | 2 |
1042
1519
 
1520
+ ### Streaming Reads
1521
+
1522
+ A cluster GET streams the object chunk by chunk; nothing is written to the
1523
+ control root. It reads from the generation of the manifest it loaded at its
1524
+ start, without holding the object's operation gate, so overwrites, deletes and
1525
+ other GETs of the same key run while it streams. Each chunk is rebuilt from the
1526
+ first k valid shards, read in parallel, and at most one rebuilt chunk waits
1527
+ ahead of the client.
1528
+
1529
+ - **Errors after the headers abort the response.** The first chunk is rebuilt
1530
+ before the status line, so an unreadable object still gets an error status.
1531
+ A failure in a later chunk ends the body with an error (the connection is
1532
+ closed), never with a clean end of body. A whole-object GET sends its last
1533
+ bytes only after the total length and the object MD5 match the manifest;
1534
+ range GETs are checked for length only.
1535
+ - **One deadline for the whole GET.** `cluster.getDeadlineMs` is the longest a
1536
+ single GET may run, counted from its start and including the client's
1537
+ transfer time; a GET that takes longer is aborted, and if its client stopped
1538
+ reading, its connection is closed. The default is 6 hours,
1539
+ the allowed range 1 minute to 7 days. The cleanup grace is derived from it:
1540
+ superseded and deleted generations keep their shards for the deadline plus
1541
+ the smaller of the deadline and one hour (7 hours by default, 2 minutes at
1542
+ the 1-minute floor), so every GET that started on a generation has ended
1543
+ before cleanup frees it. Writes in progress hold their object's gate and are
1544
+ never cleaned up, however short the grace. The setting must be the same on
1545
+ every node; a mismatch can abort streams, but never serves wrong bytes.
1546
+ CopyObject and CompleteMultipartUpload read their sources under the object
1547
+ gate with their own fixed 6-hour limit, counted from the start of the
1548
+ operation.
1549
+ - **GET memory budget.** Rebuilding chunks is charged to an engine-wide budget
1550
+ of `max(1 GiB, 16 ร— chunk footprint)` at the configured chunk size. A chunk's
1551
+ footprint is โŒˆchunk/kโŒ‰ ร— (k + min(k, m)) + chunk: the k fetched shards, the
1552
+ data shards rebuilt for missing ones (a read rebuilds no parity shards) and
1553
+ the decoded chunk; 4+2 at 4 MiB is 10 MiB, 1+15 at 64 MiB is 192 MiB. A GET
1554
+ whose client went away keeps its charge until its local shard reads have
1555
+ ended, and shutdown waits for them before it releases the drive roots. The
1556
+ budget is separate from the upload budget. The first chunk waits at most 5 seconds for
1557
+ budget and is otherwise refused with `503 SlowDown` and `Retry-After: 1`;
1558
+ later chunks wait. Slow downloaders hold up to two rebuilt chunks each, so
1559
+ many of them can exhaust the budget and turn new GETs into `503`s under load.
1560
+
1043
1561
  ### QUIC Transport
1044
1562
 
1045
1563
  Inter-node communication uses [QUIC](https://en.wikipedia.org/wiki/QUIC) via the `quinn` library:
1046
- - ๐Ÿ”’ **Built-in TLS** โ€” self-signed certs auto-generated at cluster init
1564
+ - **Mutual TLS or unauthenticated** โ€” with `cluster.tls`, every connection is TLS 1.3 with a CA-signed certificate on both ends; without it, each node generates a self-signed certificate at startup and accepts any peer (see [Cluster transport trust](#cluster-transport-trust))
1047
1565
  - ๐Ÿ”€ **Multiplexed streams** โ€” concurrent shard transfers without head-of-line blocking
1048
1566
  - โšก **Connection pooling** โ€” persistent connections to peer nodes
1049
1567
  - ๐ŸŒŠ **Natural backpressure** โ€” QUIC flow control prevents overloading slow peers
1050
1568
 
1569
+ ### Cluster transport trust
1570
+
1571
+ Without `cluster.tls` the cluster transport does not authenticate nodes. Each node generates a self-signed certificate at startup and accepts any peer's certificate without verifying it. QUIC encrypts the traffic against passive eavesdropping, but not against an attacker on the path. Any host that can reach a node's `quicPort` can join the cluster, sync topology, and read, write, tombstone and purge shards. S3 credentials and bucket policies do not apply to this port. Run such a cluster only on a network where every host that can reach `quicPort` (UDP, default 4000) is a cluster node: a private network or VPN between the nodes, with `quicPort` firewalled from everything else.
1572
+
1573
+ With `cluster.tls` set, the transport is mutual TLS (TLS 1.3, no session resumption, no early data) with a cluster CA you run:
1574
+
1575
+ - **Identity.** A node's identity is the single DNS subject alternative name (SAN) of its certificate, and it equals `cluster.nodeId`. With `cluster.tls`, `nodeId` is required and must be a lowercase DNS name such as `node-1` or `node-1.storage.internal`; it is never generated, and it must still match the node id persisted in the control root. One certificate serves as both the server and the client certificate, so it must be valid for both: an extended key usage extension listing `serverAuth` and `clientAuth`, or no extended key usage extension at all (the recipe below issues both). Use a dedicated CA per cluster: every certificate it signs with one lowercase DNS SAN is a member of the cluster.
1576
+ - **Checks.** A node connects to a known peer only if the peer's certificate names exactly that peer's node id (one DNS SAN, no wildcard), and to a seed (`seedNodes`, known only by its address) only if the seed presents a certificate from the cluster CA. A node accepts a connection only with a client certificate from the cluster CA, and a join or heartbeat only for the node id that certificate names. A peer without such a certificate fails the handshake before it can send any request. Every CA-signed node is a full member: it may sync topology and read, write, tombstone and purge any shard, so the way to remove a compromised node is to stop trusting its certificate (a new CA, see the rotation below).
1577
+ - **Startup.** A node checks its own certificate the way its peers will (chain, validity, server and client authentication, the SAN, and that `keyFile` matches `certFile`) and refuses to start if that check or any file check below fails. The error names the file and the reason. A node never falls back to the unauthenticated transport. At startup it logs one line with its certificate's `notAfter` and the SHA-256 of each CA certificate, and `getClusterHealth().transport` reports them (see [Cluster Health](#cluster-health)).
1578
+ - **Files.** `caFile` holds one CA certificate, or two during a CA rotation; anything else is refused, which keeps a system CA bundle out. `certFile` holds the node certificate, optionally followed by intermediates, and nothing but certificates. `keyFile` holds exactly one unencrypted PKCS#8, SEC1 or PKCS#1 key; ECDSA P-256 is recommended. Each path must be absolute and canonical, with no symbolic link anywhere, and lie outside the control root and every drive root (roots are backed up and replaced, and a key must not travel with them). That check compares paths, so a bind mount that makes a root reachable under another path is not detected. Each file must be a regular file of at most 64 KiB. The files and their directories must be owned by the engine's user or by root and must not be group- or world-writable, and `keyFile` must be owned by the engine's user and grant no group or world permission (mode 0600 or 0400).
1579
+ - **Reload.** The files are read once at startup; a restart is the reload. Certificates are checked at every handshake, so a connection established before a certificate's `notAfter` keeps running until it reconnects, and new handshakes after `notAfter` fail. Renew certificates and restart the nodes before they expire.
1580
+
1581
+ #### Making the CA and the node certificates
1582
+
1583
+ The engine ships no CA tooling. With OpenSSL 3 or LibreSSL 3.3 (the `openssl` of macOS), create the CA once and keep `ca.key` offline. `ec_param_enc:named_curve` is required for LibreSSL, which otherwise writes the curve as explicit parameters that the engine refuses; OpenSSL 3 writes the named curve either way:
1584
+
1585
+ ```bash
1586
+ openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 \
1587
+ -pkeyopt ec_param_enc:named_curve -out ca.key
1588
+ openssl req -x509 -new -key ca.key -sha256 -days 3650 \
1589
+ -subj "/CN=objectstorage cluster CA" \
1590
+ -addext "basicConstraints=critical,CA:TRUE" \
1591
+ -addext "keyUsage=critical,keyCertSign,cRLSign" \
1592
+ -out ca.pem
1593
+ ```
1594
+
1595
+ Then issue one certificate per node, with the node id as its only DNS SAN:
1596
+
1597
+ ```bash
1598
+ NODE_ID=node-1
1599
+ openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 \
1600
+ -pkeyopt ec_param_enc:named_curve -out "$NODE_ID.key"
1601
+ openssl req -new -key "$NODE_ID.key" -subj "/CN=$NODE_ID" -out "$NODE_ID.csr"
1602
+ cat > "$NODE_ID.ext" <<EXT
1603
+ basicConstraints = critical,CA:FALSE
1604
+ keyUsage = critical,digitalSignature
1605
+ extendedKeyUsage = serverAuth,clientAuth
1606
+ subjectAltName = DNS:$NODE_ID
1607
+ EXT
1608
+ openssl x509 -req -in "$NODE_ID.csr" -CA ca.pem -CAkey ca.key -CAcreateserial \
1609
+ -days 365 -sha256 -extfile "$NODE_ID.ext" -out "$NODE_ID.pem"
1610
+ ```
1611
+
1612
+ Install `ca.pem` and the node's certificate and key on the node, as the user that runs the engine and outside its control and drive roots:
1613
+
1614
+ ```bash
1615
+ install -d -m 0755 /etc/objectstorage/tls
1616
+ install -m 0644 ca.pem "$NODE_ID.pem" /etc/objectstorage/tls/
1617
+ install -m 0600 "$NODE_ID.key" /etc/objectstorage/tls/
1618
+ ```
1619
+
1620
+ Then set `cluster.tls` to the three paths (see [Configuration](#configuration)). The test suite runs exactly these commands (`test/test.cluster-tls.node.ts`).
1621
+
1622
+ **Docker secrets** are mounted owned by root with mode 0444 by default, which the key check refuses. Set the key secret's `uid` to the engine's user and its `mode` to `0400` in the service's long `secrets` syntax. Docker Compose ignores `uid`, `gid` and `mode` for secrets read from a file, because it bind-mounts the file; there the host file itself must be owned by the engine's uid with mode 0600 or 0400. Kubernetes secret volumes present their files through symbolic links, which the canonical-path rule refuses.
1623
+
1624
+ #### Enabling mutual TLS
1625
+
1626
+ Nodes with and without `cluster.tls` cannot talk to each other: a TLS node refuses peers without a client certificate and refuses self-signed servers. Enabling TLS is therefore not a rolling change: stop every node, configure `cluster.tls` on every node, then start every node. A node whose `nodeId` was generated keeps that id (the control root persists it, and `getClusterHealth().nodeId` reports it): configure it as `nodeId` and issue the node's certificate for it, or the node refuses to start. Disabling TLS takes the same full stop.
1627
+
1628
+ #### Rotating certificates and the CA
1629
+
1630
+ A node certificate is renewed by issuing a new one from the same CA and restarting the node; its peers need no change. A new CA takes three rolling restarts, with no cluster stop:
1631
+
1632
+ 1. Put both CAs into every node's `caFile` (two certificates) and restart the nodes one at a time.
1633
+ 2. Issue every node a certificate from the new CA and restart the nodes one at a time.
1634
+ 3. Remove the old CA from every `caFile` and restart the nodes one at a time.
1635
+
1636
+ Every pair of nodes stays connected throughout, because trust only grows before the certificates change and only shrinks after. The order matters: a node with a certificate from the new CA cannot connect to a node that trusts only the old one.
1637
+
1051
1638
  ### Cluster Membership
1052
1639
 
1053
1640
  - **Static seed nodes** โ€” initial cluster defined in config
@@ -1057,13 +1644,40 @@ Inter-node communication uses [QUIC](https://en.wikipedia.org/wiki/QUIC) via the
1057
1644
 
1058
1645
  ### Self-Healing
1059
1646
 
1060
- A background scanner periodically (default: every 24h):
1061
- 1. Checks shard checksums (CRC32C) for bit-rot detection
1062
- 2. Identifies shards on offline nodes
1063
- 3. Reconstructs missing shards from remaining data using Reed-Solomon
1064
- 4. Places healed shards on healthy drives
1065
-
1066
- Healing runs at low priority to avoid impacting foreground I/O.
1647
+ Every 24 hours, and whenever `runHealingScan()` asks for it, each node scans
1648
+ the manifests it coordinated (manifests live on the node that wrote them):
1649
+ 1. Every shard placement is checked at the drive it names, locally or through
1650
+ its node, against the checksum and size the manifest recorded. The check
1651
+ reads the shard record's header and the record's length only, never its
1652
+ payload, so a scan costs a few small reads per shard. Verifying payloads
1653
+ is not the scan's job: a read detects a rotted payload through its CRC32C
1654
+ and rebuilds the chunk from the other shards.
1655
+ 2. A missing shard is rebuilt from k valid shards with Reed-Solomon, checked
1656
+ against the recorded checksum, and written to the drive its placement
1657
+ names. The manifest does not change.
1658
+ 3. A pending shard is written to the drive its manifest recorded and then
1659
+ moved into the chunk's placements, in the same version and generation; the
1660
+ completed manifest is identical to one whose write never failed.
1661
+
1662
+ Healing never places a shard on a drive of its own choosing. Placements and
1663
+ pending shards on an offline node wait for that node; a pending shard whose
1664
+ drive refuses writes stays pending and is reported in `repairs.shardsPending`
1665
+ (replacing a permanently lost drive is not supported yet). A shard record that
1666
+ cannot be read, such as a truncated one, is reported in `repairs.failed` and
1667
+ left in place, because shard records are immutable; a node that cannot read a
1668
+ record answers with an error, never as if the shard were missing. A minority
1669
+ partition does not heal: its scan fails. Placements are checked without the
1670
+ object's operation gate, as a GET reads them. The chunks of an object that need
1671
+ a write are healed in batches of up to 1 GiB of chunk data: each batch takes
1672
+ the object's gate, reloads the manifest, heals only chunks that are unchanged
1673
+ in the same generation, publishes its pending shards with one manifest store
1674
+ and releases the gate before the next batch. An object whose gate is busy is
1675
+ skipped until the next scan.
1676
+
1677
+ Scans never overlap. A scan that outlasts the 24-hour interval delays the next
1678
+ one. `runHealingScan()` joins the next scan that starts after its request and
1679
+ never a scan that is already running; requests that arrive together share one
1680
+ scan.
1067
1681
 
1068
1682
  ### Erasure Set Formation
1069
1683
 
@@ -1083,7 +1697,7 @@ Drives are interleaved across nodes for maximum fault isolation. New nodes form
1083
1697
 
1084
1698
  ```typescript
1085
1699
  import { ObjectStorage } from '@lossless.org/objectstorage';
1086
- import { tap, expect } from '@git.zone/tstest/tapbundle';
1700
+ import { tap, expect } from '@git.zone/tsrepo/tapbundle';
1087
1701
 
1088
1702
  let storage: ObjectStorage;
1089
1703
 
@@ -1266,6 +1880,24 @@ List scoped tenant credential metadata without returning secrets.
1266
1880
 
1267
1881
  Return endpoint, port, region, bucket, access key, secret key, SSL flag, legacy descriptor fields, and S3/AWS env values for the bucket tenant.
1268
1882
 
1883
+ #### `createPrefixCredential(input: IPrefixCredentialInput): Promise<IPrefixCredential>`
1884
+
1885
+ Create an expiring credential limited to one prefix of one bucket, or install
1886
+ one created on another node (`accessKeyId`, `secretAccessKey`, `expiresAt`). An
1887
+ identical replay returns the stored record. The secret is returned only here.
1888
+ See [Prefix Credentials](#prefix-credentials) for the input rules and the
1889
+ `ObjectStorageCredentialError` codes.
1890
+
1891
+ #### `revokePrefixCredential(options): Promise<{ revoked: boolean }>`
1892
+
1893
+ Revoke a prefix credential and keep its ID refused until the latest moment it
1894
+ could still be created. Options: `{ accessKeyId }`. `revoked` is `true` only
1895
+ when a live credential was removed; unknown IDs are recorded as revoked too.
1896
+
1897
+ #### `listPrefixCredentials(options?): Promise<IPrefixCredentialMetadata[]>`
1898
+
1899
+ List live prefix credentials without secrets. Options: `{ bucketName? }`.
1900
+
1269
1901
  #### `getBucketMigrationCapability(): Promise<IObjectStorageBucketMigrationCapability>`
1270
1902
 
1271
1903
  Return standalone provider identity and receipt-key evidence plus source seal,
@@ -1324,7 +1956,9 @@ the bucket's exclusive credential. Options:
1324
1956
 
1325
1957
  Delete a fenced bucket. Supplying `accessKeyId` also durably revokes that
1326
1958
  exact tenant credential and deletes the policy; omitting it preserves the
1327
- legacy credential/policy behavior. Options:
1959
+ legacy credential/policy behavior. The bucket's prefix credentials are revoked
1960
+ first in both cases, and the deletion is refused if that revocation cannot be
1961
+ stored. Options:
1328
1962
  `{ bucketName, accessKeyId?, fence, holdPublication? }`. Setting
1329
1963
  `holdPublication: true` returns a durable `resourcePublicationBarrier`; omitted
1330
1964
  or `false` preserves immediate behavior.
@@ -1382,7 +2016,7 @@ Atomically replace the active runtime credential set without restarting the serv
1382
2016
 
1383
2017
  #### `getClusterHealth(): Promise<IClusterHealth>`
1384
2018
 
1385
- Read the Rust core's current cluster, drive, quorum, and repair health snapshot. Standalone mode returns `{ enabled: false }`.
2019
+ Read the Rust core's current cluster, drive, quorum, repair, and transport health snapshot. Standalone mode returns `{ enabled: false }`.
1386
2020
 
1387
2021
  In cluster mode, `quicAddress` is the local QUIC/UDP socket's actual bound
1388
2022
  address. With `cluster.quicPort: 0`, the operating system assigns its port; use
@@ -1390,6 +2024,35 @@ address. With `cluster.quicPort: 0`, the operating system assigns its port; use
1390
2024
  Membership advertisements and peer `quicAddress` values contain that same bound
1391
2025
  address, including after a restart.
1392
2026
 
2027
+ #### `getClusterSnapshot(): Promise<IObjectStorageClusterSnapshot>`
2028
+
2029
+ The [dashboard](#dashboard)'s snapshot, built by the engine from memory only:
2030
+ `local` is this node's `IObjectStorageNodeSnapshot` (counters, totals, drives
2031
+ from the last heartbeat probe, healing state, recent events) and, in cluster
2032
+ mode, `cluster` adds quorum, the erasure layout and every peer, with the peer's
2033
+ own snapshot when it answered within a second. One snapshot runs at a time; a
2034
+ call while another runs rejects. Standalone mode returns `cluster: null`.
2035
+
2036
+ #### `dashboardPort: number | undefined`
2037
+
2038
+ The dashboard's bound TCP port while it runs; `undefined` while it is off or
2039
+ stopped.
2040
+
2041
+ #### `runHealingScan(options?: IRunHealingScanOptions): Promise<IClusterHealingScanResult>`
2042
+
2043
+ Run a healing scan on this cluster node (see [Self-Healing](#self-healing)) and
2044
+ return its `startedAt`, `completedAt`, `durationMs`, `shardsChecked`,
2045
+ `shardsHealed`, `shardsPending` and `failed`. The request joins the next scan
2046
+ that starts after it arrives; a scan already running finishes first. It
2047
+ rejects in standalone mode, when the scan fails (without a majority of the
2048
+ cluster, or when a bucket's manifests cannot be listed) and when the engine
2049
+ shuts down before the scan completes, and it is refused while 16 requests
2050
+ already wait for a scan to start. The engine answers other management calls
2051
+ while the scan runs. `options.timeoutMs` (default 300,000) bounds the wait;
2052
+ the engine cannot cancel a scan, so one that outlasts it keeps running, its
2053
+ result is discarded, and a later call joins the next scan that starts after
2054
+ it.
2055
+
1393
2056
  #### `getHealth(): Promise<IObjectStorageHealth>`
1394
2057
 
1395
2058
  Return running state, storage directory, per-location pool observations, auth
@@ -1416,7 +2079,7 @@ objectstorage uses a **hybrid Rust + TypeScript** architecture:
1416
2079
  โ”‚ โ”‚ โ”œโ”€ FileStore (single-node mode) โ”‚
1417
2080
  โ”‚ โ”‚ โ””โ”€ DistributedStore (cluster mode) โ”‚
1418
2081
  โ”‚ โ”‚ โ”œโ”€ ErasureCoder (Reed-Solomon) โ”‚
1419
- โ”‚ โ”‚ โ”œโ”€ ShardStore (per-drive storage) โ”‚
2082
+ โ”‚ โ”‚ โ”œโ”€ ShardBackend (per-drive shards) โ”‚
1420
2083
  โ”‚ โ”‚ โ”œโ”€ QuicTransport (quinn) โ”‚
1421
2084
  โ”‚ โ”‚ โ”œโ”€ ClusterState & Membership โ”‚
1422
2085
  โ”‚ โ”‚ โ””โ”€ HealingService โ”‚
@@ -1433,7 +2096,7 @@ objectstorage uses a **hybrid Rust + TypeScript** architecture:
1433
2096
 
1434
2097
  **Why Rust?** The original TypeScript implementation had critical perf issues: OOM on multipart uploads (parts buffered in memory), double stream copying, file descriptor leaks on HEAD requests, full-file reads for range requests, and no backpressure. The Rust binary solves these with bounded-memory streaming I/O, backpressure, and direct `seek()` for range requests.
1435
2098
 
1436
- **IPC Protocol:** TypeScript communicates with the `ruststorage` binary over newline-delimited JSON via stdin/stdout. The current management commands are `probeStoragePool`, `findOrphanObjectLeaves`, `getStoragePoolDiagnostic`, `start`, `stop`, `createBucket`, `createBucketTenant`, `ensureBucketTenant`, `deleteBucketTenant`, `rotateBucketTenantCredentials`, `listBucketTenants`, `getBucketTenantCredential`, `getBucketRetentionCapability`, `getBucketRetentionReceipt`, `getBucketMigrationCapability`, `createSourceMigration`, `createDestinationMigration`, `transitionSourceMigration`, `transitionDestinationMigration`, `cleanupSourceMigration`, `inspectBucketMigration`, `exportBucket`, `importBucket`, `deleteBucket`, `commitResourcePublication`, `getResourceFencingCapability`, `getBucketResourceFenceState`, `getStorageStats`, `listBucketSummaries`, `listCredentials`, `replaceCredentials`, and `getClusterHealth`. `getStoragePoolDiagnostic` reads a weak, cached diagnostic source and remains responsive while another management command owns the server; it does not retain the mounted root after server ownership completes.
2099
+ **IPC Protocol:** TypeScript communicates with the `ruststorage` binary over newline-delimited JSON via stdin/stdout. The current management commands are `probeStoragePool`, `findOrphanObjectLeaves`, `getStoragePoolDiagnostic`, `start`, `stop`, `createBucket`, `createBucketTenant`, `ensureBucketTenant`, `deleteBucketTenant`, `rotateBucketTenantCredentials`, `listBucketTenants`, `getBucketTenantCredential`, `getBucketRetentionCapability`, `getBucketRetentionReceipt`, `getBucketMigrationCapability`, `createSourceMigration`, `createDestinationMigration`, `transitionSourceMigration`, `transitionDestinationMigration`, `cleanupSourceMigration`, `inspectBucketMigration`, `exportBucket`, `importBucket`, `deleteBucket`, `commitResourcePublication`, `getResourceFencingCapability`, `getBucketResourceFenceState`, `getStorageStats`, `listBucketSummaries`, `listCredentials`, `replaceCredentials`, `createPrefixCredential`, `revokePrefixCredential`, `listPrefixCredentials`, `getPrefixCredentialStatus`, `getClusterHealth`, `getClusterSnapshot`, and `runHealingScan`. `getStoragePoolDiagnostic` reads a weak, cached diagnostic source and remains responsive while another management command owns the server; it does not retain the mounted root after server ownership completes. `runHealingScan` answers when its scan completes, with the request's `id`; the engine serves other commands meanwhile, and a shutdown answers a pending request with an error before the `stop` response. `getClusterSnapshot` is answered the same deferred way, because a cluster node waits up to a second for each peer.
1437
2100
 
1438
2101
  ### S3-Compatible Operations
1439
2102
 
@@ -1483,7 +2146,8 @@ those versions cannot read the canonical v2 objects.
1483
2146
  | ListParts | `GET /{bucket}/{key}?uploadId` | Optional `max-parts` and `part-number-marker` |
1484
2147
  | CompleteMultipartUpload | `POST /{bucket}/{key}?uploadId` | |
1485
2148
  | AbortMultipartUpload | `DELETE /{bucket}/{key}?uploadId` | |
1486
- | ListMultipartUploads | `GET /{bucket}?uploads` | |
2149
+ | ListMultipartUploads | `GET /{bucket}?uploads` | Optional `prefix` |
2150
+ | GetBucketLocation | `GET /{bucket}?location` | Returns `server.region`; an empty constraint for `us-east-1` |
1487
2151
  | GetBucketPolicy | `GET /{bucket}?policy` | |
1488
2152
  | PutBucketPolicy | `PUT /{bucket}?policy` | |
1489
2153
  | DeleteBucketPolicy | `DELETE /{bucket}?policy` | |
@@ -1507,6 +2171,14 @@ response echoes `ContinuationToken` and `StartAfter` when the request carried
1507
2171
  them, reports `KeyCount` as contents plus common prefixes, and returns
1508
2172
  `NextContinuationToken` whenever the page is truncated.
1509
2173
 
2174
+ A continuation token is opaque: pass `NextContinuationToken` back unchanged.
2175
+ It is the unpadded base64url (RFC 4648 ยง5) encoding of the UTF-8 key the next
2176
+ page resumes after, at most 1,366 characters, and is the same with or without
2177
+ `encoding-type=url`, so a client may change `encoding-type` between pages. A
2178
+ token the server could not have issued (outside the base64url alphabet, padded,
2179
+ not canonical, or not decoding to a 1 to 1,024-byte UTF-8 key) returns
2180
+ `InvalidArgument` (400); so do tokens issued before 11.0.0, which were raw keys.
2181
+
1510
2182
  **ListObjects v1** (no `list-type`) reads `prefix`, `delimiter`, `max-keys` and
1511
2183
  `marker`, and ignores the V2 cursors. The response always carries `Marker`,
1512
2184
  empty when the request carried none, and carries `NextMarker` whenever the page
@@ -1523,14 +2195,16 @@ Paging rules shared by both versions:
1523
2195
  cursor.
1524
2196
  - `max-keys` above 1000 is clamped to 1000 and the response echoes the clamped
1525
2197
  value in `MaxKeys`, as S3 does. A `max-keys` value that is not a non-negative
1526
- integer is rejected with `InvalidRequest`, and so is a cursor longer than the
1527
- 1,024-byte S3 key bound.
2198
+ integer is rejected with `InvalidRequest`, and so is a `start-after` or
2199
+ `marker` longer than the 1,024-byte S3 key bound.
1528
2200
  - An empty `start-after`, `continuation-token` or `marker` value means no cursor.
1529
2201
  - Keys are returned in ascending UTF-8 byte order, and a key is never returned
1530
2202
  twice across pages of an unchanged bucket.
1531
2203
 
1532
2204
  ListMultipartUploads is not paginated: it returns every in-flight upload of the
1533
- bucket with `IsTruncated` false.
2205
+ bucket with `IsTruncated` false. An optional `prefix` parameter limits the
2206
+ result to uploads whose keys start with it and is echoed in the response;
2207
+ prefix credentials must supply one inside their prefix.
1534
2208
 
1535
2209
  ### On-Disk Format
1536
2210
 
@@ -1552,6 +2226,8 @@ bucket with `IsTruncated` false.
1552
2226
  {bucket}.policy.json # Bucket policy (IAM JSON format)
1553
2227
  .smartstorage/staging/v1/ # Private staging: a write lands here first; its
1554
2228
  # key leaf appears only with the complete payload
2229
+ .smartstorage/credentials.json # Runtime and bucket tenant credentials (0600)
2230
+ .smartstorage/prefix-credentials.json # Prefix credentials and revocations (0600)
1555
2231
  ```
1556
2232
 
1557
2233
  Rolling a standalone root back from 10.7.0 or later to 8.0.3โ€“10.6.0: a crash
@@ -1575,7 +2251,8 @@ were never published.
1575
2251
  {object-address}.state.json
1576
2252
  # Active/tombstoned object manifest
1577
2253
  .multipart/{upload-id}/ # Descriptor-anchored multipart state
1578
- .get-tmp/ # Unlinked GET reconstruction files
2254
+ .smartstorage/credentials.json # This node's runtime credentials (not replicated)
2255
+ .smartstorage/prefix-credentials.json # This node's prefix credentials (not replicated)
1579
2256
  .smartstorage/cluster/
1580
2257
  identity.json # Node and cluster identity
1581
2258
  topology.json # Last durable topology
@@ -1588,8 +2265,12 @@ were never published.
1588
2265
  ```
1589
2266
 
1590
2267
  Superseded physical generations are queued with their exact shard placements.
1591
- Cluster reads are bounded, cleanup waits a 24-hour reader grace period, and the
1592
- hourly cleanup worker purges both shard records and tombstones. Multipart
2268
+ They keep their shards for the cleanup grace derived from `cluster.getDeadlineMs`
2269
+ (see Streaming Reads), and the cleanup worker, which runs once per grace period
2270
+ and at least hourly, then purges both shard records and tombstones. Earlier
2271
+ engines spooled GET responses into `.manifests/.get-tmp/`; startup removes a
2272
+ leftover spool directory once it holds only spool files, and otherwise leaves
2273
+ it in place, unused. Multipart
1593
2274
  uploads older than `multipart.expirationDays`, plus committed uploads whose
1594
2275
  first cleanup attempt failed, are retried during bounded cleanup passes
1595
2276
  scheduled every `multipart.cleanupIntervalMinutes`; large registries are
@@ -1612,7 +2293,7 @@ established.
1612
2293
 
1613
2294
  - [`@push.rocks/smartbucket`](https://code.foss.global/push.rocks/smartbucket) โ€” High-level S3-compatible abstraction layer
1614
2295
  - [`@push.rocks/smartrust`](https://code.foss.global/push.rocks/smartrust) โ€” TypeScript โ†” Rust IPC bridge
1615
- - [`@git.zone/tsrust`](https://code.foss.global/git.zone/tsrust) โ€” Rust cross-compilation for npm packages
2296
+ - [`@git.zone/rsrepo`](https://code.foss.global/git.zone/rsrepo) โ€” Rust builds and cross-compilation for npm packages
1616
2297
 
1617
2298
  ## License and Legal Information
1618
2299