@objectstack/service-storage 4.0.5 → 4.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.
package/README.md CHANGED
@@ -7,7 +7,7 @@ Storage Service for ObjectStack — implements `IStorageService` with local file
7
7
  - **Multiple Adapters**: Local filesystem (development) and S3-compatible storage (production)
8
8
  - **Presigned Uploads**: Browser-direct upload via presigned URLs (S3 native, local HMAC-signed tokens)
9
9
  - **Chunked / Multipart Upload**: Resumable large file uploads with progress tracking
10
- - **File Metadata Store**: `system_file` object tracks fileId → key mapping and lifecycle status
10
+ - **File Metadata Store**: `sys_file` object tracks fileId → key mapping and lifecycle status
11
11
  - **REST Routes**: Auto-mounted `/api/v1/storage/*` endpoints consumed by `@objectstack/client`
12
12
  - **Type-Safe**: Full TypeScript support with Zod-validated API contracts
13
13
 
@@ -125,7 +125,7 @@ const completed = await client.storage.resumeUpload(
125
125
  ▼ ▼
126
126
  ┌─────────────────┐ ┌─────────────────┐
127
127
  │ MetadataStore │ │ Filesystem / S3 │
128
- │ (system_file) │ │ (actual bytes) │
128
+ │ (sys_file) │ │ (actual bytes) │
129
129
  └─────────────────┘ └─────────────────┘
130
130
  ```
131
131
 
@@ -133,9 +133,41 @@ const completed = await client.storage.resumeUpload(
133
133
 
134
134
  The plugin registers two system objects via the manifest service:
135
135
 
136
- - **`system_file`** — File metadata (fileId, key, name, mimeType, size, scope, status)
137
- - **`system_upload_session`** — Chunked upload state (progress, parts, resumeToken)
136
+ - **`sys_file`** — File metadata (fileId, key, name, mimeType, size, scope, status)
137
+ - **`sys_upload_session`** — Chunked upload state (progress, parts, resumeToken)
138
138
 
139
139
  ## License
140
140
 
141
141
  Apache-2.0
142
+
143
+ ## UI-driven configuration
144
+
145
+ `StorageServicePlugin` registers a `storage` Settings namespace (mail-style)
146
+ so administrators can switch adapter, configure S3 credentials, and tune
147
+ TTL / max-upload limits from the Settings hub instead of restarting the
148
+ process.
149
+
150
+ - Service key in the kernel: `file-storage` — registered as a
151
+ `SwappableStorageService` proxy at `init` time. The inner adapter
152
+ (local FS or S3) is rebuilt and swapped in on every `settings:changed`
153
+ event for `namespace=storage`.
154
+ - The S3 secret key is stored encrypted in `sys_secret` via the
155
+ CryptoAdapter / KMS chain set up by `service-settings`.
156
+ - A `storage/test` action handler uploads → downloads → deletes a small
157
+ probe blob to validate the configuration end-to-end. The handler is
158
+ registered on `kernel:ready`; the `service-settings` package ships a
159
+ validation-only fallback for kernels that mount Settings but not
160
+ Storage.
161
+
162
+ ### ⚠ Switching adapters does not migrate files
163
+
164
+ Files uploaded under the previous adapter remain on that backend and
165
+ become unreachable through the new one. The plugin logs a warning on
166
+ every swap. Migrate data out-of-band (e.g. `aws s3 sync` from the local
167
+ root to the new bucket) before flipping the toggle in production.
168
+
169
+ ### Disabling the live-wire
170
+
171
+ Pass `bindToSettings: false` to keep the constructor-supplied adapter
172
+ frozen — useful in tests and in deployments where storage config must
173
+ come from env vars only.
package/dist/index.cjs CHANGED
@@ -301,6 +301,7 @@ __export(index_exports, {
301
301
  S3StorageAdapter: () => S3StorageAdapter,
302
302
  StorageMetadataStore: () => StorageMetadataStore,
303
303
  StorageServicePlugin: () => StorageServicePlugin,
304
+ SwappableStorageService: () => SwappableStorageService,
304
305
  SystemFile: () => SystemFile,
305
306
  SystemUploadSession: () => SystemUploadSession,
306
307
  registerStorageRoutes: () => registerStorageRoutes
@@ -515,6 +516,9 @@ var LocalStorageAdapter = class {
515
516
  }
516
517
  };
517
518
 
519
+ // src/storage-service-plugin.ts
520
+ init_s3_storage_adapter();
521
+
518
522
  // src/metadata-store.ts
519
523
  var StorageMetadataStore = class {
520
524
  constructor(engine) {
@@ -531,7 +535,7 @@ var StorageMetadataStore = class {
531
535
  this.files.set(full.id, full);
532
536
  if (this.engine) {
533
537
  try {
534
- await this.engine.insert("system_file", full);
538
+ await this.engine.insert("sys_file", full);
535
539
  } catch {
536
540
  }
537
541
  }
@@ -540,7 +544,7 @@ var StorageMetadataStore = class {
540
544
  async getFile(id) {
541
545
  if (this.engine) {
542
546
  try {
543
- const found = await this.engine.findOne("system_file", { where: { id } });
547
+ const found = await this.engine.findOne("sys_file", { where: { id } });
544
548
  if (found) return found;
545
549
  } catch {
546
550
  }
@@ -554,7 +558,7 @@ var StorageMetadataStore = class {
554
558
  this.files.set(id, merged);
555
559
  if (this.engine) {
556
560
  try {
557
- await this.engine.update("system_file", merged, { where: { id } });
561
+ await this.engine.update("sys_file", merged, { where: { id } });
558
562
  } catch {
559
563
  }
560
564
  }
@@ -564,7 +568,7 @@ var StorageMetadataStore = class {
564
568
  this.files.delete(id);
565
569
  if (this.engine) {
566
570
  try {
567
- await this.engine.delete("system_file", { where: { id } });
571
+ await this.engine.delete("sys_file", { where: { id } });
568
572
  } catch {
569
573
  }
570
574
  }
@@ -585,7 +589,7 @@ var StorageMetadataStore = class {
585
589
  this.sessions.set(full.id, full);
586
590
  if (this.engine) {
587
591
  try {
588
- await this.engine.insert("system_upload_session", full);
592
+ await this.engine.insert("sys_upload_session", full);
589
593
  } catch {
590
594
  }
591
595
  }
@@ -594,7 +598,7 @@ var StorageMetadataStore = class {
594
598
  async getSession(id) {
595
599
  if (this.engine) {
596
600
  try {
597
- const found = await this.engine.findOne("system_upload_session", { where: { id } });
601
+ const found = await this.engine.findOne("sys_upload_session", { where: { id } });
598
602
  if (found) return found;
599
603
  } catch {
600
604
  }
@@ -613,7 +617,7 @@ var StorageMetadataStore = class {
613
617
  this.sessions.set(id, merged);
614
618
  if (this.engine) {
615
619
  try {
616
- await this.engine.update("system_upload_session", merged, { where: { id } });
620
+ await this.engine.update("sys_upload_session", merged, { where: { id } });
617
621
  } catch {
618
622
  }
619
623
  }
@@ -623,7 +627,7 @@ var StorageMetadataStore = class {
623
627
  this.sessions.delete(id);
624
628
  if (this.engine) {
625
629
  try {
626
- await this.engine.delete("system_upload_session", { where: { id } });
630
+ await this.engine.delete("sys_upload_session", { where: { id } });
627
631
  } catch {
628
632
  }
629
633
  }
@@ -916,6 +920,28 @@ function registerStorageRoutes(httpServer, storage, store, opts = {}) {
916
920
  res.status(500).json({ error: err.message ?? "Internal error" });
917
921
  }
918
922
  });
923
+ httpServer.get(`${basePath}/files/:fileId`, async (req, res) => {
924
+ try {
925
+ const { fileId } = req.params;
926
+ const file = await store.getFile(fileId);
927
+ if (!file || file.status !== "committed") {
928
+ res.status(404).json({ error: "File not found or not committed" });
929
+ return;
930
+ }
931
+ let url;
932
+ if (storage.getPresignedDownload) {
933
+ const desc = await storage.getPresignedDownload(file.key, presignedTtl);
934
+ url = desc.downloadUrl;
935
+ } else if (storage.getSignedUrl) {
936
+ url = await storage.getSignedUrl(file.key, presignedTtl);
937
+ } else {
938
+ url = `${basePath}/_local/file/${encodeURIComponent(file.key)}`;
939
+ }
940
+ res.status(302).header("Location", url).send("");
941
+ } catch (err) {
942
+ res.status(500).json({ error: err.message ?? "Internal error" });
943
+ }
944
+ });
919
945
  httpServer.put(`${basePath}/_local/raw/:token`, async (req, res) => {
920
946
  try {
921
947
  const { token } = req.params;
@@ -968,7 +994,7 @@ function buildKey(scope, fileId, filename) {
968
994
  // src/objects/system-file.object.ts
969
995
  var import_data = require("@objectstack/spec/data");
970
996
  var SystemFile = import_data.ObjectSchema.create({
971
- name: "system_file",
997
+ name: "sys_file",
972
998
  label: "System File",
973
999
  pluralLabel: "System Files",
974
1000
  icon: "file",
@@ -1014,7 +1040,7 @@ var SystemFile = import_data.ObjectSchema.create({
1014
1040
  label: "ACL",
1015
1041
  options: [
1016
1042
  { label: "Private", value: "private" },
1017
- { label: "Public Read", value: "public-read" }
1043
+ { label: "Public Read", value: "public_read" }
1018
1044
  ]
1019
1045
  }),
1020
1046
  status: import_data.Field.select({
@@ -1047,7 +1073,7 @@ var SystemFile = import_data.ObjectSchema.create({
1047
1073
  // src/objects/system-upload-session.object.ts
1048
1074
  var import_data2 = require("@objectstack/spec/data");
1049
1075
  var SystemUploadSession = import_data2.ObjectSchema.create({
1050
- name: "system_upload_session",
1076
+ name: "sys_upload_session",
1051
1077
  label: "System Upload Session",
1052
1078
  pluralLabel: "System Upload Sessions",
1053
1079
  icon: "upload-cloud",
@@ -1134,6 +1160,103 @@ var SystemUploadSession = import_data2.ObjectSchema.create({
1134
1160
  }
1135
1161
  });
1136
1162
 
1163
+ // src/swappable-storage-service.ts
1164
+ var SwappableStorageService = class {
1165
+ constructor(initial, onSwap) {
1166
+ this.inner = initial;
1167
+ this.onSwap = onSwap;
1168
+ }
1169
+ /** Replace the inner adapter. */
1170
+ swap(next) {
1171
+ const previous = this.inner;
1172
+ this.inner = next;
1173
+ this.onSwap?.(previous, next);
1174
+ }
1175
+ /** Expose the active inner adapter — primarily for tests. */
1176
+ getInner() {
1177
+ return this.inner;
1178
+ }
1179
+ upload(key, data, options) {
1180
+ return this.inner.upload(key, data, options);
1181
+ }
1182
+ download(key) {
1183
+ return this.inner.download(key);
1184
+ }
1185
+ delete(key) {
1186
+ return this.inner.delete(key);
1187
+ }
1188
+ exists(key) {
1189
+ return this.inner.exists(key);
1190
+ }
1191
+ getInfo(key) {
1192
+ return this.inner.getInfo(key);
1193
+ }
1194
+ list(prefix) {
1195
+ if (typeof this.inner.list !== "function") {
1196
+ return Promise.reject(new Error("Active storage adapter does not support list()"));
1197
+ }
1198
+ return this.inner.list(prefix);
1199
+ }
1200
+ getSignedUrl(key, expiresIn) {
1201
+ if (typeof this.inner.getSignedUrl !== "function") {
1202
+ return Promise.reject(new Error("Active storage adapter does not support getSignedUrl()"));
1203
+ }
1204
+ return this.inner.getSignedUrl(key, expiresIn);
1205
+ }
1206
+ getPresignedUpload(key, expiresIn, options) {
1207
+ if (typeof this.inner.getPresignedUpload !== "function") {
1208
+ return Promise.reject(new Error("Active storage adapter does not support getPresignedUpload()"));
1209
+ }
1210
+ return this.inner.getPresignedUpload(key, expiresIn, options);
1211
+ }
1212
+ getPresignedDownload(key, expiresIn) {
1213
+ if (typeof this.inner.getPresignedDownload !== "function") {
1214
+ return Promise.reject(new Error("Active storage adapter does not support getPresignedDownload()"));
1215
+ }
1216
+ return this.inner.getPresignedDownload(key, expiresIn);
1217
+ }
1218
+ initiateChunkedUpload(key, options) {
1219
+ if (typeof this.inner.initiateChunkedUpload !== "function") {
1220
+ return Promise.reject(new Error("Active storage adapter does not support initiateChunkedUpload()"));
1221
+ }
1222
+ return this.inner.initiateChunkedUpload(key, options);
1223
+ }
1224
+ uploadChunk(uploadId, partNumber, data) {
1225
+ if (typeof this.inner.uploadChunk !== "function") {
1226
+ return Promise.reject(new Error("Active storage adapter does not support uploadChunk()"));
1227
+ }
1228
+ return this.inner.uploadChunk(uploadId, partNumber, data);
1229
+ }
1230
+ completeChunkedUpload(uploadId, parts) {
1231
+ if (typeof this.inner.completeChunkedUpload !== "function") {
1232
+ return Promise.reject(new Error("Active storage adapter does not support completeChunkedUpload()"));
1233
+ }
1234
+ return this.inner.completeChunkedUpload(uploadId, parts);
1235
+ }
1236
+ abortChunkedUpload(uploadId) {
1237
+ if (typeof this.inner.abortChunkedUpload !== "function") {
1238
+ return Promise.reject(new Error("Active storage adapter does not support abortChunkedUpload()"));
1239
+ }
1240
+ return this.inner.abortChunkedUpload(uploadId);
1241
+ }
1242
+ /**
1243
+ * Verify a presigned HMAC token (LocalStorageAdapter-specific).
1244
+ *
1245
+ * `IStorageService` does not declare this method, but `storage-routes`
1246
+ * type-narrows the active storage to `LocalStorageAdapter` to handle the
1247
+ * `/_local/raw/:token` PUT and GET endpoints. Without a passthrough on
1248
+ * the swappable wrapper, the route sees `verifyToken === undefined` and
1249
+ * returns 501 even though the underlying local adapter supports it.
1250
+ */
1251
+ verifyToken(token, expectedOp) {
1252
+ const inner = this.inner;
1253
+ if (typeof inner.verifyToken !== "function") {
1254
+ throw new Error("Active storage adapter does not support verifyToken()");
1255
+ }
1256
+ return inner.verifyToken(token, expectedOp);
1257
+ }
1258
+ };
1259
+
1137
1260
  // src/storage-service-plugin.ts
1138
1261
  var StorageServicePlugin = class {
1139
1262
  constructor(options = {}) {
@@ -1144,22 +1267,57 @@ var StorageServicePlugin = class {
1144
1267
  this.store = null;
1145
1268
  this.options = { adapter: "local", ...options };
1146
1269
  }
1270
+ /** Build a concrete adapter from a values map (settings-derived). */
1271
+ async buildAdapterFromValues(values) {
1272
+ const adapter = String(values.adapter ?? "local");
1273
+ if (adapter === "s3") {
1274
+ const bucket = values.s3_bucket;
1275
+ const region = values.s3_region;
1276
+ if (!bucket || !region) {
1277
+ throw new Error("StorageServicePlugin: S3 adapter requires s3_bucket and s3_region");
1278
+ }
1279
+ const opts = {
1280
+ bucket,
1281
+ region,
1282
+ endpoint: values.s3_endpoint || void 0,
1283
+ accessKeyId: values.s3_access_key_id || void 0,
1284
+ secretAccessKey: values.s3_secret_access_key || void 0,
1285
+ forcePathStyle: !!values.s3_force_path_style
1286
+ };
1287
+ return new S3StorageAdapter(opts);
1288
+ }
1289
+ const rootDir = values.local_root || "./storage";
1290
+ return new LocalStorageAdapter({
1291
+ basePath: this.options.basePath ?? "/api/v1/storage",
1292
+ ...this.options.local ?? {},
1293
+ // settings value wins over any constructor-provided local.rootDir
1294
+ rootDir
1295
+ });
1296
+ }
1147
1297
  async init(ctx) {
1148
1298
  const adapter = this.options.adapter;
1299
+ let initial;
1149
1300
  if (adapter === "s3") {
1150
- const { S3StorageAdapter: S3StorageAdapter2 } = await Promise.resolve().then(() => (init_s3_storage_adapter(), s3_storage_adapter_exports));
1301
+ const { S3StorageAdapter: S3Ctor } = await Promise.resolve().then(() => (init_s3_storage_adapter(), s3_storage_adapter_exports));
1151
1302
  const s3Opts = this.options.s3;
1152
1303
  if (!s3Opts) {
1153
1304
  throw new Error('StorageServicePlugin: s3 options are required when adapter is "s3"');
1154
1305
  }
1155
- this.storage = new S3StorageAdapter2(s3Opts);
1306
+ initial = new S3Ctor(s3Opts);
1156
1307
  } else {
1157
1308
  const rootDir = this.options.local?.rootDir ?? "./storage";
1158
1309
  const basePath = this.options.basePath ?? "/api/v1/storage";
1159
- this.storage = new LocalStorageAdapter({ rootDir, basePath, ...this.options.local });
1310
+ initial = new LocalStorageAdapter({ rootDir, basePath, ...this.options.local });
1160
1311
  }
1312
+ this.storage = new SwappableStorageService(initial, (prev, next) => {
1313
+ const prevName = prev?.constructor?.name ?? "unknown";
1314
+ const nextName = next?.constructor?.name ?? "unknown";
1315
+ ctx.logger.warn(
1316
+ `StorageServicePlugin: storage adapter swapped (${prevName} \u2192 ${nextName}). Existing files were NOT migrated and may be unreachable through the new adapter.`
1317
+ );
1318
+ });
1161
1319
  ctx.registerService("file-storage", this.storage);
1162
- ctx.logger.info(`StorageServicePlugin: registered ${adapter} storage adapter`);
1320
+ ctx.logger.info(`StorageServicePlugin: registered ${adapter} storage adapter (swappable)`);
1163
1321
  try {
1164
1322
  ctx.getService("manifest").register({
1165
1323
  id: "com.objectstack.service.storage",
@@ -1173,31 +1331,101 @@ var StorageServicePlugin = class {
1173
1331
  }
1174
1332
  }
1175
1333
  async start(ctx) {
1176
- if (this.options.registerRoutes === false) return;
1177
1334
  ctx.hook("kernel:ready", async () => {
1178
- let httpServer = null;
1179
- try {
1180
- httpServer = ctx.getService("http-server");
1181
- } catch {
1182
- }
1183
- if (!httpServer || !this.storage) {
1184
- ctx.logger.warn(
1185
- 'StorageServicePlugin: no HTTP server available \u2014 REST routes not registered. File storage is still accessible programmatically via kernel.getService("file-storage").'
1186
- );
1187
- return;
1335
+ if (this.options.registerRoutes !== false) {
1336
+ let httpServer = null;
1337
+ try {
1338
+ httpServer = ctx.getService("http-server");
1339
+ } catch {
1340
+ }
1341
+ if (httpServer && this.storage) {
1342
+ let engine = null;
1343
+ try {
1344
+ engine = ctx.getService("objectql");
1345
+ } catch {
1346
+ }
1347
+ this.store = new StorageMetadataStore(engine);
1348
+ registerStorageRoutes(httpServer, this.storage, this.store, {
1349
+ basePath: this.options.basePath ?? "/api/v1/storage",
1350
+ presignedTtl: this.options.presignedTtl,
1351
+ sessionTtl: this.options.sessionTtl
1352
+ });
1353
+ ctx.logger.info(
1354
+ "StorageServicePlugin: REST routes registered at " + (this.options.basePath ?? "/api/v1/storage")
1355
+ );
1356
+ } else if (!httpServer) {
1357
+ ctx.logger.warn(
1358
+ 'StorageServicePlugin: no HTTP server available \u2014 REST routes not registered. File storage is still accessible programmatically via kernel.getService("file-storage").'
1359
+ );
1360
+ }
1188
1361
  }
1189
- let engine = null;
1362
+ if (this.options.bindToSettings === false) return;
1190
1363
  try {
1191
- engine = ctx.getService("objectql");
1364
+ const settings = ctx.getService("settings");
1365
+ if (!settings || typeof settings.createClient !== "function") return;
1366
+ const applySettings = async () => {
1367
+ if (!this.storage) return;
1368
+ try {
1369
+ const payload = await settings.getNamespace("storage");
1370
+ const values = {};
1371
+ for (const [k, v] of Object.entries(payload.values)) {
1372
+ values[k] = v?.value;
1373
+ }
1374
+ const hasAny = Object.values(values).some((v) => v !== void 0 && v !== null && v !== "");
1375
+ if (!hasAny) return;
1376
+ const next = await this.buildAdapterFromValues(values);
1377
+ this.storage.swap(next);
1378
+ } catch (err) {
1379
+ ctx.logger.warn(
1380
+ "StorageServicePlugin: failed to apply storage settings: " + (err?.message ?? err)
1381
+ );
1382
+ }
1383
+ };
1384
+ await applySettings();
1385
+ if (typeof settings.subscribe === "function") {
1386
+ settings.subscribe("storage", () => {
1387
+ void applySettings();
1388
+ });
1389
+ ctx.logger.info("StorageServicePlugin: bound to settings:changed for namespace=storage");
1390
+ }
1391
+ if (typeof settings.registerAction === "function" && this.storage) {
1392
+ const proxy = this.storage;
1393
+ settings.registerAction("storage", "test", async ({ values }) => {
1394
+ const probeKey = `__objectstack_probe__/${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
1395
+ const probeBytes = Buffer.from(`probe@${(/* @__PURE__ */ new Date()).toISOString()}`, "utf-8");
1396
+ try {
1397
+ let target = proxy;
1398
+ if (values && Object.keys(values).length > 0) {
1399
+ try {
1400
+ target = await this.buildAdapterFromValues(values);
1401
+ } catch (err) {
1402
+ return { ok: false, severity: "error", message: err?.message ?? String(err) };
1403
+ }
1404
+ }
1405
+ await target.upload(probeKey, probeBytes, { contentType: "text/plain" });
1406
+ const got = await target.download(probeKey);
1407
+ if (!got || !Buffer.isBuffer(got) || got.toString("utf-8") !== probeBytes.toString("utf-8")) {
1408
+ return { ok: false, severity: "error", message: "Probe download did not match upload." };
1409
+ }
1410
+ await target.delete(probeKey);
1411
+ const adapter = String(values?.adapter ?? this.options.adapter ?? "local");
1412
+ return {
1413
+ ok: true,
1414
+ severity: "info",
1415
+ message: `Storage round-trip succeeded (adapter=${adapter}).`
1416
+ };
1417
+ } catch (err) {
1418
+ try {
1419
+ await proxy.delete(probeKey);
1420
+ } catch {
1421
+ }
1422
+ return { ok: false, severity: "error", message: err?.message ?? String(err) };
1423
+ }
1424
+ });
1425
+ ctx.logger.info("StorageServicePlugin: registered settings action storage/test");
1426
+ }
1192
1427
  } catch {
1193
1428
  }
1194
- this.store = new StorageMetadataStore(engine);
1195
- registerStorageRoutes(httpServer, this.storage, this.store, {
1196
- basePath: this.options.basePath ?? "/api/v1/storage",
1197
- presignedTtl: this.options.presignedTtl,
1198
- sessionTtl: this.options.sessionTtl
1199
- });
1200
- ctx.logger.info("StorageServicePlugin: REST routes registered at " + (this.options.basePath ?? "/api/v1/storage"));
1201
1429
  });
1202
1430
  }
1203
1431
  };
@@ -1210,6 +1438,7 @@ init_s3_storage_adapter();
1210
1438
  S3StorageAdapter,
1211
1439
  StorageMetadataStore,
1212
1440
  StorageServicePlugin,
1441
+ SwappableStorageService,
1213
1442
  SystemFile,
1214
1443
  SystemUploadSession,
1215
1444
  registerStorageRoutes