@remit/storage-service 0.0.12 → 0.0.14
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/backends/filesystem.d.ts +12 -0
- package/dist/backends/filesystem.js +296 -0
- package/dist/backends/s3.d.ts +3 -0
- package/dist/backends/s3.js +370 -0
- package/dist/filesystem.js +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/outbox-sweep.d.ts +33 -0
- package/dist/outbox-sweep.js +37 -0
- package/dist/outbox-upload-url.d.ts +44 -0
- package/{src/outbox-upload-url.ts → dist/outbox-upload-url.js} +20 -50
- package/dist/s3.d.ts +3 -0
- package/dist/s3.js +28 -0
- package/dist/signed-path.d.ts +14 -0
- package/dist/signed-path.js +46 -0
- package/dist/storage.d.ts +241 -0
- package/dist/storage.js +219 -0
- package/dist/uri.d.ts +9 -0
- package/dist/uri.js +28 -0
- package/package.json +51 -46
- package/src/backends/filesystem.ts +0 -517
- package/src/backends/s3.test.ts +0 -447
- package/src/backends/s3.ts +0 -624
- package/src/index.ts +0 -53
- package/src/outbox-attachment-retrieve.test.ts +0 -56
- package/src/outbox-sweep.test.ts +0 -196
- package/src/outbox-sweep.ts +0 -103
- package/src/outbox-upload-url.test.ts +0 -290
- package/src/s3.ts +0 -36
- package/src/signed-path.ts +0 -86
- package/src/storage.test.ts +0 -646
- package/src/storage.ts +0 -699
- package/src/uri.ts +0 -47
- package/tsconfig.json +0 -8
- /package/{src/filesystem.ts → dist/filesystem.d.ts} +0 -0
package/dist/storage.js
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { Readable } from "node:stream";
|
|
3
|
+
import { ContentEncoding, StorageType } from "@remit/domain-enums";
|
|
4
|
+
// Path builders - centralized path formatting per RFC 011 + #224.
|
|
5
|
+
// The outer segment is `accountConfigId` (Cognito-derived tenant scope) so
|
|
6
|
+
// the edge can verify cross-tenant isolation at the URL prefix without
|
|
7
|
+
// loading any database record. The inner `accountId` distinguishes between
|
|
8
|
+
// multiple IMAP-account records owned by the same tenant.
|
|
9
|
+
export const buildMessageBodyKey = (accountConfigId, accountId, messageId) => `accounts/${accountConfigId}/${accountId}/messages/${messageId}/body.eml`;
|
|
10
|
+
export const buildParsedBodyKey = (accountConfigId, accountId, messageId) => `accounts/${accountConfigId}/${accountId}/messages/${messageId}/parsed.json.gz`;
|
|
11
|
+
export const buildBodyPartKey = (accountConfigId, accountId, messageId, partPath) => `accounts/${accountConfigId}/${accountId}/messages/${messageId}/parts/${partPath}`;
|
|
12
|
+
export const buildOutboxAttachmentPrefix = (accountConfigId, accountId, outboxMessageId) => `accounts/${accountConfigId}/${accountId}/outbox/${outboxMessageId}/attachments/`;
|
|
13
|
+
export const buildOutboxAttachmentKey = (accountConfigId, accountId, outboxMessageId, outboxAttachmentId) => `${buildOutboxAttachmentPrefix(accountConfigId, accountId, outboxMessageId)}${outboxAttachmentId}`;
|
|
14
|
+
// Every segment is a generated id, so the shape is closed rather than
|
|
15
|
+
// "anything without a slash": `..` matches `[^/]+` and the write side joins
|
|
16
|
+
// this key onto a filesystem root. The read side has `resolveContentPath` for
|
|
17
|
+
// the same reason (#310 review P1) — a signature should not be the only thing
|
|
18
|
+
// standing between a URL and the storage root.
|
|
19
|
+
const OUTBOX_ATTACHMENT_KEY = /^accounts\/([A-Za-z0-9_-]+)\/([A-Za-z0-9_-]+)\/outbox\/([A-Za-z0-9_-]+)\/attachments\/([A-Za-z0-9_-]+)$/;
|
|
20
|
+
/**
|
|
21
|
+
* Read the ids back out of an attachment key — the inverse of
|
|
22
|
+
* `buildOutboxAttachmentKey`, for the upload route, which is handed a path and
|
|
23
|
+
* has to work out what it addresses. Returns null on anything else, including a
|
|
24
|
+
* reservation key: bytes are never written to one.
|
|
25
|
+
*/
|
|
26
|
+
export const parseOutboxAttachmentKey = (storageKey) => {
|
|
27
|
+
const match = storageKey.match(OUTBOX_ATTACHMENT_KEY);
|
|
28
|
+
if (!match)
|
|
29
|
+
return null;
|
|
30
|
+
return {
|
|
31
|
+
accountConfigId: match[1],
|
|
32
|
+
accountId: match[2],
|
|
33
|
+
outboxMessageId: match[3],
|
|
34
|
+
outboxAttachmentId: match[4],
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
export const buildDeduplicatedKey = (accountConfigId, accountId, checksumSha256) => `accounts/${accountConfigId}/${accountId}/dedup/${checksumSha256.slice(0, 2)}/${checksumSha256}`;
|
|
38
|
+
/**
|
|
39
|
+
* Parse the account/message ids out of a `/content/*` storage key such as
|
|
40
|
+
* `accounts/{accountConfigId}/{accountId}/messages/{messageId}/parts/1.2`
|
|
41
|
+
* (the inverse of `buildMessageBodyKey`/`buildParsedBodyKey`/`buildBodyPartKey`
|
|
42
|
+
* above). Returns null when the shape doesn't match.
|
|
43
|
+
*/
|
|
44
|
+
export const parseContentStorageKey = (storageKey) => {
|
|
45
|
+
const match = storageKey.match(/^accounts\/([^/]+)\/([^/]+)\/messages\/([^/]+)\//);
|
|
46
|
+
if (!match)
|
|
47
|
+
return null;
|
|
48
|
+
return {
|
|
49
|
+
accountConfigId: match[1],
|
|
50
|
+
accountId: match[2],
|
|
51
|
+
messageId: match[3],
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
export const computeChecksum = (content) => createHash("sha256").update(content).digest("hex");
|
|
55
|
+
export const buildExportArchiveKey = (accountConfigId, exportRequestId) => `exports/${accountConfigId}/${exportRequestId}/export.zip`;
|
|
56
|
+
export const isStorageNotFoundError = (error) => {
|
|
57
|
+
if (typeof error !== "object" || error === null)
|
|
58
|
+
return false;
|
|
59
|
+
const obj = error;
|
|
60
|
+
if (obj.name === "NoSuchKey")
|
|
61
|
+
return true;
|
|
62
|
+
if (obj.Code === "NoSuchKey")
|
|
63
|
+
return true;
|
|
64
|
+
if (obj.code === "ENOENT")
|
|
65
|
+
return true;
|
|
66
|
+
return false;
|
|
67
|
+
};
|
|
68
|
+
export const createMockStorageService = () => {
|
|
69
|
+
const storage = new Map();
|
|
70
|
+
const storeInternal = (key, content) => {
|
|
71
|
+
const checksumSha256 = computeChecksum(content);
|
|
72
|
+
const uri = `mock://${key}`;
|
|
73
|
+
storage.set(uri, content);
|
|
74
|
+
return {
|
|
75
|
+
uri,
|
|
76
|
+
storageType: StorageType.Filesystem,
|
|
77
|
+
storageLocation: "mock",
|
|
78
|
+
storageKey: key,
|
|
79
|
+
sizeBytes: content.length,
|
|
80
|
+
checksumSha256,
|
|
81
|
+
contentEncoding: ContentEncoding.None,
|
|
82
|
+
};
|
|
83
|
+
};
|
|
84
|
+
const storeMessageBody = async (params) => {
|
|
85
|
+
const { accountConfigId, accountId, messageId, content } = params;
|
|
86
|
+
return storeInternal(buildMessageBodyKey(accountConfigId, accountId, messageId), content);
|
|
87
|
+
};
|
|
88
|
+
const storeBodyPart = async (params) => {
|
|
89
|
+
const { accountConfigId, accountId, messageId, partPath, content } = params;
|
|
90
|
+
return storeInternal(buildBodyPartKey(accountConfigId, accountId, messageId, partPath), content);
|
|
91
|
+
};
|
|
92
|
+
const storeOutboxAttachment = async (params) => {
|
|
93
|
+
const { accountConfigId, accountId, outboxMessageId, outboxAttachmentId, content, } = params;
|
|
94
|
+
return storeInternal(buildOutboxAttachmentKey(accountConfigId, accountId, outboxMessageId, outboxAttachmentId), content);
|
|
95
|
+
};
|
|
96
|
+
const listOutboxAttachments = async (accountConfigId, accountId, outboxMessageId) => {
|
|
97
|
+
const prefix = `mock://${buildOutboxAttachmentPrefix(accountConfigId, accountId, outboxMessageId)}`;
|
|
98
|
+
const items = [];
|
|
99
|
+
for (const [uri, content] of storage.entries()) {
|
|
100
|
+
if (!uri.startsWith(prefix))
|
|
101
|
+
continue;
|
|
102
|
+
items.push({
|
|
103
|
+
outboxAttachmentId: uri.slice(prefix.length),
|
|
104
|
+
key: uri.slice("mock://".length),
|
|
105
|
+
sizeBytes: content.length,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return items;
|
|
109
|
+
};
|
|
110
|
+
const listOutboxDraftsWithAttachments = async (accountConfigId, accountId) => {
|
|
111
|
+
const root = `mock://accounts/${accountConfigId}/${accountId}/outbox/`;
|
|
112
|
+
const drafts = new Set();
|
|
113
|
+
for (const uri of storage.keys()) {
|
|
114
|
+
if (!uri.startsWith(root))
|
|
115
|
+
continue;
|
|
116
|
+
const rest = uri.slice(root.length);
|
|
117
|
+
const slash = rest.indexOf("/");
|
|
118
|
+
if (slash > 0)
|
|
119
|
+
drafts.add(rest.slice(0, slash));
|
|
120
|
+
}
|
|
121
|
+
return [...drafts];
|
|
122
|
+
};
|
|
123
|
+
const deleteOutboxAttachment = async (accountConfigId, accountId, outboxMessageId, outboxAttachmentId) => {
|
|
124
|
+
storage.delete(`mock://${buildOutboxAttachmentKey(accountConfigId, accountId, outboxMessageId, outboxAttachmentId)}`);
|
|
125
|
+
};
|
|
126
|
+
const statOutboxAttachment = async (accountConfigId, accountId, outboxMessageId, outboxAttachmentId) => {
|
|
127
|
+
const content = storage.get(`mock://${buildOutboxAttachmentKey(accountConfigId, accountId, outboxMessageId, outboxAttachmentId)}`);
|
|
128
|
+
return content ? { sizeBytes: content.length } : null;
|
|
129
|
+
};
|
|
130
|
+
const retrieveOutboxAttachment = async (accountConfigId, accountId, outboxMessageId, outboxAttachmentId) => storage.get(`mock://${buildOutboxAttachmentKey(accountConfigId, accountId, outboxMessageId, outboxAttachmentId)}`) ?? null;
|
|
131
|
+
const createOutboxAttachmentUploadUrl = async (params) => ({
|
|
132
|
+
uploadUrl: `mock://upload/${buildOutboxAttachmentKey(params.accountConfigId, params.accountId, params.outboxMessageId, params.outboxAttachmentId)}?max=${params.sizeBytes}&exp=${params.expiresAt}`,
|
|
133
|
+
});
|
|
134
|
+
const deleteOutboxAttachments = async (accountConfigId, accountId, outboxMessageId) => {
|
|
135
|
+
const prefix = `mock://${buildOutboxAttachmentPrefix(accountConfigId, accountId, outboxMessageId)}`;
|
|
136
|
+
for (const uri of [...storage.keys()]) {
|
|
137
|
+
if (uri.startsWith(prefix))
|
|
138
|
+
storage.delete(uri);
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
const storeDeduplicated = async (params) => {
|
|
142
|
+
const { accountConfigId, accountId, content } = params;
|
|
143
|
+
const checksumSha256 = computeChecksum(content);
|
|
144
|
+
return storeInternal(buildDeduplicatedKey(accountConfigId, accountId, checksumSha256), content);
|
|
145
|
+
};
|
|
146
|
+
const storeParsedBody = async (params) => {
|
|
147
|
+
const { accountConfigId, accountId, messageId, parsed } = params;
|
|
148
|
+
const content = Buffer.from(JSON.stringify(parsed), "utf8");
|
|
149
|
+
return storeInternal(buildParsedBodyKey(accountConfigId, accountId, messageId), content);
|
|
150
|
+
};
|
|
151
|
+
const retrieveParsedBody = async (accountConfigId, accountId, messageId) => {
|
|
152
|
+
const uri = `mock://${buildParsedBodyKey(accountConfigId, accountId, messageId)}`;
|
|
153
|
+
const content = storage.get(uri);
|
|
154
|
+
if (!content)
|
|
155
|
+
return null;
|
|
156
|
+
return JSON.parse(content.toString("utf8"));
|
|
157
|
+
};
|
|
158
|
+
const retrieveBodyPart = async (accountConfigId, accountId, messageId, partPath) => {
|
|
159
|
+
const uri = `mock://${buildBodyPartKey(accountConfigId, accountId, messageId, partPath)}`;
|
|
160
|
+
return storage.get(uri) ?? null;
|
|
161
|
+
};
|
|
162
|
+
const retrieveMessageBody = async (accountConfigId, accountId, messageId) => {
|
|
163
|
+
const uri = `mock://${buildMessageBodyKey(accountConfigId, accountId, messageId)}`;
|
|
164
|
+
return storage.get(uri) ?? null;
|
|
165
|
+
};
|
|
166
|
+
const retrieveMessageBodyStream = async (accountConfigId, accountId, messageId) => {
|
|
167
|
+
const uri = `mock://${buildMessageBodyKey(accountConfigId, accountId, messageId)}`;
|
|
168
|
+
const content = storage.get(uri);
|
|
169
|
+
if (!content)
|
|
170
|
+
return null;
|
|
171
|
+
return Readable.from(content);
|
|
172
|
+
};
|
|
173
|
+
const bodyPartExists = async (accountConfigId, accountId, messageId, partPath) => {
|
|
174
|
+
const uri = `mock://${buildBodyPartKey(accountConfigId, accountId, messageId, partPath)}`;
|
|
175
|
+
return storage.has(uri);
|
|
176
|
+
};
|
|
177
|
+
return {
|
|
178
|
+
storeMessageBody,
|
|
179
|
+
storeBodyPart,
|
|
180
|
+
bodyPartExists,
|
|
181
|
+
retrieveBodyPart,
|
|
182
|
+
storeOutboxAttachment,
|
|
183
|
+
listOutboxAttachments,
|
|
184
|
+
listOutboxDraftsWithAttachments,
|
|
185
|
+
deleteOutboxAttachments,
|
|
186
|
+
deleteOutboxAttachment,
|
|
187
|
+
statOutboxAttachment,
|
|
188
|
+
retrieveOutboxAttachment,
|
|
189
|
+
createOutboxAttachmentUploadUrl,
|
|
190
|
+
storeDeduplicated,
|
|
191
|
+
storeParsedBody,
|
|
192
|
+
retrieveParsedBody,
|
|
193
|
+
retrieveMessageBody,
|
|
194
|
+
retrieveMessageBodyStream,
|
|
195
|
+
retrieve: async (uri) => {
|
|
196
|
+
const content = storage.get(uri);
|
|
197
|
+
if (!content) {
|
|
198
|
+
throw Object.assign(new Error(`Not found: ${uri}`), {
|
|
199
|
+
name: "NoSuchKey",
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
return content;
|
|
203
|
+
},
|
|
204
|
+
exists: async (uri) => storage.has(uri),
|
|
205
|
+
delete: async (uri) => {
|
|
206
|
+
storage.delete(uri);
|
|
207
|
+
},
|
|
208
|
+
storeExportArchiveStream: async (accountConfigId, exportRequestId, body) => {
|
|
209
|
+
const key = buildExportArchiveKey(accountConfigId, exportRequestId);
|
|
210
|
+
const chunks = [];
|
|
211
|
+
for await (const chunk of body) {
|
|
212
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
213
|
+
}
|
|
214
|
+
storage.set(`mock://${key}`, Buffer.concat(chunks));
|
|
215
|
+
return key;
|
|
216
|
+
},
|
|
217
|
+
getPresignedDownloadUrl: async (key, _expiresInSeconds) => `mock://presigned/${key}`,
|
|
218
|
+
};
|
|
219
|
+
};
|
package/dist/uri.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { StorageType } from "@remit/domain-enums";
|
|
2
|
+
export type StorageTypeValue = (typeof StorageType)[keyof typeof StorageType];
|
|
3
|
+
export interface ParsedStorageUri {
|
|
4
|
+
storageType: StorageTypeValue;
|
|
5
|
+
storageLocation: string;
|
|
6
|
+
storageKey: string;
|
|
7
|
+
}
|
|
8
|
+
export declare const parseStorageUri: (uri: string) => ParsedStorageUri;
|
|
9
|
+
export declare const buildStorageUri: (storageType: StorageTypeValue, storageLocation: string, storageKey: string) => string;
|
package/dist/uri.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { StorageType } from "@remit/domain-enums";
|
|
2
|
+
export const parseStorageUri = (uri) => {
|
|
3
|
+
const url = new URL(uri);
|
|
4
|
+
if (url.protocol === "s3:") {
|
|
5
|
+
return {
|
|
6
|
+
storageType: StorageType.S3,
|
|
7
|
+
storageLocation: url.hostname,
|
|
8
|
+
storageKey: url.pathname.slice(1),
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
if (url.protocol === "file:") {
|
|
12
|
+
return {
|
|
13
|
+
storageType: StorageType.Filesystem,
|
|
14
|
+
storageLocation: "",
|
|
15
|
+
storageKey: url.pathname,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
throw new Error(`Unsupported storage URI scheme: ${url.protocol}`);
|
|
19
|
+
};
|
|
20
|
+
export const buildStorageUri = (storageType, storageLocation, storageKey) => {
|
|
21
|
+
if (storageType === StorageType.S3) {
|
|
22
|
+
return `s3://${storageLocation}/${storageKey}`;
|
|
23
|
+
}
|
|
24
|
+
if (storageType === StorageType.Filesystem) {
|
|
25
|
+
return `file://${storageLocation}/${storageKey}`;
|
|
26
|
+
}
|
|
27
|
+
throw new Error(`Unsupported storage type: ${storageType}`);
|
|
28
|
+
};
|
package/package.json
CHANGED
|
@@ -1,48 +1,53 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
2
|
+
"name": "@remit/storage-service",
|
|
3
|
+
"version": "0.0.14",
|
|
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
|
}
|