@remit/storage-service 0.0.12 → 0.0.13

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,48 +1,53 @@
1
1
  {
2
- "name": "@remit/storage-service",
3
- "version": "0.0.12",
4
- "type": "module",
5
- "main": "src/index.ts",
6
- "types": "src/index.ts",
7
- "exports": {
8
- ".": {
9
- "types": "./src/index.ts",
10
- "default": "./src/index.ts"
11
- },
12
- "./filesystem": {
13
- "types": "./src/filesystem.ts",
14
- "default": "./src/filesystem.ts"
15
- },
16
- "./s3": {
17
- "types": "./src/s3.ts",
18
- "default": "./src/s3.ts"
19
- },
20
- "./signed-path": {
21
- "types": "./src/signed-path.ts",
22
- "default": "./src/signed-path.ts"
23
- }
24
- },
25
- "scripts": {
26
- "test:typecheck": "tsgo --noEmit",
27
- "test:run": "node $NODE_TEST_FLAGS --env-file=../../localhost-test-unit.env --import tsx --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-exclude='src/**/*.test.ts' --test-coverage-exclude='src/**/*.test.tsx' --test-coverage-lines=87 --test 'src/**/*.test.ts'",
28
- "test": "npm run test:typecheck && npm run test:run"
29
- },
30
- "dependencies": {
31
- "@aws-sdk/client-s3": "*",
32
- "@aws-sdk/lib-storage": "*",
33
- "@aws-sdk/s3-request-presigner": "*",
34
- "@remit/domain-enums": "*"
35
- },
36
- "devDependencies": {
37
- "aws-sdk-client-mock": "*"
38
- },
39
- "license": "MIT",
40
- "publishConfig": {
41
- "access": "public"
42
- },
43
- "repository": {
44
- "type": "git",
45
- "url": "git+https://github.com/remit-mail/reader.git",
46
- "directory": "packages/storage-service"
47
- }
2
+ "name": "@remit/storage-service",
3
+ "version": "0.0.13",
4
+ "type": "module",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ },
12
+ "./filesystem": {
13
+ "types": "./dist/filesystem.d.ts",
14
+ "default": "./dist/filesystem.js"
15
+ },
16
+ "./s3": {
17
+ "types": "./dist/s3.d.ts",
18
+ "default": "./dist/s3.js"
19
+ },
20
+ "./signed-path": {
21
+ "types": "./dist/signed-path.d.ts",
22
+ "default": "./dist/signed-path.js"
23
+ }
24
+ },
25
+ "scripts": {
26
+ "prepack": "node ../../npm-scripts/pack-dist.mjs prepack",
27
+ "postpack": "node ../../npm-scripts/pack-dist.mjs postpack",
28
+ "test:typecheck": "tsgo --noEmit",
29
+ "test:run": "node $NODE_TEST_FLAGS --env-file=../../localhost-test-unit.env --import tsx --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-exclude='src/**/*.test.ts' --test-coverage-exclude='src/**/*.test.tsx' --test-coverage-lines=87 --test 'src/**/*.test.ts'",
30
+ "test": "npm run test:typecheck && npm run test:run"
31
+ },
32
+ "dependencies": {
33
+ "@aws-sdk/client-s3": "^3.1090.0",
34
+ "@aws-sdk/lib-storage": "^3.1090.0",
35
+ "@aws-sdk/s3-request-presigner": "^3.1090.0",
36
+ "@remit/domain-enums": "*"
37
+ },
38
+ "devDependencies": {
39
+ "aws-sdk-client-mock": "*"
40
+ },
41
+ "license": "MIT",
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
45
+ "repository": {
46
+ "type": "git",
47
+ "url": "git+https://github.com/remit-mail/reader.git",
48
+ "directory": "packages/storage-service"
49
+ },
50
+ "files": [
51
+ "dist"
52
+ ]
48
53
  }
@@ -1,517 +0,0 @@
1
- import { createHash } from "node:crypto";
2
- import { createWriteStream } from "node:fs";
3
- import {
4
- mkdir,
5
- readdir,
6
- readFile,
7
- rm,
8
- stat,
9
- unlink,
10
- writeFile,
11
- } from "node:fs/promises";
12
- import { dirname, join, resolve } from "node:path";
13
- import { PassThrough, Readable } from "node:stream";
14
- import { pipeline } from "node:stream/promises";
15
- import { createGunzip, createGzip, gunzipSync, gzipSync } from "node:zlib";
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";
22
- import type {
23
- OutboxAttachmentListItem,
24
- ParsedBody,
25
- StorageReference,
26
- StorageService,
27
- } from "../storage.js";
28
- import {
29
- buildBodyPartKey,
30
- buildDeduplicatedKey,
31
- buildExportArchiveKey,
32
- buildMessageBodyKey,
33
- buildOutboxAttachmentKey,
34
- buildOutboxAttachmentPrefix,
35
- buildParsedBodyKey,
36
- computeChecksum,
37
- isStorageNotFoundError,
38
- } from "../storage.js";
39
-
40
- interface StoreParams {
41
- key: string;
42
- content: Buffer;
43
- compress?: boolean;
44
- }
45
-
46
- /**
47
- * Where a minted upload URL points, and what signs it. Absent on a process that
48
- * only reads storage; a mint then fails loud rather than handing out a URL
49
- * nothing can verify.
50
- */
51
- export interface FilesystemUploadUrlConfig {
52
- /** Public origin of the deployment serving the upload route. */
53
- origin: string;
54
- signingSecret: string;
55
- }
56
-
57
- export const createFilesystemStorageService = (
58
- basePath: string,
59
- uploadUrls?: FilesystemUploadUrlConfig,
60
- ): StorageService => {
61
- const storeInternal = async (
62
- params: StoreParams,
63
- ): Promise<StorageReference> => {
64
- const { key, content, compress = true } = params;
65
- const checksumSha256 = computeChecksum(content);
66
- const contentEncoding = compress
67
- ? ContentEncoding.Gzip
68
- : ContentEncoding.None;
69
- const body = compress ? gzipSync(content) : content;
70
-
71
- const fullPath = join(basePath, key);
72
- await mkdir(dirname(fullPath), { recursive: true });
73
- await writeFile(fullPath, body);
74
-
75
- return {
76
- uri: `file://${fullPath}`,
77
- storageType: StorageType.Filesystem,
78
- storageLocation: basePath,
79
- storageKey: key,
80
- sizeBytes: body.length,
81
- checksumSha256,
82
- contentEncoding,
83
- };
84
- };
85
-
86
- const storeMessageBody: StorageService["storeMessageBody"] = (params) => {
87
- const { accountConfigId, accountId, messageId, content } = params;
88
- return storeInternal({
89
- key: buildMessageBodyKey(accountConfigId, accountId, messageId),
90
- content,
91
- });
92
- };
93
-
94
- const storeMessageBodyStream: StorageService["storeMessageBodyStream"] =
95
- async (params) => {
96
- const { accountConfigId, accountId, messageId, content } = params;
97
- const key = buildMessageBodyKey(accountConfigId, accountId, messageId);
98
- const fullPath = join(basePath, key);
99
- await mkdir(dirname(fullPath), { recursive: true });
100
-
101
- const hash = createHash("sha256");
102
- const hashTap = new PassThrough();
103
- hashTap.on("data", (chunk: Buffer) => hash.update(chunk));
104
-
105
- await pipeline(
106
- content,
107
- hashTap,
108
- createGzip(),
109
- createWriteStream(fullPath),
110
- );
111
-
112
- const { size } = await stat(fullPath);
113
-
114
- return {
115
- uri: `file://${fullPath}`,
116
- storageType: StorageType.Filesystem,
117
- storageLocation: basePath,
118
- storageKey: key,
119
- sizeBytes: size,
120
- checksumSha256: hash.digest("hex"),
121
- contentEncoding: ContentEncoding.Gzip,
122
- };
123
- };
124
-
125
- const storeBodyPart: StorageService["storeBodyPart"] = (params) => {
126
- const { accountConfigId, accountId, messageId, partPath, content } = params;
127
- return storeInternal({
128
- key: buildBodyPartKey(accountConfigId, accountId, messageId, partPath),
129
- content,
130
- });
131
- };
132
-
133
- const bodyPartExists: StorageService["bodyPartExists"] = (
134
- accountConfigId,
135
- accountId,
136
- messageId,
137
- partPath,
138
- ) => {
139
- const key = buildBodyPartKey(
140
- accountConfigId,
141
- accountId,
142
- messageId,
143
- partPath,
144
- );
145
- const fullPath = join(basePath, key);
146
- return stat(fullPath)
147
- .then(() => true)
148
- .catch(() => false);
149
- };
150
-
151
- const retrieveBodyPart: StorageService["retrieveBodyPart"] = async (
152
- accountConfigId,
153
- accountId,
154
- messageId,
155
- partPath,
156
- ) => {
157
- const key = buildBodyPartKey(
158
- accountConfigId,
159
- accountId,
160
- messageId,
161
- partPath,
162
- );
163
- const fullPath = join(basePath, key);
164
- const buffer = await readFile(fullPath).catch((error: unknown) => {
165
- if (isStorageNotFoundError(error)) return null;
166
- throw error;
167
- });
168
- if (!buffer) return null;
169
- return buffer[0] === 0x1f && buffer[1] === 0x8b
170
- ? gunzipSync(buffer)
171
- : buffer;
172
- };
173
-
174
- const storeOutboxAttachment: StorageService["storeOutboxAttachment"] = (
175
- params,
176
- ) => {
177
- const {
178
- accountConfigId,
179
- accountId,
180
- outboxMessageId,
181
- outboxAttachmentId,
182
- content,
183
- } = params;
184
- return storeInternal({
185
- key: buildOutboxAttachmentKey(
186
- accountConfigId,
187
- accountId,
188
- outboxMessageId,
189
- outboxAttachmentId,
190
- ),
191
- content,
192
- compress: false,
193
- });
194
- };
195
-
196
- const listOutboxAttachments: StorageService["listOutboxAttachments"] = async (
197
- accountConfigId,
198
- accountId,
199
- outboxMessageId,
200
- ) => {
201
- const prefix = buildOutboxAttachmentPrefix(
202
- accountConfigId,
203
- accountId,
204
- outboxMessageId,
205
- );
206
- const entries = await readdir(join(basePath, prefix)).catch(
207
- (error: unknown) => {
208
- if (isStorageNotFoundError(error)) return [];
209
- throw error;
210
- },
211
- );
212
-
213
- const items: OutboxAttachmentListItem[] = [];
214
- for (const name of entries) {
215
- const { size } = await stat(join(basePath, prefix, name));
216
- items.push({
217
- outboxAttachmentId: name,
218
- key: `${prefix}${name}`,
219
- sizeBytes: size,
220
- });
221
- }
222
- return items;
223
- };
224
-
225
- const listOutboxDraftsWithAttachments: StorageService["listOutboxDraftsWithAttachments"] =
226
- async (accountConfigId, accountId) => {
227
- // An account that has never held an attachment has no directory, and
228
- // that is genuinely nothing to do. A storage root that is not there at
229
- // all is a different thing entirely — a process that cannot see the
230
- // volume it is meant to be collecting from — and it must not read as an
231
- // empty answer. Unmounted, the sweep would report every account swept
232
- // and collect nothing, forever.
233
- const rootExists = await stat(basePath)
234
- .then(() => true)
235
- .catch((error: unknown) => {
236
- if (isStorageNotFoundError(error)) return false;
237
- throw error;
238
- });
239
- if (!rootExists) {
240
- throw new Error(
241
- `storage root ${basePath} does not exist; this process cannot see the storage it was asked to read`,
242
- );
243
- }
244
-
245
- const root = join(
246
- basePath,
247
- `accounts/${accountConfigId}/${accountId}/outbox`,
248
- );
249
- const entries = await readdir(root, { withFileTypes: true }).catch(
250
- (error: unknown) => {
251
- if (isStorageNotFoundError(error)) return [];
252
- throw error;
253
- },
254
- );
255
- return entries
256
- .filter((entry) => entry.isDirectory())
257
- .map((entry) => entry.name);
258
- };
259
-
260
- const deleteOutboxAttachment: StorageService["deleteOutboxAttachment"] =
261
- async (accountConfigId, accountId, outboxMessageId, outboxAttachmentId) => {
262
- const prefix = buildOutboxAttachmentPrefix(
263
- accountConfigId,
264
- accountId,
265
- outboxMessageId,
266
- );
267
- const entries = await readdir(join(basePath, prefix)).catch(
268
- (error: unknown) => {
269
- if (isStorageNotFoundError(error)) return [];
270
- throw error;
271
- },
272
- );
273
- for (const name of entries) {
274
- if (name !== outboxAttachmentId) continue;
275
- await rm(join(basePath, prefix, name), { force: true });
276
- }
277
- };
278
-
279
- const statOutboxAttachment: StorageService["statOutboxAttachment"] = async (
280
- accountConfigId,
281
- accountId,
282
- outboxMessageId,
283
- outboxAttachmentId,
284
- ) => {
285
- const fullPath = join(
286
- basePath,
287
- buildOutboxAttachmentKey(
288
- accountConfigId,
289
- accountId,
290
- outboxMessageId,
291
- outboxAttachmentId,
292
- ),
293
- );
294
- const stats = await stat(fullPath).catch((error: unknown) => {
295
- if (isStorageNotFoundError(error)) return null;
296
- throw error;
297
- });
298
- return stats ? { sizeBytes: stats.size } : null;
299
- };
300
-
301
- const retrieveOutboxAttachment: StorageService["retrieveOutboxAttachment"] = (
302
- accountConfigId,
303
- accountId,
304
- outboxMessageId,
305
- outboxAttachmentId,
306
- ) =>
307
- readFile(
308
- join(
309
- basePath,
310
- buildOutboxAttachmentKey(
311
- accountConfigId,
312
- accountId,
313
- outboxMessageId,
314
- outboxAttachmentId,
315
- ),
316
- ),
317
- ).catch((error: unknown) => {
318
- if (isStorageNotFoundError(error)) return null;
319
- throw error;
320
- });
321
-
322
- // This backend has no presigned anything, so the URL addresses the
323
- // deployment's own upload route. Its authority is the same HMAC the read side
324
- // uses on /content, under the write label, covering the storage key, the
325
- // expiry and the exact byte count — a URL for one attachment, for a while,
326
- // for one size.
327
- const createOutboxAttachmentUploadUrl: StorageService["createOutboxAttachmentUploadUrl"] =
328
- async (params) => {
329
- if (!uploadUrls) {
330
- throw new Error(
331
- "the filesystem storage backend cannot mint an upload URL without an origin and a signing secret",
332
- );
333
- }
334
- const key = buildOutboxAttachmentKey(
335
- params.accountConfigId,
336
- params.accountId,
337
- params.outboxMessageId,
338
- params.outboxAttachmentId,
339
- );
340
- const sig = signStoragePath(
341
- uploadUrls.signingSecret,
342
- UPLOAD_URL_SIGNING_LABEL,
343
- key,
344
- [params.expiresAt, params.sizeBytes],
345
- );
346
- const origin = uploadUrls.origin.replace(/\/+$/, "");
347
- const query = new URLSearchParams({
348
- exp: String(params.expiresAt),
349
- max: String(params.sizeBytes),
350
- sig,
351
- });
352
- return {
353
- uploadUrl: `${origin}${UPLOAD_ROUTE_PREFIX}${key}?${query.toString()}`,
354
- };
355
- };
356
-
357
- const deleteOutboxAttachments: StorageService["deleteOutboxAttachments"] =
358
- async (accountConfigId, accountId, outboxMessageId) => {
359
- const dirPath = join(
360
- basePath,
361
- buildOutboxAttachmentPrefix(
362
- accountConfigId,
363
- accountId,
364
- outboxMessageId,
365
- ),
366
- );
367
- await rm(dirPath, { recursive: true, force: true });
368
- };
369
-
370
- const storeDeduplicated: StorageService["storeDeduplicated"] = (params) => {
371
- const { accountConfigId, accountId, content } = params;
372
- const checksumSha256 = computeChecksum(content);
373
- return storeInternal({
374
- key: buildDeduplicatedKey(accountConfigId, accountId, checksumSha256),
375
- content,
376
- });
377
- };
378
-
379
- const storeParsedBody: StorageService["storeParsedBody"] = (params) => {
380
- const { accountConfigId, accountId, messageId, parsed } = params;
381
- const content = Buffer.from(JSON.stringify(parsed), "utf8");
382
- return storeInternal({
383
- key: buildParsedBodyKey(accountConfigId, accountId, messageId),
384
- content,
385
- });
386
- };
387
-
388
- const retrieveParsedBody: StorageService["retrieveParsedBody"] = async (
389
- accountConfigId,
390
- accountId,
391
- messageId,
392
- ) => {
393
- const key = buildParsedBodyKey(accountConfigId, accountId, messageId);
394
- const fullPath = join(basePath, key);
395
-
396
- const buffer = await readFile(fullPath).catch((error: unknown) => {
397
- if (isStorageNotFoundError(error)) return null;
398
- throw error;
399
- });
400
-
401
- if (!buffer) return null;
402
-
403
- const decoded =
404
- buffer[0] === 0x1f && buffer[1] === 0x8b ? gunzipSync(buffer) : buffer;
405
- return JSON.parse(decoded.toString("utf8")) as ParsedBody;
406
- };
407
-
408
- // Resolve path from URI, handling both absolute and relative paths
409
- // Note: URIs like file://.remit/storage/... parse incorrectly - '.remit' becomes hostname
410
- // We reconstruct the original path by combining hostname + pathname
411
- const resolvePathFromUri = (uri: string): string => {
412
- const url = new URL(uri);
413
- const hostname = url.hostname;
414
- const pathname = url.pathname;
415
-
416
- // If hostname exists, it was part of a relative path (e.g., file://.remit/... -> hostname='.remit')
417
- if (hostname) {
418
- const relativePath = hostname + pathname;
419
- return resolve(relativePath);
420
- }
421
-
422
- // Absolute path (file:///absolute/path)
423
- return pathname;
424
- };
425
-
426
- const retrieve = async (uri: string): Promise<Buffer> => {
427
- const fullPath = resolvePathFromUri(uri);
428
-
429
- const buffer = await readFile(fullPath);
430
-
431
- if (buffer[0] === 0x1f && buffer[1] === 0x8b) {
432
- return gunzipSync(buffer);
433
- }
434
-
435
- return buffer;
436
- };
437
-
438
- const exists = async (uri: string): Promise<boolean> => {
439
- const fullPath = resolvePathFromUri(uri);
440
- return stat(fullPath)
441
- .then(() => true)
442
- .catch(() => false);
443
- };
444
-
445
- const del = async (uri: string): Promise<void> => {
446
- const fullPath = resolvePathFromUri(uri);
447
- await unlink(fullPath);
448
- };
449
-
450
- return {
451
- storeMessageBody,
452
- storeMessageBodyStream,
453
- storeBodyPart,
454
- bodyPartExists,
455
- retrieveBodyPart,
456
- storeOutboxAttachment,
457
- listOutboxAttachments,
458
- listOutboxDraftsWithAttachments,
459
- deleteOutboxAttachments,
460
- deleteOutboxAttachment,
461
- statOutboxAttachment,
462
- retrieveOutboxAttachment,
463
- createOutboxAttachmentUploadUrl,
464
- storeDeduplicated,
465
- storeParsedBody,
466
- retrieveParsedBody,
467
- retrieveMessageBody: async (accountConfigId, accountId, messageId) => {
468
- const key = buildMessageBodyKey(accountConfigId, accountId, messageId);
469
- const fullPath = join(basePath, key);
470
- const buffer = await readFile(fullPath).catch((error: unknown) => {
471
- if (isStorageNotFoundError(error)) return null;
472
- throw error;
473
- });
474
- if (!buffer) return null;
475
- if (buffer[0] === 0x1f && buffer[1] === 0x8b) {
476
- return gunzipSync(buffer);
477
- }
478
- return buffer;
479
- },
480
- retrieveMessageBodyStream: async (
481
- accountConfigId,
482
- accountId,
483
- messageId,
484
- ) => {
485
- const key = buildMessageBodyKey(accountConfigId, accountId, messageId);
486
- const fullPath = join(basePath, key);
487
- const buffer = await readFile(fullPath).catch((error: unknown) => {
488
- if (isStorageNotFoundError(error)) return null;
489
- throw error;
490
- });
491
- if (!buffer) return null;
492
- if (buffer[0] === 0x1f && buffer[1] === 0x8b) {
493
- return Readable.from(buffer).pipe(createGunzip());
494
- }
495
- return Readable.from(buffer);
496
- },
497
- retrieve,
498
- exists,
499
- delete: del,
500
- storeExportArchiveStream: async (
501
- accountConfigId,
502
- exportRequestId,
503
- body,
504
- ) => {
505
- const key = buildExportArchiveKey(accountConfigId, exportRequestId);
506
- const fullPath = join(basePath, key);
507
- await mkdir(dirname(fullPath), { recursive: true });
508
- await pipeline(body, createWriteStream(fullPath));
509
- return key;
510
- },
511
- getPresignedDownloadUrl: (_key: string, _expiresInSeconds: number) => {
512
- throw new Error(
513
- "getPresignedDownloadUrl is not supported on the filesystem backend",
514
- );
515
- },
516
- };
517
- };