@remit/storage-service 0.0.7 → 0.0.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/storage-service",
3
- "version": "0.0.7",
3
+ "version": "0.0.9",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -16,6 +16,10 @@
16
16
  "./s3": {
17
17
  "types": "./src/s3.ts",
18
18
  "default": "./src/s3.ts"
19
+ },
20
+ "./signed-path": {
21
+ "types": "./src/signed-path.ts",
22
+ "default": "./src/signed-path.ts"
19
23
  }
20
24
  },
21
25
  "scripts": {
@@ -4,6 +4,7 @@ import {
4
4
  mkdir,
5
5
  readdir,
6
6
  readFile,
7
+ rm,
7
8
  stat,
8
9
  unlink,
9
10
  writeFile,
@@ -13,7 +14,13 @@ import { PassThrough, Readable } from "node:stream";
13
14
  import { pipeline } from "node:stream/promises";
14
15
  import { createGunzip, createGzip, gunzipSync, gzipSync } from "node:zlib";
15
16
  import { ContentEncoding, StorageType } from "@remit/domain-enums";
17
+ import {
18
+ UPLOAD_ROUTE_PREFIX,
19
+ UPLOAD_URL_SIGNING_LABEL,
20
+ } from "../outbox-upload-url.js";
21
+ import { signStoragePath } from "../signed-path.js";
16
22
  import type {
23
+ OutboxAttachmentListItem,
17
24
  ParsedBody,
18
25
  StorageReference,
19
26
  StorageService,
@@ -26,6 +33,8 @@ import {
26
33
  buildExtractedSkippedKey,
27
34
  buildExtractedTextKey,
28
35
  buildMessageBodyKey,
36
+ buildOutboxAttachmentKey,
37
+ buildOutboxAttachmentPrefix,
29
38
  buildParsedBodyKey,
30
39
  computeChecksum,
31
40
  isStorageNotFoundError,
@@ -37,8 +46,20 @@ interface StoreParams {
37
46
  compress?: boolean;
38
47
  }
39
48
 
49
+ /**
50
+ * Where a minted upload URL points, and what signs it. Absent on a process that
51
+ * only reads storage; a mint then fails loud rather than handing out a URL
52
+ * nothing can verify.
53
+ */
54
+ export interface FilesystemUploadUrlConfig {
55
+ /** Public origin of the deployment serving the upload route. */
56
+ origin: string;
57
+ signingSecret: string;
58
+ }
59
+
40
60
  export const createFilesystemStorageService = (
41
61
  basePath: string,
62
+ uploadUrls?: FilesystemUploadUrlConfig,
42
63
  ): StorageService => {
43
64
  const storeInternal = async (
44
65
  params: StoreParams,
@@ -252,6 +273,181 @@ export const createFilesystemStorageService = (
252
273
  }));
253
274
  };
254
275
 
276
+ const storeOutboxAttachment: StorageService["storeOutboxAttachment"] = (
277
+ params,
278
+ ) => {
279
+ const {
280
+ accountConfigId,
281
+ accountId,
282
+ outboxMessageId,
283
+ outboxAttachmentId,
284
+ content,
285
+ } = params;
286
+ return storeInternal({
287
+ key: buildOutboxAttachmentKey(
288
+ accountConfigId,
289
+ accountId,
290
+ outboxMessageId,
291
+ outboxAttachmentId,
292
+ ),
293
+ content,
294
+ compress: false,
295
+ });
296
+ };
297
+
298
+ const listOutboxAttachments: StorageService["listOutboxAttachments"] = async (
299
+ accountConfigId,
300
+ accountId,
301
+ outboxMessageId,
302
+ ) => {
303
+ const prefix = buildOutboxAttachmentPrefix(
304
+ accountConfigId,
305
+ accountId,
306
+ outboxMessageId,
307
+ );
308
+ const entries = await readdir(join(basePath, prefix)).catch(
309
+ (error: unknown) => {
310
+ if (isStorageNotFoundError(error)) return [];
311
+ throw error;
312
+ },
313
+ );
314
+
315
+ const items: OutboxAttachmentListItem[] = [];
316
+ for (const name of entries) {
317
+ const { size } = await stat(join(basePath, prefix, name));
318
+ items.push({
319
+ outboxAttachmentId: name,
320
+ key: `${prefix}${name}`,
321
+ sizeBytes: size,
322
+ });
323
+ }
324
+ return items;
325
+ };
326
+
327
+ const listOutboxDraftsWithAttachments: StorageService["listOutboxDraftsWithAttachments"] =
328
+ async (accountConfigId, accountId) => {
329
+ // An account that has never held an attachment has no directory, and
330
+ // that is genuinely nothing to do. A storage root that is not there at
331
+ // all is a different thing entirely — a process that cannot see the
332
+ // volume it is meant to be collecting from — and it must not read as an
333
+ // empty answer. Unmounted, the sweep would report every account swept
334
+ // and collect nothing, forever.
335
+ const rootExists = await stat(basePath)
336
+ .then(() => true)
337
+ .catch((error: unknown) => {
338
+ if (isStorageNotFoundError(error)) return false;
339
+ throw error;
340
+ });
341
+ if (!rootExists) {
342
+ throw new Error(
343
+ `storage root ${basePath} does not exist; this process cannot see the storage it was asked to read`,
344
+ );
345
+ }
346
+
347
+ const root = join(
348
+ basePath,
349
+ `accounts/${accountConfigId}/${accountId}/outbox`,
350
+ );
351
+ const entries = await readdir(root, { withFileTypes: true }).catch(
352
+ (error: unknown) => {
353
+ if (isStorageNotFoundError(error)) return [];
354
+ throw error;
355
+ },
356
+ );
357
+ return entries
358
+ .filter((entry) => entry.isDirectory())
359
+ .map((entry) => entry.name);
360
+ };
361
+
362
+ const deleteOutboxAttachment: StorageService["deleteOutboxAttachment"] =
363
+ async (accountConfigId, accountId, outboxMessageId, outboxAttachmentId) => {
364
+ const prefix = buildOutboxAttachmentPrefix(
365
+ accountConfigId,
366
+ accountId,
367
+ outboxMessageId,
368
+ );
369
+ const entries = await readdir(join(basePath, prefix)).catch(
370
+ (error: unknown) => {
371
+ if (isStorageNotFoundError(error)) return [];
372
+ throw error;
373
+ },
374
+ );
375
+ for (const name of entries) {
376
+ if (name !== outboxAttachmentId) continue;
377
+ await rm(join(basePath, prefix, name), { force: true });
378
+ }
379
+ };
380
+
381
+ const statOutboxAttachment: StorageService["statOutboxAttachment"] = async (
382
+ accountConfigId,
383
+ accountId,
384
+ outboxMessageId,
385
+ outboxAttachmentId,
386
+ ) => {
387
+ const fullPath = join(
388
+ basePath,
389
+ buildOutboxAttachmentKey(
390
+ accountConfigId,
391
+ accountId,
392
+ outboxMessageId,
393
+ outboxAttachmentId,
394
+ ),
395
+ );
396
+ const stats = await stat(fullPath).catch((error: unknown) => {
397
+ if (isStorageNotFoundError(error)) return null;
398
+ throw error;
399
+ });
400
+ return stats ? { sizeBytes: stats.size } : null;
401
+ };
402
+
403
+ // This backend has no presigned anything, so the URL addresses the
404
+ // deployment's own upload route. Its authority is the same HMAC the read side
405
+ // uses on /content, under the write label, covering the storage key, the
406
+ // expiry and the exact byte count — a URL for one attachment, for a while,
407
+ // for one size.
408
+ const createOutboxAttachmentUploadUrl: StorageService["createOutboxAttachmentUploadUrl"] =
409
+ async (params) => {
410
+ if (!uploadUrls) {
411
+ throw new Error(
412
+ "the filesystem storage backend cannot mint an upload URL without an origin and a signing secret",
413
+ );
414
+ }
415
+ const key = buildOutboxAttachmentKey(
416
+ params.accountConfigId,
417
+ params.accountId,
418
+ params.outboxMessageId,
419
+ params.outboxAttachmentId,
420
+ );
421
+ const sig = signStoragePath(
422
+ uploadUrls.signingSecret,
423
+ UPLOAD_URL_SIGNING_LABEL,
424
+ key,
425
+ [params.expiresAt, params.sizeBytes],
426
+ );
427
+ const origin = uploadUrls.origin.replace(/\/+$/, "");
428
+ const query = new URLSearchParams({
429
+ exp: String(params.expiresAt),
430
+ max: String(params.sizeBytes),
431
+ sig,
432
+ });
433
+ return {
434
+ uploadUrl: `${origin}${UPLOAD_ROUTE_PREFIX}${key}?${query.toString()}`,
435
+ };
436
+ };
437
+
438
+ const deleteOutboxAttachments: StorageService["deleteOutboxAttachments"] =
439
+ async (accountConfigId, accountId, outboxMessageId) => {
440
+ const dirPath = join(
441
+ basePath,
442
+ buildOutboxAttachmentPrefix(
443
+ accountConfigId,
444
+ accountId,
445
+ outboxMessageId,
446
+ ),
447
+ );
448
+ await rm(dirPath, { recursive: true, force: true });
449
+ };
450
+
255
451
  const storeDeduplicated: StorageService["storeDeduplicated"] = (params) => {
256
452
  const { accountConfigId, accountId, content } = params;
257
453
  const checksumSha256 = computeChecksum(content);
@@ -338,6 +534,13 @@ export const createFilesystemStorageService = (
338
534
  storeBodyPart,
339
535
  bodyPartExists,
340
536
  retrieveBodyPart,
537
+ storeOutboxAttachment,
538
+ listOutboxAttachments,
539
+ listOutboxDraftsWithAttachments,
540
+ deleteOutboxAttachments,
541
+ deleteOutboxAttachment,
542
+ statOutboxAttachment,
543
+ createOutboxAttachmentUploadUrl,
341
544
  storeDeduplicated,
342
545
  storeParsedBody,
343
546
  retrieveParsedBody,
@@ -3,6 +3,8 @@ import { Readable } from "node:stream";
3
3
  import { describe, test } from "node:test";
4
4
  import { gunzipSync } from "node:zlib";
5
5
  import {
6
+ DeleteObjectCommand,
7
+ DeleteObjectsCommand,
6
8
  GetObjectCommand,
7
9
  HeadObjectCommand,
8
10
  ListObjectsV2Command,
@@ -22,16 +24,34 @@ interface CapturedCommand {
22
24
  const createFakeS3Client = (): {
23
25
  client: S3Client;
24
26
  commands: CapturedCommand[];
25
- stored: Map<string, { body: Buffer; contentEncoding?: string }>;
27
+ stored: Map<string, { body: Buffer; etag: string; contentEncoding?: string }>;
26
28
  } => {
27
29
  const commands: CapturedCommand[] = [];
28
- const stored = new Map<string, { body: Buffer; contentEncoding?: string }>();
30
+ const stored = new Map<
31
+ string,
32
+ { body: Buffer; etag: string; contentEncoding?: string }
33
+ >();
34
+ let etagCounter = 0;
29
35
 
30
36
  const send = async (command: unknown): Promise<unknown> => {
31
37
  if (command instanceof PutObjectCommand) {
32
38
  const input = command.input as unknown as Record<string, unknown>;
33
39
  commands.push({ name: "PutObjectCommand", input });
34
40
 
41
+ // S3's conditional write, which is what the ledger's compare-and-set
42
+ // rides on: If-None-Match refuses an overwrite, If-Match refuses a
43
+ // stale one. Both answer 412.
44
+ const existing = stored.get(String(input.Key));
45
+ const precondition =
46
+ (input.IfNoneMatch === "*" && existing !== undefined) ||
47
+ (typeof input.IfMatch === "string" && input.IfMatch !== existing?.etag);
48
+ if (precondition) {
49
+ throw Object.assign(new Error("PreconditionFailed"), {
50
+ name: "PreconditionFailed",
51
+ $metadata: { httpStatusCode: 412 },
52
+ });
53
+ }
54
+
35
55
  if (input.ChecksumSHA256 !== undefined) {
36
56
  throw Object.assign(
37
57
  new Error(
@@ -46,8 +66,10 @@ const createFakeS3Client = (): {
46
66
  if (!Buffer.isBuffer(body)) {
47
67
  throw new Error("expected Body to be a Buffer");
48
68
  }
69
+ etagCounter += 1;
49
70
  stored.set(key, {
50
71
  body,
72
+ etag: `"etag-${etagCounter}"`,
51
73
  contentEncoding:
52
74
  typeof input.ContentEncoding === "string"
53
75
  ? input.ContentEncoding
@@ -70,26 +92,65 @@ const createFakeS3Client = (): {
70
92
  transformToByteArray: async () => new Uint8Array(entry.body),
71
93
  },
72
94
  ContentEncoding: entry.contentEncoding,
95
+ ETag: entry.etag,
73
96
  };
74
97
  }
75
98
 
76
99
  if (command instanceof HeadObjectCommand) {
77
100
  const input = command.input as unknown as Record<string, unknown>;
78
101
  const key = String(input.Key);
79
- if (!stored.has(key)) {
102
+ const entry = stored.get(key);
103
+ if (!entry) {
80
104
  throw Object.assign(new Error(`not found: ${key}`), {
81
105
  name: "NotFound",
82
106
  });
83
107
  }
108
+ return { ContentLength: entry.body.length };
109
+ }
110
+
111
+ if (command instanceof DeleteObjectCommand) {
112
+ const input = command.input as unknown as Record<string, unknown>;
113
+ commands.push({ name: "DeleteObjectCommand", input });
114
+ stored.delete(String(input.Key));
115
+ return {};
116
+ }
117
+
118
+ if (command instanceof DeleteObjectsCommand) {
119
+ const input = command.input as unknown as Record<string, unknown>;
120
+ commands.push({ name: "DeleteObjectsCommand", input });
121
+ const del = input.Delete as { Objects: { Key: string }[] };
122
+ for (const { Key } of del.Objects) stored.delete(Key);
84
123
  return {};
85
124
  }
86
125
 
87
126
  if (command instanceof ListObjectsV2Command) {
88
127
  const input = command.input as unknown as Record<string, unknown>;
89
128
  const prefix = String(input.Prefix ?? "");
90
- const Contents = [...stored.keys()]
91
- .filter((key) => key.startsWith(prefix))
92
- .map((Key) => ({ Key }));
129
+ const maxKeys = Number(input.MaxKeys ?? Number.POSITIVE_INFINITY);
130
+ const matching = [...stored.entries()].filter(([key]) =>
131
+ key.startsWith(prefix),
132
+ );
133
+
134
+ // A delimited listing answers with directory-like prefixes instead of
135
+ // the objects beneath them, the way listOutboxDraftsWithAttachments
136
+ // asks for them.
137
+ if (typeof input.Delimiter === "string") {
138
+ const delimiter = input.Delimiter;
139
+ const prefixes = new Set<string>();
140
+ for (const [key] of matching) {
141
+ const rest = key.slice(prefix.length);
142
+ const at = rest.indexOf(delimiter);
143
+ if (at >= 0) prefixes.add(prefix + rest.slice(0, at + 1));
144
+ }
145
+ return {
146
+ CommonPrefixes: [...prefixes].map((Prefix) => ({ Prefix })),
147
+ IsTruncated: false,
148
+ };
149
+ }
150
+
151
+ const Contents = matching
152
+ .slice(0, maxKeys)
153
+ .map(([Key, entry]) => ({ Key, Size: entry.body.length }));
93
154
  return { Contents, IsTruncated: false };
94
155
  }
95
156
 
@@ -461,3 +522,80 @@ describe("createS3StorageService", () => {
461
522
  }
462
523
  });
463
524
  });
525
+
526
+ describe("createS3StorageService: outbox attachments", () => {
527
+ test("an uploaded object lists at the size the bucket holds", async () => {
528
+ const { client } = createFakeS3Client();
529
+ const storage = createS3StorageService(client, "bucket");
530
+
531
+ await storage.storeOutboxAttachment({
532
+ accountConfigId: "cfg1",
533
+ accountId: "acc1",
534
+ outboxMessageId: "draft1",
535
+ outboxAttachmentId: "att1",
536
+ content: Buffer.alloc(777),
537
+ });
538
+
539
+ const [entry] = await storage.listOutboxAttachments(
540
+ "cfg1",
541
+ "acc1",
542
+ "draft1",
543
+ );
544
+ assert.strictEqual(entry.outboxAttachmentId, "att1");
545
+ // Uncompressed, so the size in the bucket is the size the cap counts.
546
+ assert.strictEqual(entry.sizeBytes, 777);
547
+ assert.strictEqual(
548
+ await storage
549
+ .statOutboxAttachment("cfg1", "acc1", "draft1", "att1")
550
+ .then((found) => found?.sizeBytes),
551
+ 777,
552
+ );
553
+ });
554
+
555
+ test("statOutboxAttachment answers null for an object that was never uploaded", async () => {
556
+ const { client } = createFakeS3Client();
557
+ const storage = createS3StorageService(client, "bucket");
558
+
559
+ assert.strictEqual(
560
+ await storage.statOutboxAttachment("cfg1", "acc1", "draft1", "nothing"),
561
+ null,
562
+ );
563
+ });
564
+
565
+ test("deleting a draft's attachments empties the prefix", async () => {
566
+ const { client } = createFakeS3Client();
567
+ const storage = createS3StorageService(client, "bucket");
568
+ await storage.storeOutboxAttachment({
569
+ accountConfigId: "cfg1",
570
+ accountId: "acc1",
571
+ outboxMessageId: "draft1",
572
+ outboxAttachmentId: "att2",
573
+ content: Buffer.alloc(8),
574
+ });
575
+ await storage.deleteOutboxAttachments("cfg1", "acc1", "draft1");
576
+
577
+ assert.deepStrictEqual(
578
+ await storage.listOutboxAttachments("cfg1", "acc1", "draft1"),
579
+ [],
580
+ );
581
+ });
582
+
583
+ test("listOutboxDraftsWithAttachments finds prefixes whose row may be long gone", async () => {
584
+ const { client } = createFakeS3Client();
585
+ const storage = createS3StorageService(client, "bucket");
586
+ for (const draft of ["draft1", "draft2"]) {
587
+ await storage.storeOutboxAttachment({
588
+ accountConfigId: "cfg1",
589
+ accountId: "acc1",
590
+ outboxMessageId: draft,
591
+ outboxAttachmentId: "att1",
592
+ content: Buffer.alloc(4),
593
+ });
594
+ }
595
+
596
+ assert.deepStrictEqual(
597
+ (await storage.listOutboxDraftsWithAttachments("cfg1", "acc1")).sort(),
598
+ ["draft1", "draft2"],
599
+ );
600
+ });
601
+ });