@objectstack/service-storage 4.0.4 → 4.0.5

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/dist/index.cjs CHANGED
@@ -1,8 +1,13 @@
1
1
  "use strict";
2
+ var __create = Object.create;
2
3
  var __defProp = Object.defineProperty;
3
4
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
5
  var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
5
7
  var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __esm = (fn, res) => function __init() {
9
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
10
+ };
6
11
  var __export = (target, all) => {
7
12
  for (var name in all)
8
13
  __defProp(target, name, { get: all[name], enumerable: true });
@@ -15,51 +20,348 @@ var __copyProps = (to, from, except, desc) => {
15
20
  }
16
21
  return to;
17
22
  };
23
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
24
+ // If the importer is in node compatibility mode or this is not an ESM
25
+ // file that has been converted to a CommonJS file using a Babel-
26
+ // compatible transform (i.e. "__esModule" has not been set), then set
27
+ // "default" to the CommonJS "module.exports" for node compatibility.
28
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
29
+ mod
30
+ ));
18
31
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
32
 
33
+ // src/s3-storage-adapter.ts
34
+ var s3_storage_adapter_exports = {};
35
+ __export(s3_storage_adapter_exports, {
36
+ S3StorageAdapter: () => S3StorageAdapter
37
+ });
38
+ async function streamToBuffer(stream) {
39
+ if (Buffer.isBuffer(stream)) return stream;
40
+ if (stream instanceof Uint8Array) return Buffer.from(stream);
41
+ const chunks = [];
42
+ if (typeof stream[Symbol.asyncIterator] === "function") {
43
+ for await (const chunk of stream) {
44
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
45
+ }
46
+ } else if (stream.getReader) {
47
+ const reader = stream.getReader();
48
+ let done = false;
49
+ while (!done) {
50
+ const result = await reader.read();
51
+ done = result.done;
52
+ if (result.value) chunks.push(result.value);
53
+ }
54
+ } else {
55
+ throw new Error("Cannot convert stream to buffer");
56
+ }
57
+ return Buffer.concat(chunks);
58
+ }
59
+ var S3StorageAdapter;
60
+ var init_s3_storage_adapter = __esm({
61
+ "src/s3-storage-adapter.ts"() {
62
+ "use strict";
63
+ S3StorageAdapter = class {
64
+ constructor(options) {
65
+ this.options = options;
66
+ this.clientPromise = null;
67
+ // ---------------------------------------------------------------------------
68
+ // Internal upload key tracking
69
+ // ---------------------------------------------------------------------------
70
+ this._uploadKeys = /* @__PURE__ */ new Map();
71
+ this.bucket = options.bucket;
72
+ this.region = options.region;
73
+ this.endpoint = options.endpoint;
74
+ this.forcePathStyle = options.forcePathStyle ?? false;
75
+ }
76
+ /**
77
+ * Lazily resolve the AWS S3 client to avoid crashing at import time when
78
+ * `@aws-sdk/client-s3` isn't installed.
79
+ */
80
+ async getClient() {
81
+ if (!this.clientPromise) {
82
+ this.clientPromise = (async () => {
83
+ let s3Mod;
84
+ try {
85
+ s3Mod = await import("@aws-sdk/client-s3");
86
+ } catch {
87
+ throw new Error(
88
+ "S3StorageAdapter requires @aws-sdk/client-s3. Install it with: pnpm add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner"
89
+ );
90
+ }
91
+ const { S3Client } = s3Mod;
92
+ const clientOpts = { region: this.region };
93
+ if (this.endpoint) clientOpts.endpoint = this.endpoint;
94
+ if (this.forcePathStyle) clientOpts.forcePathStyle = true;
95
+ if (this.options.accessKeyId && this.options.secretAccessKey) {
96
+ clientOpts.credentials = {
97
+ accessKeyId: this.options.accessKeyId,
98
+ secretAccessKey: this.options.secretAccessKey
99
+ };
100
+ }
101
+ return new S3Client(clientOpts);
102
+ })();
103
+ }
104
+ return this.clientPromise;
105
+ }
106
+ async s3Mod() {
107
+ try {
108
+ return await import("@aws-sdk/client-s3");
109
+ } catch {
110
+ throw new Error("S3StorageAdapter requires @aws-sdk/client-s3");
111
+ }
112
+ }
113
+ async presignerMod() {
114
+ try {
115
+ return await import("@aws-sdk/s3-request-presigner");
116
+ } catch {
117
+ throw new Error("S3StorageAdapter requires @aws-sdk/s3-request-presigner");
118
+ }
119
+ }
120
+ // ---------------------------------------------------------------------------
121
+ // Basic operations
122
+ // ---------------------------------------------------------------------------
123
+ async upload(key, data, options) {
124
+ const client = await this.getClient();
125
+ const s3 = await this.s3Mod();
126
+ const body = data instanceof Buffer ? data : await streamToBuffer(data);
127
+ const cmd = new s3.PutObjectCommand({
128
+ Bucket: this.bucket,
129
+ Key: key,
130
+ Body: body,
131
+ ContentType: options?.contentType,
132
+ Metadata: options?.metadata,
133
+ ACL: options?.acl === "public-read" ? "public-read" : void 0
134
+ });
135
+ await client.send(cmd);
136
+ }
137
+ async download(key) {
138
+ const client = await this.getClient();
139
+ const s3 = await this.s3Mod();
140
+ const cmd = new s3.GetObjectCommand({ Bucket: this.bucket, Key: key });
141
+ const res = await client.send(cmd);
142
+ return streamToBuffer(res.Body);
143
+ }
144
+ async delete(key) {
145
+ const client = await this.getClient();
146
+ const s3 = await this.s3Mod();
147
+ const cmd = new s3.DeleteObjectCommand({ Bucket: this.bucket, Key: key });
148
+ await client.send(cmd);
149
+ }
150
+ async exists(key) {
151
+ const client = await this.getClient();
152
+ const s3 = await this.s3Mod();
153
+ try {
154
+ const cmd = new s3.HeadObjectCommand({ Bucket: this.bucket, Key: key });
155
+ await client.send(cmd);
156
+ return true;
157
+ } catch (err) {
158
+ if (err.name === "NotFound" || err.$metadata?.httpStatusCode === 404) return false;
159
+ throw err;
160
+ }
161
+ }
162
+ async getInfo(key) {
163
+ const client = await this.getClient();
164
+ const s3 = await this.s3Mod();
165
+ const cmd = new s3.HeadObjectCommand({ Bucket: this.bucket, Key: key });
166
+ const res = await client.send(cmd);
167
+ return {
168
+ key,
169
+ size: res.ContentLength ?? 0,
170
+ contentType: res.ContentType,
171
+ lastModified: res.LastModified ?? /* @__PURE__ */ new Date(),
172
+ metadata: res.Metadata
173
+ };
174
+ }
175
+ async list(prefix) {
176
+ const client = await this.getClient();
177
+ const s3 = await this.s3Mod();
178
+ const cmd = new s3.ListObjectsV2Command({ Bucket: this.bucket, Prefix: prefix });
179
+ const res = await client.send(cmd);
180
+ return (res.Contents ?? []).map((item) => ({
181
+ key: item.Key,
182
+ size: item.Size ?? 0,
183
+ lastModified: item.LastModified ?? /* @__PURE__ */ new Date()
184
+ }));
185
+ }
186
+ // ---------------------------------------------------------------------------
187
+ // Presigned URLs
188
+ // ---------------------------------------------------------------------------
189
+ async getSignedUrl(key, expiresIn) {
190
+ const desc = await this.getPresignedDownload(key, expiresIn);
191
+ return desc.downloadUrl;
192
+ }
193
+ async getPresignedUpload(key, expiresIn, options) {
194
+ const client = await this.getClient();
195
+ const s3 = await this.s3Mod();
196
+ const { getSignedUrl } = await this.presignerMod();
197
+ const cmd = new s3.PutObjectCommand({
198
+ Bucket: this.bucket,
199
+ Key: key,
200
+ ContentType: options?.contentType,
201
+ Metadata: options?.metadata,
202
+ ACL: options?.acl === "public-read" ? "public-read" : void 0
203
+ });
204
+ const url = await getSignedUrl(client, cmd, { expiresIn });
205
+ return {
206
+ uploadUrl: url,
207
+ method: "PUT",
208
+ headers: options?.contentType ? { "content-type": options.contentType } : void 0,
209
+ expiresIn
210
+ };
211
+ }
212
+ async getPresignedDownload(key, expiresIn) {
213
+ const client = await this.getClient();
214
+ const s3 = await this.s3Mod();
215
+ const { getSignedUrl } = await this.presignerMod();
216
+ const cmd = new s3.GetObjectCommand({ Bucket: this.bucket, Key: key });
217
+ const url = await getSignedUrl(client, cmd, { expiresIn });
218
+ return { downloadUrl: url, expiresIn };
219
+ }
220
+ // ---------------------------------------------------------------------------
221
+ // Chunked / multipart upload
222
+ // ---------------------------------------------------------------------------
223
+ async initiateChunkedUpload(key, options) {
224
+ const client = await this.getClient();
225
+ const s3 = await this.s3Mod();
226
+ const cmd = new s3.CreateMultipartUploadCommand({
227
+ Bucket: this.bucket,
228
+ Key: key,
229
+ ContentType: options?.contentType,
230
+ Metadata: options?.metadata
231
+ });
232
+ const res = await client.send(cmd);
233
+ return res.UploadId;
234
+ }
235
+ async uploadChunk(uploadId, partNumber, data) {
236
+ const client = await this.getClient();
237
+ const s3 = await this.s3Mod();
238
+ const key = this._uploadKeys?.get(uploadId);
239
+ if (!key) {
240
+ throw new Error("S3StorageAdapter: key not found for uploadId. Call setUploadKey() before uploadChunk().");
241
+ }
242
+ const cmd = new s3.UploadPartCommand({
243
+ Bucket: this.bucket,
244
+ Key: key,
245
+ UploadId: uploadId,
246
+ PartNumber: partNumber,
247
+ Body: data
248
+ });
249
+ const res = await client.send(cmd);
250
+ return res.ETag;
251
+ }
252
+ async completeChunkedUpload(uploadId, parts) {
253
+ const client = await this.getClient();
254
+ const s3 = await this.s3Mod();
255
+ const key = this._uploadKeys?.get(uploadId);
256
+ if (!key) {
257
+ throw new Error("S3StorageAdapter: key not found for uploadId.");
258
+ }
259
+ const cmd = new s3.CompleteMultipartUploadCommand({
260
+ Bucket: this.bucket,
261
+ Key: key,
262
+ UploadId: uploadId,
263
+ MultipartUpload: {
264
+ Parts: parts.map((p) => ({ PartNumber: p.partNumber, ETag: p.eTag }))
265
+ }
266
+ });
267
+ await client.send(cmd);
268
+ this._uploadKeys?.delete(uploadId);
269
+ return key;
270
+ }
271
+ async abortChunkedUpload(uploadId) {
272
+ const client = await this.getClient();
273
+ const s3 = await this.s3Mod();
274
+ const key = this._uploadKeys?.get(uploadId);
275
+ if (!key) return;
276
+ const cmd = new s3.AbortMultipartUploadCommand({
277
+ Bucket: this.bucket,
278
+ Key: key,
279
+ UploadId: uploadId
280
+ });
281
+ await client.send(cmd);
282
+ this._uploadKeys?.delete(uploadId);
283
+ }
284
+ /**
285
+ * Register the storage key for a multipart upload session. Must be called
286
+ * by the StorageServicePlugin after `initiateChunkedUpload()` returns so
287
+ * that subsequent `uploadChunk` / `completeChunkedUpload` calls can resolve
288
+ * the S3 key without it being part of the IStorageService contract signature.
289
+ */
290
+ setUploadKey(uploadId, key) {
291
+ this._uploadKeys.set(uploadId, key);
292
+ }
293
+ };
294
+ }
295
+ });
296
+
20
297
  // src/index.ts
21
298
  var index_exports = {};
22
299
  __export(index_exports, {
23
300
  LocalStorageAdapter: () => LocalStorageAdapter,
24
301
  S3StorageAdapter: () => S3StorageAdapter,
25
- StorageServicePlugin: () => StorageServicePlugin
302
+ StorageMetadataStore: () => StorageMetadataStore,
303
+ StorageServicePlugin: () => StorageServicePlugin,
304
+ SystemFile: () => SystemFile,
305
+ SystemUploadSession: () => SystemUploadSession,
306
+ registerStorageRoutes: () => registerStorageRoutes
26
307
  });
27
308
  module.exports = __toCommonJS(index_exports);
28
309
 
29
310
  // src/local-storage-adapter.ts
30
311
  var import_node_fs = require("fs");
31
312
  var import_node_path = require("path");
313
+ var import_node_crypto = require("crypto");
32
314
  var LocalStorageAdapter = class {
33
315
  constructor(options) {
34
316
  this.rootDir = options.rootDir;
317
+ this.partsDir = (0, import_node_path.join)(this.rootDir, ".parts");
318
+ this.baseUrl = options.baseUrl ?? "";
319
+ this.basePath = options.basePath ?? "/api/v1/storage";
320
+ this.signingSecret = options.signingSecret ?? (0, import_node_crypto.randomUUID)();
35
321
  }
322
+ // ---------------------------------------------------------------------------
323
+ // Path helpers
324
+ // ---------------------------------------------------------------------------
36
325
  resolvePath(key) {
326
+ if (key.includes("..")) {
327
+ throw new Error(`LocalStorageAdapter: path traversal not allowed (key="${key}")`);
328
+ }
37
329
  return (0, import_node_path.join)(this.rootDir, key);
38
330
  }
331
+ resolvePartPath(uploadId, partNumber) {
332
+ if (!/^[A-Za-z0-9_-]+$/.test(uploadId)) {
333
+ throw new Error(`LocalStorageAdapter: invalid uploadId "${uploadId}"`);
334
+ }
335
+ return (0, import_node_path.join)(this.partsDir, uploadId, String(partNumber).padStart(8, "0"));
336
+ }
337
+ // ---------------------------------------------------------------------------
338
+ // Basic file operations
339
+ // ---------------------------------------------------------------------------
39
340
  async upload(key, data, _options) {
40
341
  const filePath = this.resolvePath(key);
41
342
  await import_node_fs.promises.mkdir((0, import_node_path.dirname)(filePath), { recursive: true });
42
343
  if (data instanceof Buffer) {
43
344
  await import_node_fs.promises.writeFile(filePath, data);
44
- } else {
45
- const chunks = [];
46
- const reader = data.getReader();
47
- let done = false;
48
- while (!done) {
49
- const result = await reader.read();
50
- done = result.done;
51
- if (result.value) chunks.push(result.value);
52
- }
53
- await import_node_fs.promises.writeFile(filePath, Buffer.concat(chunks));
345
+ return;
54
346
  }
347
+ const chunks = [];
348
+ const reader = data.getReader();
349
+ let done = false;
350
+ while (!done) {
351
+ const result = await reader.read();
352
+ done = result.done;
353
+ if (result.value) chunks.push(result.value);
354
+ }
355
+ await import_node_fs.promises.writeFile(filePath, Buffer.concat(chunks));
55
356
  }
56
357
  async download(key) {
57
- const filePath = this.resolvePath(key);
58
- return import_node_fs.promises.readFile(filePath);
358
+ return import_node_fs.promises.readFile(this.resolvePath(key));
59
359
  }
60
360
  async delete(key) {
61
- const filePath = this.resolvePath(key);
62
- await import_node_fs.promises.unlink(filePath);
361
+ await import_node_fs.promises.unlink(this.resolvePath(key)).catch((err) => {
362
+ if (err && err.code === "ENOENT") return;
363
+ throw err;
364
+ });
63
365
  }
64
366
  async exists(key) {
65
367
  try {
@@ -72,11 +374,7 @@ var LocalStorageAdapter = class {
72
374
  async getInfo(key) {
73
375
  const filePath = this.resolvePath(key);
74
376
  const stat = await import_node_fs.promises.stat(filePath);
75
- return {
76
- key,
77
- size: stat.size,
78
- lastModified: stat.mtime
79
- };
377
+ return { key, size: stat.size, lastModified: stat.mtime };
80
378
  }
81
379
  async list(prefix) {
82
380
  const dirPath = this.resolvePath(prefix);
@@ -84,10 +382,10 @@ var LocalStorageAdapter = class {
84
382
  const entries = await import_node_fs.promises.readdir(dirPath);
85
383
  const results = [];
86
384
  for (const entry of entries) {
385
+ if (entry.startsWith(".")) continue;
87
386
  const fullKey = prefix ? `${prefix}/${entry}` : entry;
88
387
  try {
89
- const info = await this.getInfo(fullKey);
90
- results.push(info);
388
+ results.push(await this.getInfo(fullKey));
91
389
  } catch {
92
390
  }
93
391
  }
@@ -96,74 +394,824 @@ var LocalStorageAdapter = class {
96
394
  return [];
97
395
  }
98
396
  }
99
- };
100
-
101
- // src/storage-service-plugin.ts
102
- var StorageServicePlugin = class {
103
- constructor(options = {}) {
104
- this.name = "com.objectstack.service.storage";
105
- this.version = "1.0.0";
106
- this.type = "standard";
107
- this.options = { adapter: "local", ...options };
397
+ // ---------------------------------------------------------------------------
398
+ // Presigned URL helpers
399
+ // ---------------------------------------------------------------------------
400
+ /**
401
+ * Sign an opaque token for the given payload.
402
+ * Format: base64url(JSON.stringify(payload)) + '.' + base64url(HMAC)
403
+ */
404
+ signToken(payload) {
405
+ const b64 = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
406
+ const sig = (0, import_node_crypto.createHmac)("sha256", this.signingSecret).update(b64).digest("base64url");
407
+ return `${b64}.${sig}`;
108
408
  }
109
- async init(ctx) {
110
- const adapter = this.options.adapter;
111
- if (adapter === "s3") {
112
- throw new Error(
113
- 'S3 storage adapter is not yet implemented. Use adapter: "local" or provide a custom IStorageService via ctx.registerService("file-storage", impl).'
114
- );
409
+ /**
410
+ * Verify and decode a presigned token. Throws on invalid signature or
411
+ * expiration.
412
+ */
413
+ verifyToken(token, expectedOp) {
414
+ const [b64, sig] = token.split(".");
415
+ if (!b64 || !sig) throw new Error("Invalid storage token format");
416
+ const expected = (0, import_node_crypto.createHmac)("sha256", this.signingSecret).update(b64).digest("base64url");
417
+ if (expected !== sig) throw new Error("Invalid storage token signature");
418
+ let payload;
419
+ try {
420
+ payload = JSON.parse(Buffer.from(b64, "base64url").toString("utf8"));
421
+ } catch {
422
+ throw new Error("Malformed storage token payload");
115
423
  }
116
- const rootDir = this.options.local?.rootDir ?? "./storage";
117
- const storage = new LocalStorageAdapter({ rootDir });
118
- ctx.registerService("file-storage", storage);
119
- ctx.logger.info(`StorageServicePlugin: registered local storage adapter (root: ${rootDir})`);
424
+ if (payload.op !== expectedOp) {
425
+ throw new Error(`Storage token op mismatch (expected="${expectedOp}", actual="${payload.op}")`);
426
+ }
427
+ if (Date.now() / 1e3 > payload.exp) {
428
+ throw new Error("Storage token expired");
429
+ }
430
+ return payload;
431
+ }
432
+ async getPresignedUpload(key, expiresIn, options) {
433
+ const exp = Math.floor(Date.now() / 1e3) + Math.max(1, expiresIn);
434
+ const token = this.signToken({ k: key, ct: options?.contentType, exp, op: "put" });
435
+ return {
436
+ uploadUrl: `${this.baseUrl}${this.basePath}/_local/raw/${token}`,
437
+ method: "PUT",
438
+ headers: options?.contentType ? { "content-type": options.contentType } : { "content-type": "application/octet-stream" },
439
+ expiresIn,
440
+ downloadUrl: `${this.baseUrl}${this.basePath}/_local/file/${encodeURIComponent(key)}`
441
+ };
442
+ }
443
+ async getPresignedDownload(key, expiresIn) {
444
+ const exp = Math.floor(Date.now() / 1e3) + Math.max(1, expiresIn);
445
+ const token = this.signToken({ k: key, exp, op: "get" });
446
+ return {
447
+ downloadUrl: `${this.baseUrl}${this.basePath}/_local/raw/${token}`,
448
+ expiresIn
449
+ };
450
+ }
451
+ async getSignedUrl(key, expiresIn) {
452
+ const desc = await this.getPresignedDownload(key, expiresIn);
453
+ return desc.downloadUrl;
454
+ }
455
+ // ---------------------------------------------------------------------------
456
+ // Chunked / multipart upload
457
+ // ---------------------------------------------------------------------------
458
+ async initiateChunkedUpload(key, options) {
459
+ const uploadId = (0, import_node_crypto.randomUUID)().replace(/-/g, "");
460
+ const dir = (0, import_node_path.join)(this.partsDir, uploadId);
461
+ await import_node_fs.promises.mkdir(dir, { recursive: true });
462
+ const meta = {
463
+ key,
464
+ contentType: options?.contentType,
465
+ metadata: options?.metadata,
466
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
467
+ };
468
+ await import_node_fs.promises.writeFile((0, import_node_path.join)(dir, "_meta.json"), JSON.stringify(meta), "utf8");
469
+ return uploadId;
470
+ }
471
+ async uploadChunk(uploadId, partNumber, data) {
472
+ if (!Number.isInteger(partNumber) || partNumber < 1) {
473
+ throw new Error(`uploadChunk: partNumber must be a positive integer (got ${partNumber})`);
474
+ }
475
+ const partPath = this.resolvePartPath(uploadId, partNumber);
476
+ await import_node_fs.promises.mkdir((0, import_node_path.dirname)(partPath), { recursive: true });
477
+ await import_node_fs.promises.writeFile(partPath, data);
478
+ const { createHash } = await import("crypto");
479
+ return createHash("md5").update(data).digest("hex");
480
+ }
481
+ async completeChunkedUpload(uploadId, parts) {
482
+ const dir = (0, import_node_path.join)(this.partsDir, uploadId);
483
+ let meta = {};
484
+ try {
485
+ meta = JSON.parse(await import_node_fs.promises.readFile((0, import_node_path.join)(dir, "_meta.json"), "utf8"));
486
+ } catch {
487
+ throw new Error(`Upload session "${uploadId}" not found`);
488
+ }
489
+ const targetKey = meta.key;
490
+ if (!targetKey) {
491
+ throw new Error(`Upload session "${uploadId}" missing target key`);
492
+ }
493
+ const sortedParts = [...parts].sort((a, b) => a.partNumber - b.partNumber);
494
+ const finalPath = this.resolvePath(targetKey);
495
+ await import_node_fs.promises.mkdir((0, import_node_path.dirname)(finalPath), { recursive: true });
496
+ const out = (0, import_node_fs.createWriteStream)(finalPath);
497
+ try {
498
+ for (const p of sortedParts) {
499
+ const partPath = this.resolvePartPath(uploadId, p.partNumber);
500
+ await new Promise((resolve, reject) => {
501
+ const inp = (0, import_node_fs.createReadStream)(partPath);
502
+ inp.on("error", reject);
503
+ inp.on("end", () => resolve());
504
+ inp.pipe(out, { end: false });
505
+ });
506
+ }
507
+ } finally {
508
+ await new Promise((resolve) => out.end(() => resolve()));
509
+ }
510
+ await import_node_fs.promises.rm(dir, { recursive: true, force: true });
511
+ return targetKey;
512
+ }
513
+ async abortChunkedUpload(uploadId) {
514
+ await import_node_fs.promises.rm((0, import_node_path.join)(this.partsDir, uploadId), { recursive: true, force: true });
120
515
  }
121
516
  };
122
517
 
123
- // src/s3-storage-adapter.ts
124
- var S3StorageAdapter = class {
125
- constructor(options) {
126
- this.bucket = options.bucket;
127
- this.region = options.region;
518
+ // src/metadata-store.ts
519
+ var StorageMetadataStore = class {
520
+ constructor(engine) {
521
+ this.engine = engine;
522
+ this.files = /* @__PURE__ */ new Map();
523
+ this.sessions = /* @__PURE__ */ new Map();
128
524
  }
129
- async upload(_key, _data, _options) {
130
- throw new Error(`S3StorageAdapter not yet implemented (bucket: ${this.bucket}, region: ${this.region})`);
525
+ // ---------------------------------------------------------------------------
526
+ // Files
527
+ // ---------------------------------------------------------------------------
528
+ async createFile(rec) {
529
+ const now = (/* @__PURE__ */ new Date()).toISOString();
530
+ const full = { created_at: now, updated_at: now, ...rec };
531
+ this.files.set(full.id, full);
532
+ if (this.engine) {
533
+ try {
534
+ await this.engine.insert("system_file", full);
535
+ } catch {
536
+ }
537
+ }
538
+ return full;
131
539
  }
132
- async download(_key) {
133
- throw new Error("S3StorageAdapter not yet implemented");
540
+ async getFile(id) {
541
+ if (this.engine) {
542
+ try {
543
+ const found = await this.engine.findOne("system_file", { where: { id } });
544
+ if (found) return found;
545
+ } catch {
546
+ }
547
+ }
548
+ return this.files.get(id) ?? null;
134
549
  }
135
- async delete(_key) {
136
- throw new Error("S3StorageAdapter not yet implemented");
550
+ async updateFile(id, patch) {
551
+ const existing = await this.getFile(id);
552
+ if (!existing) return null;
553
+ const merged = { ...existing, ...patch, id, updated_at: (/* @__PURE__ */ new Date()).toISOString() };
554
+ this.files.set(id, merged);
555
+ if (this.engine) {
556
+ try {
557
+ await this.engine.update("system_file", merged, { where: { id } });
558
+ } catch {
559
+ }
560
+ }
561
+ return merged;
137
562
  }
138
- async exists(_key) {
139
- throw new Error("S3StorageAdapter not yet implemented");
563
+ async deleteFile(id) {
564
+ this.files.delete(id);
565
+ if (this.engine) {
566
+ try {
567
+ await this.engine.delete("system_file", { where: { id } });
568
+ } catch {
569
+ }
570
+ }
140
571
  }
141
- async getInfo(_key) {
142
- throw new Error("S3StorageAdapter not yet implemented");
572
+ // ---------------------------------------------------------------------------
573
+ // Upload sessions
574
+ // ---------------------------------------------------------------------------
575
+ async createSession(rec) {
576
+ const now = (/* @__PURE__ */ new Date()).toISOString();
577
+ const full = {
578
+ uploaded_chunks: 0,
579
+ uploaded_size: 0,
580
+ parts: "[]",
581
+ started_at: now,
582
+ updated_at: now,
583
+ ...rec
584
+ };
585
+ this.sessions.set(full.id, full);
586
+ if (this.engine) {
587
+ try {
588
+ await this.engine.insert("system_upload_session", full);
589
+ } catch {
590
+ }
591
+ }
592
+ return full;
143
593
  }
144
- async list(_prefix) {
145
- throw new Error("S3StorageAdapter not yet implemented");
594
+ async getSession(id) {
595
+ if (this.engine) {
596
+ try {
597
+ const found = await this.engine.findOne("system_upload_session", { where: { id } });
598
+ if (found) return found;
599
+ } catch {
600
+ }
601
+ }
602
+ return this.sessions.get(id) ?? null;
146
603
  }
147
- async getSignedUrl(_key, _expiresIn) {
148
- throw new Error("S3StorageAdapter not yet implemented");
604
+ async updateSession(id, patch) {
605
+ const existing = await this.getSession(id);
606
+ if (!existing) return null;
607
+ const merged = {
608
+ ...existing,
609
+ ...patch,
610
+ id,
611
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
612
+ };
613
+ this.sessions.set(id, merged);
614
+ if (this.engine) {
615
+ try {
616
+ await this.engine.update("system_upload_session", merged, { where: { id } });
617
+ } catch {
618
+ }
619
+ }
620
+ return merged;
621
+ }
622
+ async deleteSession(id) {
623
+ this.sessions.delete(id);
624
+ if (this.engine) {
625
+ try {
626
+ await this.engine.delete("system_upload_session", { where: { id } });
627
+ } catch {
628
+ }
629
+ }
630
+ }
631
+ };
632
+
633
+ // src/storage-routes.ts
634
+ var import_node_crypto2 = require("crypto");
635
+ function registerStorageRoutes(httpServer, storage, store, opts = {}) {
636
+ const basePath = opts.basePath ?? "/api/v1/storage";
637
+ const presignedTtl = opts.presignedTtl ?? 3600;
638
+ const sessionTtl = opts.sessionTtl ?? 86400;
639
+ httpServer.post(`${basePath}/upload/presigned`, async (req, res) => {
640
+ try {
641
+ const { filename, mimeType, size, scope, bucket } = req.body ?? {};
642
+ if (!filename || !mimeType || size == null) {
643
+ res.status(400).json({ error: "filename, mimeType, and size are required" });
644
+ return;
645
+ }
646
+ const fileId = (0, import_node_crypto2.randomUUID)();
647
+ const key = buildKey(scope ?? "user", fileId, filename);
648
+ await store.createFile({
649
+ id: fileId,
650
+ key,
651
+ name: filename,
652
+ mime_type: mimeType,
653
+ size,
654
+ scope: scope ?? "user",
655
+ bucket,
656
+ acl: "private",
657
+ status: "pending"
658
+ });
659
+ let uploadUrl;
660
+ let method = "PUT";
661
+ let headers = { "content-type": mimeType };
662
+ let expiresIn = presignedTtl;
663
+ if (storage.getPresignedUpload) {
664
+ const desc = await storage.getPresignedUpload(key, presignedTtl, { contentType: mimeType });
665
+ uploadUrl = desc.uploadUrl;
666
+ method = desc.method;
667
+ if (desc.headers) headers = desc.headers;
668
+ expiresIn = desc.expiresIn;
669
+ } else {
670
+ uploadUrl = `${basePath}/_local/raw/${fileId}`;
671
+ }
672
+ res.json({
673
+ data: {
674
+ uploadUrl,
675
+ method,
676
+ headers,
677
+ fileId,
678
+ expiresIn,
679
+ downloadUrl: `${basePath}/files/${fileId}/url`
680
+ }
681
+ });
682
+ } catch (err) {
683
+ res.status(500).json({ error: err.message ?? "Internal error" });
684
+ }
685
+ });
686
+ httpServer.post(`${basePath}/upload/complete`, async (req, res) => {
687
+ try {
688
+ const { fileId, eTag } = req.body ?? {};
689
+ if (!fileId) {
690
+ res.status(400).json({ error: "fileId is required" });
691
+ return;
692
+ }
693
+ const file = await store.getFile(fileId);
694
+ if (!file) {
695
+ res.status(404).json({ error: "File not found" });
696
+ return;
697
+ }
698
+ const updated = await store.updateFile(fileId, {
699
+ status: "committed",
700
+ etag: eTag ?? void 0
701
+ });
702
+ res.json({
703
+ data: {
704
+ path: updated.key,
705
+ name: updated.name,
706
+ size: updated.size ?? 0,
707
+ mimeType: updated.mime_type ?? "application/octet-stream",
708
+ lastModified: updated.updated_at ?? (/* @__PURE__ */ new Date()).toISOString(),
709
+ created: updated.created_at ?? (/* @__PURE__ */ new Date()).toISOString(),
710
+ etag: updated.etag
711
+ }
712
+ });
713
+ } catch (err) {
714
+ res.status(500).json({ error: err.message ?? "Internal error" });
715
+ }
716
+ });
717
+ httpServer.post(`${basePath}/upload/chunked`, async (req, res) => {
718
+ try {
719
+ const { filename, mimeType, totalSize, chunkSize: reqChunkSize, scope, bucket, metadata } = req.body ?? {};
720
+ if (!filename || !mimeType || !totalSize) {
721
+ res.status(400).json({ error: "filename, mimeType, and totalSize are required" });
722
+ return;
723
+ }
724
+ const chunkSize = Math.max(reqChunkSize ?? 5242880, 5242880);
725
+ const totalChunks = Math.ceil(totalSize / chunkSize);
726
+ const fileId = (0, import_node_crypto2.randomUUID)();
727
+ const key = buildKey(scope ?? "user", fileId, filename);
728
+ await store.createFile({
729
+ id: fileId,
730
+ key,
731
+ name: filename,
732
+ mime_type: mimeType,
733
+ size: totalSize,
734
+ scope: scope ?? "user",
735
+ bucket,
736
+ acl: "private",
737
+ status: "pending",
738
+ metadata: metadata ? JSON.stringify(metadata) : void 0
739
+ });
740
+ let backendUploadId;
741
+ if (storage.initiateChunkedUpload) {
742
+ backendUploadId = await storage.initiateChunkedUpload(key, { contentType: mimeType, metadata });
743
+ if ("setUploadKey" in storage && typeof storage.setUploadKey === "function") {
744
+ storage.setUploadKey(backendUploadId, key);
745
+ }
746
+ }
747
+ const uploadId = backendUploadId ?? (0, import_node_crypto2.randomUUID)().replace(/-/g, "");
748
+ const resumeToken = (0, import_node_crypto2.randomUUID)();
749
+ const expiresAt = new Date(Date.now() + sessionTtl * 1e3).toISOString();
750
+ await store.createSession({
751
+ id: uploadId,
752
+ file_id: fileId,
753
+ key,
754
+ filename,
755
+ mime_type: mimeType,
756
+ total_size: totalSize,
757
+ chunk_size: chunkSize,
758
+ total_chunks: totalChunks,
759
+ resume_token: resumeToken,
760
+ backend_upload_id: backendUploadId,
761
+ scope: scope ?? "user",
762
+ bucket,
763
+ metadata: metadata ? JSON.stringify(metadata) : void 0,
764
+ status: "in_progress",
765
+ expires_at: expiresAt
766
+ });
767
+ res.json({
768
+ data: {
769
+ uploadId,
770
+ resumeToken,
771
+ fileId,
772
+ totalChunks,
773
+ chunkSize,
774
+ expiresAt
775
+ }
776
+ });
777
+ } catch (err) {
778
+ res.status(500).json({ error: err.message ?? "Internal error" });
779
+ }
780
+ });
781
+ httpServer.put(`${basePath}/upload/chunked/:uploadId/chunk/:chunkIndex`, async (req, res) => {
782
+ try {
783
+ const { uploadId, chunkIndex: chunkIndexStr } = req.params;
784
+ const chunkIndex = parseInt(chunkIndexStr, 10);
785
+ if (!uploadId || isNaN(chunkIndex)) {
786
+ res.status(400).json({ error: "uploadId and chunkIndex are required" });
787
+ return;
788
+ }
789
+ const session = await store.getSession(uploadId);
790
+ if (!session) {
791
+ res.status(404).json({ error: "Upload session not found" });
792
+ return;
793
+ }
794
+ const token = req.headers["x-resume-token"] ?? "";
795
+ if (session.resume_token && token !== session.resume_token) {
796
+ res.status(403).json({ error: "Invalid resume token" });
797
+ return;
798
+ }
799
+ let data;
800
+ if (req.rawBody) {
801
+ data = await req.rawBody();
802
+ } else if (Buffer.isBuffer(req.body)) {
803
+ data = req.body;
804
+ } else if (req.body instanceof ArrayBuffer) {
805
+ data = Buffer.from(req.body);
806
+ } else {
807
+ res.status(400).json({ error: "Binary body required" });
808
+ return;
809
+ }
810
+ let eTag = "";
811
+ if (storage.uploadChunk) {
812
+ eTag = await storage.uploadChunk(uploadId, chunkIndex + 1, data);
813
+ }
814
+ const currentParts = JSON.parse(session.parts ?? "[]");
815
+ currentParts.push({ chunkIndex, eTag });
816
+ const uploadedChunks = (session.uploaded_chunks ?? 0) + 1;
817
+ const uploadedSize = (session.uploaded_size ?? 0) + data.byteLength;
818
+ await store.updateSession(uploadId, {
819
+ uploaded_chunks: uploadedChunks,
820
+ uploaded_size: uploadedSize,
821
+ parts: JSON.stringify(currentParts)
822
+ });
823
+ res.json({
824
+ data: {
825
+ chunkIndex,
826
+ eTag,
827
+ bytesReceived: data.byteLength
828
+ }
829
+ });
830
+ } catch (err) {
831
+ res.status(500).json({ error: err.message ?? "Internal error" });
832
+ }
833
+ });
834
+ httpServer.post(`${basePath}/upload/chunked/:uploadId/complete`, async (req, res) => {
835
+ try {
836
+ const { uploadId } = req.params;
837
+ const session = await store.getSession(uploadId);
838
+ if (!session) {
839
+ res.status(404).json({ error: "Upload session not found" });
840
+ return;
841
+ }
842
+ await store.updateSession(uploadId, { status: "completing" });
843
+ const partsFromBody = req.body?.parts ?? [];
844
+ const partsForBackend = partsFromBody.map((p) => ({
845
+ partNumber: p.chunkIndex + 1,
846
+ eTag: p.eTag
847
+ }));
848
+ let finalKey = session.key;
849
+ if (storage.completeChunkedUpload) {
850
+ finalKey = await storage.completeChunkedUpload(uploadId, partsForBackend);
851
+ }
852
+ await store.updateFile(session.file_id, { status: "committed", key: finalKey });
853
+ await store.updateSession(uploadId, { status: "completed" });
854
+ res.json({
855
+ data: {
856
+ fileId: session.file_id,
857
+ key: finalKey,
858
+ size: session.total_size,
859
+ mimeType: session.mime_type ?? "application/octet-stream",
860
+ url: `${basePath}/files/${session.file_id}/url`
861
+ }
862
+ });
863
+ } catch (err) {
864
+ res.status(500).json({ error: err.message ?? "Internal error" });
865
+ }
866
+ });
867
+ httpServer.get(`${basePath}/upload/chunked/:uploadId/progress`, async (req, res) => {
868
+ try {
869
+ const { uploadId } = req.params;
870
+ const session = await store.getSession(uploadId);
871
+ if (!session) {
872
+ res.status(404).json({ error: "Upload session not found" });
873
+ return;
874
+ }
875
+ const uploadedChunks = session.uploaded_chunks ?? 0;
876
+ const uploadedSize = session.uploaded_size ?? 0;
877
+ const percentComplete = session.total_size > 0 ? Math.min(100, Math.round(uploadedSize / session.total_size * 100)) : 0;
878
+ res.json({
879
+ data: {
880
+ uploadId: session.id,
881
+ fileId: session.file_id,
882
+ filename: session.filename,
883
+ totalSize: session.total_size,
884
+ uploadedSize,
885
+ totalChunks: session.total_chunks,
886
+ uploadedChunks,
887
+ percentComplete,
888
+ status: session.status,
889
+ startedAt: session.started_at,
890
+ expiresAt: session.expires_at
891
+ }
892
+ });
893
+ } catch (err) {
894
+ res.status(500).json({ error: err.message ?? "Internal error" });
895
+ }
896
+ });
897
+ httpServer.get(`${basePath}/files/:fileId/url`, async (req, res) => {
898
+ try {
899
+ const { fileId } = req.params;
900
+ const file = await store.getFile(fileId);
901
+ if (!file || file.status !== "committed") {
902
+ res.status(404).json({ error: "File not found or not committed" });
903
+ return;
904
+ }
905
+ let url;
906
+ if (storage.getPresignedDownload) {
907
+ const desc = await storage.getPresignedDownload(file.key, presignedTtl);
908
+ url = desc.downloadUrl;
909
+ } else if (storage.getSignedUrl) {
910
+ url = await storage.getSignedUrl(file.key, presignedTtl);
911
+ } else {
912
+ url = `${basePath}/_local/file/${encodeURIComponent(file.key)}`;
913
+ }
914
+ res.json({ url });
915
+ } catch (err) {
916
+ res.status(500).json({ error: err.message ?? "Internal error" });
917
+ }
918
+ });
919
+ httpServer.put(`${basePath}/_local/raw/:token`, async (req, res) => {
920
+ try {
921
+ const { token } = req.params;
922
+ const localAdapter = storage;
923
+ if (!localAdapter.verifyToken) {
924
+ res.status(501).json({ error: "Presigned raw upload not supported by this adapter" });
925
+ return;
926
+ }
927
+ const payload = localAdapter.verifyToken(token, "put");
928
+ let data;
929
+ if (req.rawBody) {
930
+ data = await req.rawBody();
931
+ } else if (Buffer.isBuffer(req.body)) {
932
+ data = req.body;
933
+ } else {
934
+ res.status(400).json({ error: "Binary body required" });
935
+ return;
936
+ }
937
+ await storage.upload(payload.k, data, { contentType: payload.ct });
938
+ res.json({ ok: true, key: payload.k });
939
+ } catch (err) {
940
+ const statusCode = err.message?.includes("expired") || err.message?.includes("signature") ? 403 : 500;
941
+ res.status(statusCode).json({ error: err.message ?? "Upload failed" });
942
+ }
943
+ });
944
+ httpServer.get(`${basePath}/_local/raw/:token`, async (req, res) => {
945
+ try {
946
+ const { token } = req.params;
947
+ const localAdapter = storage;
948
+ if (!localAdapter.verifyToken) {
949
+ res.status(501).json({ error: "Presigned download not supported by this adapter" });
950
+ return;
951
+ }
952
+ const payload = localAdapter.verifyToken(token, "get");
953
+ const data = await storage.download(payload.k);
954
+ res.header("content-type", payload.ct ?? "application/octet-stream");
955
+ res.header("content-length", String(data.byteLength));
956
+ res.send(data);
957
+ } catch (err) {
958
+ const statusCode = err.message?.includes("expired") || err.message?.includes("signature") ? 403 : 500;
959
+ res.status(statusCode).json({ error: err.message ?? "Download failed" });
960
+ }
961
+ });
962
+ }
963
+ function buildKey(scope, fileId, filename) {
964
+ const ext = filename.includes(".") ? "." + filename.split(".").pop() : "";
965
+ return `${scope}/${fileId}${ext}`;
966
+ }
967
+
968
+ // src/objects/system-file.object.ts
969
+ var import_data = require("@objectstack/spec/data");
970
+ var SystemFile = import_data.ObjectSchema.create({
971
+ name: "system_file",
972
+ label: "System File",
973
+ pluralLabel: "System Files",
974
+ icon: "file",
975
+ description: "Storage service file metadata (fileId \u2194 key mapping)",
976
+ titleFormat: "{name}",
977
+ compactLayout: ["name", "mime_type", "size", "status", "created_at"],
978
+ fields: {
979
+ id: import_data.Field.text({
980
+ label: "File ID",
981
+ required: true,
982
+ readonly: true
983
+ }),
984
+ key: import_data.Field.text({
985
+ label: "Storage Key",
986
+ required: true,
987
+ searchable: true
988
+ }),
989
+ name: import_data.Field.text({
990
+ label: "File Name",
991
+ required: true,
992
+ searchable: true
993
+ }),
994
+ mime_type: import_data.Field.text({
995
+ label: "MIME Type"
996
+ }),
997
+ size: import_data.Field.number({
998
+ label: "Size (bytes)"
999
+ }),
1000
+ scope: import_data.Field.select({
1001
+ label: "Scope",
1002
+ options: [
1003
+ { label: "User", value: "user" },
1004
+ { label: "Tenant", value: "tenant" },
1005
+ { label: "Public", value: "public" },
1006
+ { label: "Private", value: "private" },
1007
+ { label: "Temp", value: "temp" }
1008
+ ]
1009
+ }),
1010
+ bucket: import_data.Field.text({
1011
+ label: "Bucket"
1012
+ }),
1013
+ acl: import_data.Field.select({
1014
+ label: "ACL",
1015
+ options: [
1016
+ { label: "Private", value: "private" },
1017
+ { label: "Public Read", value: "public-read" }
1018
+ ]
1019
+ }),
1020
+ status: import_data.Field.select({
1021
+ label: "Status",
1022
+ required: true,
1023
+ options: [
1024
+ { label: "Pending Upload", value: "pending" },
1025
+ { label: "Committed", value: "committed" },
1026
+ { label: "Deleted", value: "deleted" }
1027
+ ]
1028
+ }),
1029
+ etag: import_data.Field.text({
1030
+ label: "ETag"
1031
+ }),
1032
+ owner_id: import_data.Field.text({
1033
+ label: "Owner ID"
1034
+ }),
1035
+ metadata: import_data.Field.text({
1036
+ label: "Metadata (JSON)"
1037
+ }),
1038
+ created_at: import_data.Field.datetime({
1039
+ label: "Created At"
1040
+ }),
1041
+ updated_at: import_data.Field.datetime({
1042
+ label: "Updated At"
1043
+ })
149
1044
  }
150
- async initiateChunkedUpload(_key, _options) {
151
- throw new Error("S3StorageAdapter.initiateChunkedUpload not yet implemented");
1045
+ });
1046
+
1047
+ // src/objects/system-upload-session.object.ts
1048
+ var import_data2 = require("@objectstack/spec/data");
1049
+ var SystemUploadSession = import_data2.ObjectSchema.create({
1050
+ name: "system_upload_session",
1051
+ label: "System Upload Session",
1052
+ pluralLabel: "System Upload Sessions",
1053
+ icon: "upload-cloud",
1054
+ description: "Resumable multipart upload sessions tracked by service-storage",
1055
+ titleFormat: "{filename}",
1056
+ compactLayout: ["filename", "status", "uploaded_chunks", "total_chunks", "expires_at"],
1057
+ fields: {
1058
+ id: import_data2.Field.text({
1059
+ label: "Upload Session ID",
1060
+ required: true,
1061
+ readonly: true
1062
+ }),
1063
+ file_id: import_data2.Field.text({
1064
+ label: "File ID",
1065
+ required: true
1066
+ }),
1067
+ key: import_data2.Field.text({
1068
+ label: "Storage Key",
1069
+ required: true
1070
+ }),
1071
+ filename: import_data2.Field.text({
1072
+ label: "Filename",
1073
+ required: true
1074
+ }),
1075
+ mime_type: import_data2.Field.text({
1076
+ label: "MIME Type"
1077
+ }),
1078
+ total_size: import_data2.Field.number({
1079
+ label: "Total Size (bytes)",
1080
+ required: true
1081
+ }),
1082
+ chunk_size: import_data2.Field.number({
1083
+ label: "Chunk Size (bytes)",
1084
+ required: true
1085
+ }),
1086
+ total_chunks: import_data2.Field.number({
1087
+ label: "Total Chunks",
1088
+ required: true
1089
+ }),
1090
+ uploaded_chunks: import_data2.Field.number({
1091
+ label: "Uploaded Chunks"
1092
+ }),
1093
+ uploaded_size: import_data2.Field.number({
1094
+ label: "Uploaded Size (bytes)"
1095
+ }),
1096
+ parts: import_data2.Field.text({
1097
+ label: "Uploaded Parts (JSON)"
1098
+ }),
1099
+ resume_token: import_data2.Field.text({
1100
+ label: "Resume Token"
1101
+ }),
1102
+ backend_upload_id: import_data2.Field.text({
1103
+ label: "Backend Upload ID"
1104
+ }),
1105
+ scope: import_data2.Field.text({
1106
+ label: "Scope"
1107
+ }),
1108
+ bucket: import_data2.Field.text({
1109
+ label: "Bucket"
1110
+ }),
1111
+ metadata: import_data2.Field.text({
1112
+ label: "Metadata (JSON)"
1113
+ }),
1114
+ status: import_data2.Field.select({
1115
+ label: "Status",
1116
+ required: true,
1117
+ options: [
1118
+ { label: "In Progress", value: "in_progress" },
1119
+ { label: "Completing", value: "completing" },
1120
+ { label: "Completed", value: "completed" },
1121
+ { label: "Failed", value: "failed" },
1122
+ { label: "Expired", value: "expired" }
1123
+ ]
1124
+ }),
1125
+ started_at: import_data2.Field.datetime({
1126
+ label: "Started At"
1127
+ }),
1128
+ expires_at: import_data2.Field.datetime({
1129
+ label: "Expires At"
1130
+ }),
1131
+ updated_at: import_data2.Field.datetime({
1132
+ label: "Updated At"
1133
+ })
152
1134
  }
153
- async uploadChunk(_uploadId, _partNumber, _data) {
154
- throw new Error("S3StorageAdapter.uploadChunk not yet implemented");
1135
+ });
1136
+
1137
+ // src/storage-service-plugin.ts
1138
+ var StorageServicePlugin = class {
1139
+ constructor(options = {}) {
1140
+ this.name = "com.objectstack.service.storage";
1141
+ this.version = "1.0.0";
1142
+ this.type = "standard";
1143
+ this.storage = null;
1144
+ this.store = null;
1145
+ this.options = { adapter: "local", ...options };
155
1146
  }
156
- async completeChunkedUpload(_uploadId, _parts) {
157
- throw new Error("S3StorageAdapter.completeChunkedUpload not yet implemented");
1147
+ async init(ctx) {
1148
+ const adapter = this.options.adapter;
1149
+ if (adapter === "s3") {
1150
+ const { S3StorageAdapter: S3StorageAdapter2 } = await Promise.resolve().then(() => (init_s3_storage_adapter(), s3_storage_adapter_exports));
1151
+ const s3Opts = this.options.s3;
1152
+ if (!s3Opts) {
1153
+ throw new Error('StorageServicePlugin: s3 options are required when adapter is "s3"');
1154
+ }
1155
+ this.storage = new S3StorageAdapter2(s3Opts);
1156
+ } else {
1157
+ const rootDir = this.options.local?.rootDir ?? "./storage";
1158
+ const basePath = this.options.basePath ?? "/api/v1/storage";
1159
+ this.storage = new LocalStorageAdapter({ rootDir, basePath, ...this.options.local });
1160
+ }
1161
+ ctx.registerService("file-storage", this.storage);
1162
+ ctx.logger.info(`StorageServicePlugin: registered ${adapter} storage adapter`);
1163
+ try {
1164
+ ctx.getService("manifest").register({
1165
+ id: "com.objectstack.service.storage",
1166
+ name: "Storage Service",
1167
+ version: "1.0.0",
1168
+ type: "plugin",
1169
+ scope: "project",
1170
+ objects: [SystemFile, SystemUploadSession]
1171
+ });
1172
+ } catch {
1173
+ }
158
1174
  }
159
- async abortChunkedUpload(_uploadId) {
160
- throw new Error("S3StorageAdapter.abortChunkedUpload not yet implemented");
1175
+ async start(ctx) {
1176
+ if (this.options.registerRoutes === false) return;
1177
+ 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;
1188
+ }
1189
+ let engine = null;
1190
+ try {
1191
+ engine = ctx.getService("objectql");
1192
+ } catch {
1193
+ }
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
+ });
161
1202
  }
162
1203
  };
1204
+
1205
+ // src/index.ts
1206
+ init_s3_storage_adapter();
163
1207
  // Annotate the CommonJS export names for ESM import in node:
164
1208
  0 && (module.exports = {
165
1209
  LocalStorageAdapter,
166
1210
  S3StorageAdapter,
167
- StorageServicePlugin
1211
+ StorageMetadataStore,
1212
+ StorageServicePlugin,
1213
+ SystemFile,
1214
+ SystemUploadSession,
1215
+ registerStorageRoutes
168
1216
  });
169
1217
  //# sourceMappingURL=index.cjs.map