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