@remit/storage-service 0.0.11 → 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.
@@ -0,0 +1,12 @@
1
+ import type { StorageService } from "../storage.js";
2
+ /**
3
+ * Where a minted upload URL points, and what signs it. Absent on a process that
4
+ * only reads storage; a mint then fails loud rather than handing out a URL
5
+ * nothing can verify.
6
+ */
7
+ export interface FilesystemUploadUrlConfig {
8
+ /** Public origin of the deployment serving the upload route. */
9
+ origin: string;
10
+ signingSecret: string;
11
+ }
12
+ export declare const createFilesystemStorageService: (basePath: string, uploadUrls?: FilesystemUploadUrlConfig) => StorageService;
@@ -0,0 +1,316 @@
1
+ import { createHash } from "node:crypto";
2
+ import { createWriteStream } from "node:fs";
3
+ import { mkdir, readdir, readFile, rm, stat, unlink, writeFile, } from "node:fs/promises";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { PassThrough, Readable } from "node:stream";
6
+ import { pipeline } from "node:stream/promises";
7
+ import { createGunzip, createGzip, gunzipSync, gzipSync } from "node:zlib";
8
+ import { ContentEncoding, StorageType } from "@remit/domain-enums";
9
+ import { UPLOAD_ROUTE_PREFIX, UPLOAD_URL_SIGNING_LABEL, } from "../outbox-upload-url.js";
10
+ import { signStoragePath } from "../signed-path.js";
11
+ import { buildBodyPartKey, buildDeduplicatedKey, buildExportArchiveKey, buildMessageBodyKey, buildOutboxAttachmentKey, buildOutboxAttachmentPrefix, buildParsedBodyKey, computeChecksum, isStorageNotFoundError, } from "../storage.js";
12
+ export const createFilesystemStorageService = (basePath, uploadUrls) => {
13
+ const storeInternal = async (params) => {
14
+ const { key, content, compress = true } = params;
15
+ const checksumSha256 = computeChecksum(content);
16
+ const contentEncoding = compress
17
+ ? ContentEncoding.Gzip
18
+ : ContentEncoding.None;
19
+ const body = compress ? gzipSync(content) : content;
20
+ const fullPath = join(basePath, key);
21
+ await mkdir(dirname(fullPath), { recursive: true });
22
+ await writeFile(fullPath, body);
23
+ return {
24
+ uri: `file://${fullPath}`,
25
+ storageType: StorageType.Filesystem,
26
+ storageLocation: basePath,
27
+ storageKey: key,
28
+ sizeBytes: body.length,
29
+ checksumSha256,
30
+ contentEncoding,
31
+ };
32
+ };
33
+ const storeMessageBody = (params) => {
34
+ const { accountConfigId, accountId, messageId, content } = params;
35
+ return storeInternal({
36
+ key: buildMessageBodyKey(accountConfigId, accountId, messageId),
37
+ content,
38
+ });
39
+ };
40
+ const storeMessageBodyStream = async (params) => {
41
+ const { accountConfigId, accountId, messageId, content } = params;
42
+ const key = buildMessageBodyKey(accountConfigId, accountId, messageId);
43
+ const fullPath = join(basePath, key);
44
+ await mkdir(dirname(fullPath), { recursive: true });
45
+ const hash = createHash("sha256");
46
+ const hashTap = new PassThrough();
47
+ hashTap.on("data", (chunk) => hash.update(chunk));
48
+ await pipeline(content, hashTap, createGzip(), createWriteStream(fullPath));
49
+ const { size } = await stat(fullPath);
50
+ return {
51
+ uri: `file://${fullPath}`,
52
+ storageType: StorageType.Filesystem,
53
+ storageLocation: basePath,
54
+ storageKey: key,
55
+ sizeBytes: size,
56
+ checksumSha256: hash.digest("hex"),
57
+ contentEncoding: ContentEncoding.Gzip,
58
+ };
59
+ };
60
+ const storeBodyPart = (params) => {
61
+ const { accountConfigId, accountId, messageId, partPath, content } = params;
62
+ return storeInternal({
63
+ key: buildBodyPartKey(accountConfigId, accountId, messageId, partPath),
64
+ content,
65
+ });
66
+ };
67
+ const bodyPartExists = (accountConfigId, accountId, messageId, partPath) => {
68
+ const key = buildBodyPartKey(accountConfigId, accountId, messageId, partPath);
69
+ const fullPath = join(basePath, key);
70
+ return stat(fullPath)
71
+ .then(() => true)
72
+ .catch(() => false);
73
+ };
74
+ const retrieveBodyPart = async (accountConfigId, accountId, messageId, partPath) => {
75
+ const key = buildBodyPartKey(accountConfigId, accountId, messageId, partPath);
76
+ const fullPath = join(basePath, key);
77
+ const buffer = await readFile(fullPath).catch((error) => {
78
+ if (isStorageNotFoundError(error))
79
+ return null;
80
+ throw error;
81
+ });
82
+ if (!buffer)
83
+ return null;
84
+ return buffer[0] === 0x1f && buffer[1] === 0x8b
85
+ ? gunzipSync(buffer)
86
+ : buffer;
87
+ };
88
+ const storeOutboxAttachment = (params) => {
89
+ const { accountConfigId, accountId, outboxMessageId, outboxAttachmentId, content, } = params;
90
+ return storeInternal({
91
+ key: buildOutboxAttachmentKey(accountConfigId, accountId, outboxMessageId, outboxAttachmentId),
92
+ content,
93
+ compress: false,
94
+ });
95
+ };
96
+ const listOutboxAttachments = async (accountConfigId, accountId, outboxMessageId) => {
97
+ const prefix = buildOutboxAttachmentPrefix(accountConfigId, accountId, outboxMessageId);
98
+ const entries = await readdir(join(basePath, prefix)).catch((error) => {
99
+ if (isStorageNotFoundError(error))
100
+ return [];
101
+ throw error;
102
+ });
103
+ const items = [];
104
+ for (const name of entries) {
105
+ const { size } = await stat(join(basePath, prefix, name));
106
+ items.push({
107
+ outboxAttachmentId: name,
108
+ key: `${prefix}${name}`,
109
+ sizeBytes: size,
110
+ });
111
+ }
112
+ return items;
113
+ };
114
+ const listOutboxDraftsWithAttachments = async (accountConfigId, accountId) => {
115
+ // An account that has never held an attachment has no directory, and
116
+ // that is genuinely nothing to do. A storage root that is not there at
117
+ // all is a different thing entirely — a process that cannot see the
118
+ // volume it is meant to be collecting from — and it must not read as an
119
+ // empty answer. Unmounted, the sweep would report every account swept
120
+ // and collect nothing, forever.
121
+ const rootExists = await stat(basePath)
122
+ .then(() => true)
123
+ .catch((error) => {
124
+ if (isStorageNotFoundError(error))
125
+ return false;
126
+ throw error;
127
+ });
128
+ if (!rootExists) {
129
+ throw new Error(`storage root ${basePath} does not exist; this process cannot see the storage it was asked to read`);
130
+ }
131
+ const root = join(basePath, `accounts/${accountConfigId}/${accountId}/outbox`);
132
+ const entries = await readdir(root, { withFileTypes: true }).catch((error) => {
133
+ if (isStorageNotFoundError(error))
134
+ return [];
135
+ throw error;
136
+ });
137
+ return entries
138
+ .filter((entry) => entry.isDirectory())
139
+ .map((entry) => entry.name);
140
+ };
141
+ const deleteOutboxAttachment = async (accountConfigId, accountId, outboxMessageId, outboxAttachmentId) => {
142
+ const prefix = buildOutboxAttachmentPrefix(accountConfigId, accountId, outboxMessageId);
143
+ const entries = await readdir(join(basePath, prefix)).catch((error) => {
144
+ if (isStorageNotFoundError(error))
145
+ return [];
146
+ throw error;
147
+ });
148
+ for (const name of entries) {
149
+ if (name !== outboxAttachmentId)
150
+ continue;
151
+ await rm(join(basePath, prefix, name), { force: true });
152
+ }
153
+ };
154
+ const statOutboxAttachment = async (accountConfigId, accountId, outboxMessageId, outboxAttachmentId) => {
155
+ const fullPath = join(basePath, buildOutboxAttachmentKey(accountConfigId, accountId, outboxMessageId, outboxAttachmentId));
156
+ const stats = await stat(fullPath).catch((error) => {
157
+ if (isStorageNotFoundError(error))
158
+ return null;
159
+ throw error;
160
+ });
161
+ return stats ? { sizeBytes: stats.size } : null;
162
+ };
163
+ const retrieveOutboxAttachment = (accountConfigId, accountId, outboxMessageId, outboxAttachmentId) => readFile(join(basePath, buildOutboxAttachmentKey(accountConfigId, accountId, outboxMessageId, outboxAttachmentId))).catch((error) => {
164
+ if (isStorageNotFoundError(error))
165
+ return null;
166
+ throw error;
167
+ });
168
+ // This backend has no presigned anything, so the URL addresses the
169
+ // deployment's own upload route. Its authority is the same HMAC the read side
170
+ // uses on /content, under the write label, covering the storage key, the
171
+ // expiry and the exact byte count — a URL for one attachment, for a while,
172
+ // for one size.
173
+ const createOutboxAttachmentUploadUrl = async (params) => {
174
+ if (!uploadUrls) {
175
+ throw new Error("the filesystem storage backend cannot mint an upload URL without an origin and a signing secret");
176
+ }
177
+ const key = buildOutboxAttachmentKey(params.accountConfigId, params.accountId, params.outboxMessageId, params.outboxAttachmentId);
178
+ const sig = signStoragePath(uploadUrls.signingSecret, UPLOAD_URL_SIGNING_LABEL, key, [params.expiresAt, params.sizeBytes]);
179
+ const origin = uploadUrls.origin.replace(/\/+$/, "");
180
+ const query = new URLSearchParams({
181
+ exp: String(params.expiresAt),
182
+ max: String(params.sizeBytes),
183
+ sig,
184
+ });
185
+ return {
186
+ uploadUrl: `${origin}${UPLOAD_ROUTE_PREFIX}${key}?${query.toString()}`,
187
+ };
188
+ };
189
+ const deleteOutboxAttachments = async (accountConfigId, accountId, outboxMessageId) => {
190
+ const dirPath = join(basePath, buildOutboxAttachmentPrefix(accountConfigId, accountId, outboxMessageId));
191
+ await rm(dirPath, { recursive: true, force: true });
192
+ };
193
+ const storeDeduplicated = (params) => {
194
+ const { accountConfigId, accountId, content } = params;
195
+ const checksumSha256 = computeChecksum(content);
196
+ return storeInternal({
197
+ key: buildDeduplicatedKey(accountConfigId, accountId, checksumSha256),
198
+ content,
199
+ });
200
+ };
201
+ const storeParsedBody = (params) => {
202
+ const { accountConfigId, accountId, messageId, parsed } = params;
203
+ const content = Buffer.from(JSON.stringify(parsed), "utf8");
204
+ return storeInternal({
205
+ key: buildParsedBodyKey(accountConfigId, accountId, messageId),
206
+ content,
207
+ });
208
+ };
209
+ const retrieveParsedBody = async (accountConfigId, accountId, messageId) => {
210
+ const key = buildParsedBodyKey(accountConfigId, accountId, messageId);
211
+ const fullPath = join(basePath, key);
212
+ const buffer = await readFile(fullPath).catch((error) => {
213
+ if (isStorageNotFoundError(error))
214
+ return null;
215
+ throw error;
216
+ });
217
+ if (!buffer)
218
+ return null;
219
+ const decoded = buffer[0] === 0x1f && buffer[1] === 0x8b ? gunzipSync(buffer) : buffer;
220
+ return JSON.parse(decoded.toString("utf8"));
221
+ };
222
+ // Resolve path from URI, handling both absolute and relative paths
223
+ // Note: URIs like file://.remit/storage/... parse incorrectly - '.remit' becomes hostname
224
+ // We reconstruct the original path by combining hostname + pathname
225
+ const resolvePathFromUri = (uri) => {
226
+ const url = new URL(uri);
227
+ const hostname = url.hostname;
228
+ const pathname = url.pathname;
229
+ // If hostname exists, it was part of a relative path (e.g., file://.remit/... -> hostname='.remit')
230
+ if (hostname) {
231
+ const relativePath = hostname + pathname;
232
+ return resolve(relativePath);
233
+ }
234
+ // Absolute path (file:///absolute/path)
235
+ return pathname;
236
+ };
237
+ const retrieve = async (uri) => {
238
+ const fullPath = resolvePathFromUri(uri);
239
+ const buffer = await readFile(fullPath);
240
+ if (buffer[0] === 0x1f && buffer[1] === 0x8b) {
241
+ return gunzipSync(buffer);
242
+ }
243
+ return buffer;
244
+ };
245
+ const exists = async (uri) => {
246
+ const fullPath = resolvePathFromUri(uri);
247
+ return stat(fullPath)
248
+ .then(() => true)
249
+ .catch(() => false);
250
+ };
251
+ const del = async (uri) => {
252
+ const fullPath = resolvePathFromUri(uri);
253
+ await unlink(fullPath);
254
+ };
255
+ return {
256
+ storeMessageBody,
257
+ storeMessageBodyStream,
258
+ storeBodyPart,
259
+ bodyPartExists,
260
+ retrieveBodyPart,
261
+ storeOutboxAttachment,
262
+ listOutboxAttachments,
263
+ listOutboxDraftsWithAttachments,
264
+ deleteOutboxAttachments,
265
+ deleteOutboxAttachment,
266
+ statOutboxAttachment,
267
+ retrieveOutboxAttachment,
268
+ createOutboxAttachmentUploadUrl,
269
+ storeDeduplicated,
270
+ storeParsedBody,
271
+ retrieveParsedBody,
272
+ retrieveMessageBody: async (accountConfigId, accountId, messageId) => {
273
+ const key = buildMessageBodyKey(accountConfigId, accountId, messageId);
274
+ const fullPath = join(basePath, key);
275
+ const buffer = await readFile(fullPath).catch((error) => {
276
+ if (isStorageNotFoundError(error))
277
+ return null;
278
+ throw error;
279
+ });
280
+ if (!buffer)
281
+ return null;
282
+ if (buffer[0] === 0x1f && buffer[1] === 0x8b) {
283
+ return gunzipSync(buffer);
284
+ }
285
+ return buffer;
286
+ },
287
+ retrieveMessageBodyStream: async (accountConfigId, accountId, messageId) => {
288
+ const key = buildMessageBodyKey(accountConfigId, accountId, messageId);
289
+ const fullPath = join(basePath, key);
290
+ const buffer = await readFile(fullPath).catch((error) => {
291
+ if (isStorageNotFoundError(error))
292
+ return null;
293
+ throw error;
294
+ });
295
+ if (!buffer)
296
+ return null;
297
+ if (buffer[0] === 0x1f && buffer[1] === 0x8b) {
298
+ return Readable.from(buffer).pipe(createGunzip());
299
+ }
300
+ return Readable.from(buffer);
301
+ },
302
+ retrieve,
303
+ exists,
304
+ delete: del,
305
+ storeExportArchiveStream: async (accountConfigId, exportRequestId, body) => {
306
+ const key = buildExportArchiveKey(accountConfigId, exportRequestId);
307
+ const fullPath = join(basePath, key);
308
+ await mkdir(dirname(fullPath), { recursive: true });
309
+ await pipeline(body, createWriteStream(fullPath));
310
+ return key;
311
+ },
312
+ getPresignedDownloadUrl: (_key, _expiresInSeconds) => {
313
+ throw new Error("getPresignedDownloadUrl is not supported on the filesystem backend");
314
+ },
315
+ };
316
+ };
@@ -0,0 +1,3 @@
1
+ import { type S3Client } from "@aws-sdk/client-s3";
2
+ import type { StorageService } from "../storage.js";
3
+ export declare const createS3StorageService: (client: S3Client, bucketName: string) => StorageService;