@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/ts/index.ts CHANGED
@@ -1,5 +1,12 @@
1
1
  import * as plugins from './plugins.js';
2
2
  import * as paths from './paths.js';
3
+ import type { ObjectStorageDashboard } from './classes.dashboard.js';
4
+ import type {
5
+ IObjectStorageClusterSnapshot,
6
+ IObjectStorageDashboardConfig,
7
+ } from './dashboard.interfaces.js';
8
+
9
+ export * from './dashboard.interfaces.js';
3
10
 
4
11
  /**
5
12
  * Authentication configuration
@@ -62,6 +69,11 @@ export interface ILimitsConfig {
62
69
  export interface IMultipartConfig {
63
70
  expirationDays?: number;
64
71
  cleanupIntervalMinutes?: number;
72
+ /**
73
+ * Open multipart uploads that the prefix credentials of one scope (bucket
74
+ * and prefix) may hold together (default 100). Counted in memory.
75
+ */
76
+ maxUploadsPerPrefixScope?: number;
65
77
  }
66
78
 
67
79
  /**
@@ -118,6 +130,11 @@ export interface IStorageConfig {
118
130
  export interface IErasureConfig {
119
131
  dataShards?: number;
120
132
  parityShards?: number;
133
+ /**
134
+ * Bytes per erasure-coded chunk, between 1 and 64 MiB (67,108,864 bytes);
135
+ * startup refuses a value outside that range, because every manifest
136
+ * refuses a larger chunk. Default 4,194,304 (4 MiB).
137
+ */
121
138
  chunkSizeBytes?: number;
122
139
  }
123
140
 
@@ -126,6 +143,31 @@ export interface IErasureConfig {
126
143
  */
127
144
  export interface IDriveConfig {
128
145
  paths: string[];
146
+ /**
147
+ * Accept drive roots that share a device (`st_dev`). Each drive root counts
148
+ * as one failure domain, so by default startup refuses two drive roots on
149
+ * one device. Set this only for dev and test clusters whose drive roots are
150
+ * directories on one filesystem. Default false.
151
+ */
152
+ allowSharedDevice?: boolean;
153
+ }
154
+
155
+ /**
156
+ * Mutual TLS for the cluster transport. Every file is read once at startup and
157
+ * must lie outside the control and drive roots, at an absolute path without
158
+ * symbolic links; a restart reloads them.
159
+ */
160
+ export interface IClusterTlsConfig {
161
+ /** PEM file with one or two CA certificates (two only during a CA rotation). */
162
+ caFile: string;
163
+ /**
164
+ * PEM file with this node's certificate, optionally followed by intermediates. It has one DNS SAN,
165
+ * equal to nodeId, and is valid for server and client authentication: an extended key usage
166
+ * extension listing serverAuth and clientAuth, or no extended key usage extension.
167
+ */
168
+ certFile: string;
169
+ /** PEM private key of certFile; owned by the engine's user, mode 0600 or 0400. */
170
+ keyFile: string;
129
171
  }
130
172
 
131
173
  /**
@@ -133,6 +175,11 @@ export interface IDriveConfig {
133
175
  */
134
176
  export interface IClusterConfig {
135
177
  enabled: boolean;
178
+ /**
179
+ * This node's id. Generated and persisted when omitted, except with `tls`:
180
+ * then it is required and must be a lowercase DNS name equal to the node
181
+ * certificate's DNS subject alternative name.
182
+ */
136
183
  nodeId?: string;
137
184
  quicPort?: number;
138
185
  seedNodes?: string[];
@@ -140,12 +187,30 @@ export interface IClusterConfig {
140
187
  drives?: IDriveConfig;
141
188
  heartbeatIntervalMs?: number;
142
189
  heartbeatTimeoutMs?: number;
190
+ /**
191
+ * Longest a single GET may run, in milliseconds, from its start to its last
192
+ * byte, including the client's transfer time. A longer GET is aborted.
193
+ * Superseded and deleted object generations keep their shards for this
194
+ * deadline plus the smaller of the deadline and one hour, then cleanup frees
195
+ * them. Must be the same on every node: a mismatch can abort streams but
196
+ * never serves wrong bytes. Default 21,600,000 (6 hours); allowed from
197
+ * 60,000 (1 minute) to 604,800,000 (7 days).
198
+ */
199
+ getDeadlineMs?: number;
200
+ /**
201
+ * Mutual TLS for the cluster transport. Without it the transport is
202
+ * encrypted but authenticates no peer. Every node of a cluster sets it, or
203
+ * none does: enabling it is a full stop of every node, not a rolling change.
204
+ */
205
+ tls?: IClusterTlsConfig;
143
206
  }
144
207
 
145
208
  /**
146
209
  * Configuration as the engine receives it: every section resolved.
147
210
  */
148
- export type TObjectStorageResolvedConfig = Required<Omit<IObjectStorageConfig, 'binaryPath'>>;
211
+ export type TObjectStorageResolvedConfig = Required<
212
+ Omit<IObjectStorageConfig, 'binaryPath' | 'dashboard'>
213
+ >;
149
214
 
150
215
  /**
151
216
  * Complete objectstorage configuration
@@ -166,6 +231,11 @@ export interface IObjectStorageConfig {
166
231
  limits?: ILimitsConfig;
167
232
  multipart?: IMultipartConfig;
168
233
  cluster?: IClusterConfig;
234
+ /**
235
+ * The read-only visibility dashboard, served by this process on its own
236
+ * port. Off unless `enabled` is true; never sent to the engine.
237
+ */
238
+ dashboard?: IObjectStorageDashboardConfig;
169
239
  }
170
240
 
171
241
  /**
@@ -341,6 +411,35 @@ export interface IBucketTenantMetadata {
341
411
  retention?: IBucketRetentionReceipt;
342
412
  }
343
413
 
414
+ export interface IPrefixCredentialInput {
415
+ bucketName: string;
416
+ /** Unreserved characters (RFC 3986) and `/`; ends with `/`, no leading `/`. */
417
+ prefix: string;
418
+ /** First node: lifetime in seconds, 1..43200. */
419
+ ttlSeconds?: number;
420
+ /** Fan-out to further nodes: the first node's expiresAt (epoch ms). */
421
+ expiresAt?: number;
422
+ /** Supply both together with expiresAt to install an existing credential on another node. */
423
+ accessKeyId?: string;
424
+ secretAccessKey?: string;
425
+ region?: string;
426
+ }
427
+
428
+ export interface IPrefixCredentialMetadata {
429
+ accessKeyId: string;
430
+ bucketName: string;
431
+ prefix: string;
432
+ region: string;
433
+ /** Epoch ms. */
434
+ createdAt: number;
435
+ /** Epoch ms; requests are refused from this instant and admitted ones end at it. */
436
+ expiresAt: number;
437
+ }
438
+
439
+ export interface IPrefixCredential extends IPrefixCredentialMetadata {
440
+ secretAccessKey: string;
441
+ }
442
+
344
443
  export interface IBucketRetentionPolicyInput {
345
444
  mode: 'compliance';
346
445
  policyId: string;
@@ -998,6 +1097,10 @@ export interface IObjectStorageHealth {
998
1097
  enabled: boolean;
999
1098
  credentialCount: number;
1000
1099
  tenantCredentialCount: number;
1100
+ prefixCredentialCount: number;
1101
+ prefixCredentialTombstoneCount: number;
1102
+ /** False when the prefix credential file is unreadable: every prefix credential is refused. */
1103
+ prefixCredentialStoreAvailable: boolean;
1001
1104
  };
1002
1105
  bucketCount: number;
1003
1106
  objectCount: number;
@@ -1062,6 +1165,55 @@ const normalizeResourceFenceError = (errorArg: unknown): Error => {
1062
1165
  );
1063
1166
  };
1064
1167
 
1168
+ export const objectStorageCredentialErrorCodes = [
1169
+ 'ECREDENTIAL_AUTH_DISABLED',
1170
+ 'ECREDENTIAL_INVALID_PREFIX',
1171
+ 'ECREDENTIAL_INVALID_TTL',
1172
+ 'ECREDENTIAL_INVALID_EXPIRY',
1173
+ 'ECREDENTIAL_INVALID_KEY',
1174
+ 'ECREDENTIAL_NO_SUCH_BUCKET',
1175
+ 'ECREDENTIAL_BUCKET_UNAVAILABLE',
1176
+ 'ECREDENTIAL_CONFLICT',
1177
+ 'ECREDENTIAL_REVOKED',
1178
+ 'ECREDENTIAL_NOT_PREFIX',
1179
+ 'ECREDENTIAL_LIMIT',
1180
+ 'ECREDENTIAL_PERSISTENCE',
1181
+ 'ECREDENTIAL_STORE_UNAVAILABLE',
1182
+ ] as const;
1183
+
1184
+ export type TObjectStorageCredentialErrorCode =
1185
+ (typeof objectStorageCredentialErrorCodes)[number];
1186
+
1187
+ export class ObjectStorageCredentialError extends Error {
1188
+ public readonly code: TObjectStorageCredentialErrorCode;
1189
+
1190
+ public constructor(
1191
+ codeArg: TObjectStorageCredentialErrorCode,
1192
+ messageArg: string,
1193
+ causeArg?: unknown,
1194
+ ) {
1195
+ super(`${codeArg}: ${messageArg}`, { cause: causeArg });
1196
+ this.name = 'ObjectStorageCredentialError';
1197
+ this.code = codeArg;
1198
+ }
1199
+ }
1200
+
1201
+ const normalizeCredentialError = (errorArg: unknown): Error => {
1202
+ const error = errorArg instanceof Error ? errorArg : new Error(String(errorArg));
1203
+ const match = /(ECREDENTIAL_[A-Z_]+):\s*(.*)$/s.exec(error.message);
1204
+ if (
1205
+ !match ||
1206
+ !objectStorageCredentialErrorCodes.includes(match[1] as TObjectStorageCredentialErrorCode)
1207
+ ) {
1208
+ return error;
1209
+ }
1210
+ return new ObjectStorageCredentialError(
1211
+ match[1] as TObjectStorageCredentialErrorCode,
1212
+ match[2],
1213
+ error,
1214
+ );
1215
+ };
1216
+
1065
1217
  export interface IObjectStorageMetrics {
1066
1218
  bucketCount: number;
1067
1219
  objectCount: number;
@@ -1085,6 +1237,35 @@ export interface IClusterPeerHealth {
1085
1237
  missedHeartbeats?: number;
1086
1238
  }
1087
1239
 
1240
+ /**
1241
+ * A drive's medium: `'rotational'` and `'flash'` (network volumes included),
1242
+ * `'smr'` for drive-managed SMR, which reports itself as rotational and is
1243
+ * never detected, and `'zoned'` for host-managed or host-aware SMR and ZNS.
1244
+ */
1245
+ export type TDriveMedia = 'rotational' | 'smr' | 'flash' | 'zoned';
1246
+
1247
+ /**
1248
+ * A drive's media.
1249
+ */
1250
+ export interface IClusterDriveMedia {
1251
+ /**
1252
+ * The media the configuration declares for the drive. Not reported yet:
1253
+ * drives cannot declare their media.
1254
+ */
1255
+ declared?: TDriveMedia;
1256
+ /**
1257
+ * What the engine detected once at startup, from the whole disk behind the
1258
+ * drive root's filesystem (Linux sysfs `queue/zoned` and `queue/rotational`;
1259
+ * a partition counts as its disk). `'unknown'` for filesystems without a
1260
+ * block device of their own (tmpfs, overlay, network filesystems),
1261
+ * device-mapper, md, loop and other virtual block devices, and on every
1262
+ * platform but Linux. Virtual and network block devices report the
1263
+ * rotational flag their driver or hypervisor sets (QEMU disks show
1264
+ * `'rotational'` unless configured with `rotation_rate=1`).
1265
+ */
1266
+ detected: TDriveMedia | 'unknown';
1267
+ }
1268
+
1088
1269
  /**
1089
1270
  * Local drive health as measured by objectstorage's runtime probes.
1090
1271
  */
@@ -1099,6 +1280,7 @@ export interface IClusterDriveHealth {
1099
1280
  lastError?: string;
1100
1281
  lastCheck?: number;
1101
1282
  erasureSetId?: number;
1283
+ media: IClusterDriveMedia;
1102
1284
  }
1103
1285
 
1104
1286
  export interface IClusterErasureHealth {
@@ -1119,10 +1301,31 @@ export interface IClusterRepairHealth {
1119
1301
  lastDurationMs?: number;
1120
1302
  shardsChecked?: number;
1121
1303
  shardsHealed?: number;
1304
+ /** Pending shards (writes that failed at publication) the last scan left pending. */
1305
+ shardsPending?: number;
1122
1306
  failed?: number;
1123
1307
  lastError?: string;
1124
1308
  }
1125
1309
 
1310
+ export interface IClusterTransportCertificate {
1311
+ /** SHA-256 of the DER certificate, lowercase hex. */
1312
+ sha256: string;
1313
+ /** Milliseconds since the Unix epoch. */
1314
+ notAfter: number;
1315
+ }
1316
+
1317
+ /**
1318
+ * How the cluster transport authenticates peers, and the certificates it
1319
+ * loaded at startup.
1320
+ */
1321
+ export interface IClusterTransportHealth {
1322
+ mode: 'mutualTls' | 'unauthenticated';
1323
+ /** This node's certificate; only in mode 'mutualTls'. */
1324
+ certificate?: IClusterTransportCertificate;
1325
+ /** The loaded CA bundle in file order; only in mode 'mutualTls'. */
1326
+ caCertificates?: IClusterTransportCertificate[];
1327
+ }
1328
+
1126
1329
  /**
1127
1330
  * Cluster runtime health from the Rust core.
1128
1331
  * When clustering is disabled, the response is `{ enabled: false }`.
@@ -1138,6 +1341,36 @@ export interface IClusterHealth {
1138
1341
  drives?: IClusterDriveHealth[];
1139
1342
  erasure?: IClusterErasureHealth;
1140
1343
  repairs?: IClusterRepairHealth;
1344
+ transport?: IClusterTransportHealth;
1345
+ }
1346
+
1347
+ /**
1348
+ * The result of one cluster healing scan, as `ObjectStorage.runHealingScan()`
1349
+ * returns it. Times are milliseconds since the Unix epoch.
1350
+ */
1351
+ export interface IClusterHealingScanResult {
1352
+ /** When the scan started; always after the request arrived. */
1353
+ startedAt: number;
1354
+ completedAt: number;
1355
+ durationMs: number;
1356
+ /** Shard placements checked at their drives (placements on offline nodes are not). */
1357
+ shardsChecked: number;
1358
+ /** Shards written: missing placements rebuilt and pending shards completed. */
1359
+ shardsHealed: number;
1360
+ /** Pending shards (writes that failed at publication) this scan left pending. */
1361
+ shardsPending: number;
1362
+ /** Failed checks, rebuilds, writes and manifest stores. */
1363
+ failed: number;
1364
+ }
1365
+
1366
+ export interface IRunHealingScanOptions {
1367
+ /**
1368
+ * How long to wait for the scan, in milliseconds. Defaults to the bridge's
1369
+ * request timeout (300,000). The engine cannot cancel a scan: one that
1370
+ * outlasts this keeps running, its result is discarded, and a later call
1371
+ * joins the next scan that starts after it.
1372
+ */
1373
+ timeoutMs?: number;
1141
1374
  }
1142
1375
 
1143
1376
  /**
@@ -1185,6 +1418,7 @@ const DEFAULT_CONFIG: IObjectStorageConfig = {
1185
1418
  multipart: {
1186
1419
  expirationDays: 7,
1187
1420
  cleanupIntervalMinutes: 60,
1421
+ maxUploadsPerPrefixScope: 100,
1188
1422
  },
1189
1423
  };
1190
1424
 
@@ -1309,10 +1543,41 @@ function mergeConfig(userConfig: IObjectStorageConfig): TObjectStorageResolvedCo
1309
1543
  } as TObjectStorageResolvedConfig;
1310
1544
  }
1311
1545
 
1546
+ const DEFAULT_DASHBOARD_ADDRESS = '127.0.0.1';
1547
+ const DEFAULT_DASHBOARD_PORT = 3001;
1548
+
1549
+ function resolveDashboardConfig(
1550
+ dashboardArg: IObjectStorageDashboardConfig | undefined,
1551
+ s3PortArg: number,
1552
+ ): Required<IObjectStorageDashboardConfig> | undefined {
1553
+ if (dashboardArg === undefined) {
1554
+ return undefined;
1555
+ }
1556
+ if (typeof dashboardArg.enabled !== 'boolean') {
1557
+ throw new Error('dashboard.enabled must be a boolean.');
1558
+ }
1559
+ const address = dashboardArg.address ?? DEFAULT_DASHBOARD_ADDRESS;
1560
+ if (typeof address !== 'string' || address.length === 0) {
1561
+ throw new Error('dashboard.address must be a non-empty string.');
1562
+ }
1563
+ const port = dashboardArg.port ?? DEFAULT_DASHBOARD_PORT;
1564
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
1565
+ throw new Error('dashboard.port must be an integer from 0 to 65535.');
1566
+ }
1567
+ if (dashboardArg.enabled && port !== 0 && port === s3PortArg) {
1568
+ throw new Error(`dashboard.port ${port} is the S3 port; the dashboard needs its own port.`);
1569
+ }
1570
+ return { enabled: dashboardArg.enabled, address, port };
1571
+ }
1572
+
1312
1573
  function createAccessKeyId(): string {
1313
1574
  return `SS${plugins.crypto.randomBytes(10).toString('hex').toUpperCase()}`;
1314
1575
  }
1315
1576
 
1577
+ function createPrefixAccessKeyId(): string {
1578
+ return `SP${plugins.crypto.randomBytes(10).toString('hex').toUpperCase()}`;
1579
+ }
1580
+
1316
1581
  function createSecretAccessKey(): string {
1317
1582
  return plugins.crypto.randomBytes(32).toString('hex');
1318
1583
  }
@@ -1441,7 +1706,30 @@ type TRustStorageCommands = {
1441
1706
  listBucketSummaries: { params: {}; result: IBucketSummary[] };
1442
1707
  listCredentials: { params: {}; result: IStorageCredentialMetadata[] };
1443
1708
  replaceCredentials: { params: { credentials: IStorageCredential[] }; result: {} };
1709
+ createPrefixCredential: {
1710
+ params: {
1711
+ bucketName: string;
1712
+ prefix: string;
1713
+ accessKeyId: string;
1714
+ secretAccessKey: string;
1715
+ region?: string;
1716
+ ttlSeconds?: number;
1717
+ expiresAt?: number;
1718
+ };
1719
+ result: IPrefixCredentialMetadata;
1720
+ };
1721
+ revokePrefixCredential: { params: { accessKeyId: string }; result: { revoked: boolean } };
1722
+ listPrefixCredentials: {
1723
+ params: { bucketName?: string };
1724
+ result: IPrefixCredentialMetadata[];
1725
+ };
1726
+ getPrefixCredentialStatus: {
1727
+ params: {};
1728
+ result: { available: boolean; credentialCount: number; tombstoneCount: number };
1729
+ };
1444
1730
  getClusterHealth: { params: {}; result: IClusterHealth };
1731
+ getClusterSnapshot: { params: {}; result: IObjectStorageClusterSnapshot };
1732
+ runHealingScan: { params: {}; result: IClusterHealingScanResult };
1445
1733
  };
1446
1734
 
1447
1735
  /**
@@ -1525,6 +1813,8 @@ export class ObjectStorage {
1525
1813
  private bridgeOwned = false;
1526
1814
  private stopRequested = false;
1527
1815
  private stopCompleted = false;
1816
+ private readonly dashboardConfig: Required<IObjectStorageDashboardConfig> | undefined;
1817
+ private dashboard: ObjectStorageDashboard | undefined;
1528
1818
 
1529
1819
  constructor(configArg: IObjectStorageConfig = {}) {
1530
1820
  const binaryPath = configArg.binaryPath;
@@ -1535,6 +1825,7 @@ export class ObjectStorage {
1535
1825
  throw new Error('binaryPath must be a non-empty executable path.');
1536
1826
  }
1537
1827
  this.config = mergeConfig(configArg);
1828
+ this.dashboardConfig = resolveDashboardConfig(configArg.dashboard, this.config.server.port!);
1538
1829
  this.bridge = new plugins.RustBridge<TRustStorageCommands>({
1539
1830
  binaryName: 'ruststorage',
1540
1831
  ...(binaryPath !== undefined
@@ -1600,6 +1891,23 @@ export class ObjectStorage {
1600
1891
  throw new Error('Storage engine did not report a valid bound TCP port.');
1601
1892
  }
1602
1893
  this.boundPort = result.port;
1894
+ if (this.dashboardConfig?.enabled) {
1895
+ // Enabled but unable to start (a port in use, say) fails startup:
1896
+ // the catch below stops the engine.
1897
+ const { ObjectStorageDashboard } = await import('./classes.dashboard.js');
1898
+ const dashboard = new ObjectStorageDashboard({
1899
+ address: this.dashboardConfig.address,
1900
+ port: this.dashboardConfig.port,
1901
+ readSnapshot: () => this.getClusterSnapshot(),
1902
+ });
1903
+ try {
1904
+ await dashboard.start();
1905
+ } catch (dashboardError) {
1906
+ await dashboard.stop();
1907
+ throw dashboardError;
1908
+ }
1909
+ this.dashboard = dashboard;
1910
+ }
1603
1911
  } catch (error) {
1604
1912
  try {
1605
1913
  if (this.bridgeOwned) {
@@ -1627,6 +1935,11 @@ export class ObjectStorage {
1627
1935
  return this.running ? this.boundPort : this.config.server.port!;
1628
1936
  }
1629
1937
 
1938
+ /** The dashboard's bound TCP port while it runs; `undefined` otherwise. */
1939
+ public get dashboardPort(): number | undefined {
1940
+ return this.dashboard?.port;
1941
+ }
1942
+
1630
1943
  public async getStorageDescriptor(
1631
1944
  optionsArg?: Partial<plugins.tsclass.storage.IS3Descriptor>,
1632
1945
  ): Promise<plugins.tsclass.storage.IS3Descriptor> {
@@ -1730,7 +2043,7 @@ export class ObjectStorage {
1730
2043
  });
1731
2044
  return this.buildBucketTenantDescriptor(credential, tenantArg.bucketName);
1732
2045
  } catch (error) {
1733
- throw normalizeResourceFenceError(error);
2046
+ throw normalizeCredentialError(normalizeResourceFenceError(error));
1734
2047
  }
1735
2048
  }
1736
2049
 
@@ -1745,7 +2058,7 @@ export class ObjectStorage {
1745
2058
  });
1746
2059
  return this.buildBucketTenantDescriptor(credential, tenantArg.bucketName);
1747
2060
  } catch (error) {
1748
- throw normalizeResourceFenceError(error);
2061
+ throw normalizeCredentialError(normalizeResourceFenceError(error));
1749
2062
  }
1750
2063
  }
1751
2064
 
@@ -1754,7 +2067,7 @@ export class ObjectStorage {
1754
2067
  try {
1755
2068
  await this.bridge.sendCommand('deleteBucketTenant', tenantArg);
1756
2069
  } catch (error) {
1757
- throw normalizeResourceFenceError(error);
2070
+ throw normalizeCredentialError(normalizeResourceFenceError(error));
1758
2071
  }
1759
2072
  }
1760
2073
 
@@ -1771,7 +2084,7 @@ export class ObjectStorage {
1771
2084
  });
1772
2085
  return this.buildBucketTenantDescriptor(credential, tenantArg.bucketName);
1773
2086
  } catch (error) {
1774
- throw normalizeResourceFenceError(error);
2087
+ throw normalizeCredentialError(normalizeResourceFenceError(error));
1775
2088
  }
1776
2089
  }
1777
2090
 
@@ -1790,6 +2103,95 @@ export class ObjectStorage {
1790
2103
  return this.buildBucketTenantDescriptor(credential, optionsArg.bucketName);
1791
2104
  }
1792
2105
 
2106
+ /**
2107
+ * Creates a credential confined to one key prefix of one bucket until its
2108
+ * expiry. The first node takes `ttlSeconds`; further nodes of a fan-out take
2109
+ * the first result's `accessKeyId`, `secretAccessKey` and `expiresAt`. The
2110
+ * secret is returned only here and never kept by this instance.
2111
+ */
2112
+ public async createPrefixCredential(
2113
+ inputArg: IPrefixCredentialInput,
2114
+ ): Promise<IPrefixCredential> {
2115
+ const hasTtl = inputArg.ttlSeconds !== undefined;
2116
+ const hasExpiresAt = inputArg.expiresAt !== undefined;
2117
+ if (hasTtl === hasExpiresAt) {
2118
+ throw new ObjectStorageCredentialError(
2119
+ 'ECREDENTIAL_INVALID_TTL',
2120
+ 'Exactly one of ttlSeconds and expiresAt is required.',
2121
+ );
2122
+ }
2123
+ if (hasTtl && (!Number.isSafeInteger(inputArg.ttlSeconds) || inputArg.ttlSeconds! < 1)) {
2124
+ throw new ObjectStorageCredentialError(
2125
+ 'ECREDENTIAL_INVALID_TTL',
2126
+ 'ttlSeconds must be an integer from 1 to 43200.',
2127
+ );
2128
+ }
2129
+ if (hasExpiresAt && !Number.isSafeInteger(inputArg.expiresAt)) {
2130
+ throw new ObjectStorageCredentialError(
2131
+ 'ECREDENTIAL_INVALID_EXPIRY',
2132
+ 'expiresAt must be an integer epoch millisecond.',
2133
+ );
2134
+ }
2135
+ const hasAccessKeyId = inputArg.accessKeyId !== undefined;
2136
+ const hasSecretAccessKey = inputArg.secretAccessKey !== undefined;
2137
+ if (hasAccessKeyId !== hasSecretAccessKey) {
2138
+ throw new ObjectStorageCredentialError(
2139
+ 'ECREDENTIAL_INVALID_KEY',
2140
+ 'accessKeyId and secretAccessKey must be supplied together.',
2141
+ );
2142
+ }
2143
+ if (hasAccessKeyId && !hasExpiresAt) {
2144
+ throw new ObjectStorageCredentialError(
2145
+ 'ECREDENTIAL_INVALID_KEY',
2146
+ 'Key material is accepted only with expiresAt, to install an existing credential.',
2147
+ );
2148
+ }
2149
+ const secretAccessKey = inputArg.secretAccessKey ?? createSecretAccessKey();
2150
+ try {
2151
+ const metadata = await this.bridge.sendCommand('createPrefixCredential', {
2152
+ bucketName: inputArg.bucketName,
2153
+ prefix: inputArg.prefix,
2154
+ accessKeyId: inputArg.accessKeyId ?? createPrefixAccessKeyId(),
2155
+ secretAccessKey,
2156
+ region: inputArg.region || this.config.server.region,
2157
+ ...(hasTtl ? { ttlSeconds: inputArg.ttlSeconds } : { expiresAt: inputArg.expiresAt }),
2158
+ });
2159
+ return { ...metadata, secretAccessKey };
2160
+ } catch (error) {
2161
+ throw normalizeCredentialError(error);
2162
+ }
2163
+ }
2164
+
2165
+ /**
2166
+ * Revokes a prefix credential on this node. An unknown ID is recorded as
2167
+ * revoked too, so a later create for it is refused; `revoked` is true only
2168
+ * when a live credential was removed.
2169
+ */
2170
+ public async revokePrefixCredential(optionsArg: {
2171
+ accessKeyId: string;
2172
+ }): Promise<{ revoked: boolean }> {
2173
+ try {
2174
+ return await this.bridge.sendCommand('revokePrefixCredential', {
2175
+ accessKeyId: optionsArg.accessKeyId,
2176
+ });
2177
+ } catch (error) {
2178
+ throw normalizeCredentialError(error);
2179
+ }
2180
+ }
2181
+
2182
+ /** Lists this node's live prefix credentials, without secrets. */
2183
+ public async listPrefixCredentials(
2184
+ optionsArg: { bucketName?: string } = {},
2185
+ ): Promise<IPrefixCredentialMetadata[]> {
2186
+ try {
2187
+ return await this.bridge.sendCommand('listPrefixCredentials', {
2188
+ ...(optionsArg.bucketName !== undefined ? { bucketName: optionsArg.bucketName } : {}),
2189
+ });
2190
+ } catch (error) {
2191
+ throw normalizeCredentialError(error);
2192
+ }
2193
+ }
2194
+
1793
2195
  public async getBucketRetentionCapability(): Promise<IBucketRetentionCapability> {
1794
2196
  return this.bridge.sendCommand('getBucketRetentionCapability', {});
1795
2197
  }
@@ -1864,7 +2266,7 @@ export class ObjectStorage {
1864
2266
  try {
1865
2267
  return await this.bridge.sendCommand('deleteBucket', optionsArg);
1866
2268
  } catch (error) {
1867
- throw normalizeResourceFenceError(error);
2269
+ throw normalizeCredentialError(normalizeResourceFenceError(error));
1868
2270
  }
1869
2271
  }
1870
2272
 
@@ -1914,7 +2316,11 @@ export class ObjectStorage {
1914
2316
  * if this override introduces ownership or credential-material drift.
1915
2317
  */
1916
2318
  public async replaceCredentials(credentials: IStorageCredential[]): Promise<void> {
1917
- await this.bridge.sendCommand('replaceCredentials', { credentials });
2319
+ try {
2320
+ await this.bridge.sendCommand('replaceCredentials', { credentials });
2321
+ } catch (error) {
2322
+ throw normalizeCredentialError(error);
2323
+ }
1918
2324
  this.config.auth.credentials = credentials.map((credential) => ({ ...credential }));
1919
2325
  }
1920
2326
 
@@ -1922,6 +2328,38 @@ export class ObjectStorage {
1922
2328
  return this.bridge.sendCommand('getClusterHealth', {});
1923
2329
  }
1924
2330
 
2331
+ /**
2332
+ * The dashboard's snapshot, built by the engine from memory only: it never
2333
+ * probes a drive, syncs or walks storage. A cluster node also asks each
2334
+ * online peer for its own snapshot, waiting up to a second per peer, in
2335
+ * parallel; a peer that does not answer is reported unavailable. One
2336
+ * snapshot runs at a time: a call while another runs rejects.
2337
+ */
2338
+ public async getClusterSnapshot(): Promise<IObjectStorageClusterSnapshot> {
2339
+ return this.bridge.sendCommand('getClusterSnapshot', {});
2340
+ }
2341
+
2342
+ /**
2343
+ * Runs a healing scan on this cluster node and resolves with its result.
2344
+ * The scan covers the manifests this node coordinated. The request joins
2345
+ * the next scan that starts after it arrives, never one already running,
2346
+ * and requests that arrive together share one scan. It rejects in
2347
+ * standalone mode, when the scan fails (for example without a majority of
2348
+ * the cluster, or when a bucket's manifests cannot be listed), when 16
2349
+ * requests already wait for a scan to start, and when the engine shuts down
2350
+ * before the scan completes. The engine answers other management calls
2351
+ * while the scan runs.
2352
+ */
2353
+ public async runHealingScan(
2354
+ optionsArg: IRunHealingScanOptions = {},
2355
+ ): Promise<IClusterHealingScanResult> {
2356
+ return this.bridge.sendCommand(
2357
+ 'runHealingScan',
2358
+ {},
2359
+ optionsArg.timeoutMs === undefined ? undefined : { timeoutMs: optionsArg.timeoutMs },
2360
+ );
2361
+ }
2362
+
1925
2363
  public async getHealth(): Promise<IObjectStorageHealth> {
1926
2364
  if (!this.running) {
1927
2365
  return {
@@ -1933,6 +2371,9 @@ export class ObjectStorage {
1933
2371
  enabled: this.config.auth.enabled,
1934
2372
  credentialCount: this.config.auth.credentials.length,
1935
2373
  tenantCredentialCount: 0,
2374
+ prefixCredentialCount: 0,
2375
+ prefixCredentialTombstoneCount: 0,
2376
+ prefixCredentialStoreAvailable: false,
1936
2377
  },
1937
2378
  bucketCount: 0,
1938
2379
  objectCount: 0,
@@ -1947,13 +2388,15 @@ export class ObjectStorage {
1947
2388
  };
1948
2389
  }
1949
2390
 
1950
- const [stats, credentials, tenants, cluster, resourceFencing] = await Promise.all([
1951
- this.getStorageStats(),
1952
- this.listCredentials(),
1953
- this.config.auth.enabled ? this.listBucketTenants() : Promise.resolve([]),
1954
- this.getClusterHealth(),
1955
- this.getResourceFencingCapability(),
1956
- ]);
2391
+ const [stats, credentials, tenants, prefixCredentials, cluster, resourceFencing] =
2392
+ await Promise.all([
2393
+ this.getStorageStats(),
2394
+ this.listCredentials(),
2395
+ this.config.auth.enabled ? this.listBucketTenants() : Promise.resolve([]),
2396
+ this.bridge.sendCommand('getPrefixCredentialStatus', {}),
2397
+ this.getClusterHealth(),
2398
+ this.getResourceFencingCapability(),
2399
+ ]);
1957
2400
  return {
1958
2401
  ok: true,
1959
2402
  running: true,
@@ -1963,6 +2406,9 @@ export class ObjectStorage {
1963
2406
  enabled: this.config.auth.enabled,
1964
2407
  credentialCount: credentials.length,
1965
2408
  tenantCredentialCount: tenants.length,
2409
+ prefixCredentialCount: prefixCredentials.credentialCount,
2410
+ prefixCredentialTombstoneCount: prefixCredentials.tombstoneCount,
2411
+ prefixCredentialStoreAvailable: prefixCredentials.available,
1966
2412
  },
1967
2413
  bucketCount: stats.bucketCount,
1968
2414
  objectCount: stats.totalObjectCount,
@@ -2002,6 +2448,15 @@ export class ObjectStorage {
2002
2448
  '# HELP objectstorage_tenant_credentials_total Scoped bucket tenant credential count.',
2003
2449
  '# TYPE objectstorage_tenant_credentials_total gauge',
2004
2450
  `objectstorage_tenant_credentials_total ${health.auth.tenantCredentialCount}`,
2451
+ '# HELP objectstorage_prefix_credentials_total Live prefix credential count.',
2452
+ '# TYPE objectstorage_prefix_credentials_total gauge',
2453
+ `objectstorage_prefix_credentials_total ${health.auth.prefixCredentialCount}`,
2454
+ '# HELP objectstorage_prefix_credential_tombstones_total Unexpired prefix credential revocations.',
2455
+ '# TYPE objectstorage_prefix_credential_tombstones_total gauge',
2456
+ `objectstorage_prefix_credential_tombstones_total ${health.auth.prefixCredentialTombstoneCount}`,
2457
+ '# HELP objectstorage_prefix_credential_store_available Prefix credential store readable.',
2458
+ '# TYPE objectstorage_prefix_credential_store_available gauge',
2459
+ `objectstorage_prefix_credential_store_available ${health.auth.prefixCredentialStoreAvailable ? 1 : 0}`,
2005
2460
  '# HELP objectstorage_cluster_enabled Cluster mode enabled.',
2006
2461
  '# TYPE objectstorage_cluster_enabled gauge',
2007
2462
  `objectstorage_cluster_enabled ${clusterEnabled ? 1 : 0}`,
@@ -2034,6 +2489,10 @@ export class ObjectStorage {
2034
2489
  // Startup keeps its own rejection; stop joins only its resource ownership.
2035
2490
  await Promise.allSettled([this.startPromise]);
2036
2491
  }
2492
+ if (this.dashboard) {
2493
+ await this.dashboard.stop();
2494
+ this.dashboard = undefined;
2495
+ }
2037
2496
  if (this.bridgeOwned) {
2038
2497
  if (this.running) {
2039
2498
  this.completedStopResult ??= await this.bridge.sendCommand('stop', {});