@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 +5 -1
- package/src/backends/filesystem.ts +203 -0
- package/src/backends/s3.test.ts +144 -6
- package/src/backends/s3.ts +214 -0
- package/src/index.ts +26 -0
- package/src/outbox-sweep.test.ts +196 -0
- package/src/outbox-sweep.ts +103 -0
- package/src/outbox-upload-url.test.ts +290 -0
- package/src/outbox-upload-url.ts +78 -0
- package/src/s3.ts +17 -1
- package/src/signed-path.ts +86 -0
- package/src/storage.test.ts +136 -0
- package/src/storage.ts +261 -2
package/src/storage.ts
CHANGED
|
@@ -50,6 +50,52 @@ export interface StoreBodyPartParams {
|
|
|
50
50
|
contentType?: string;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Parameters for storing a file attached to a draft outbox message.
|
|
55
|
+
*
|
|
56
|
+
* Deliberately not the deduplicated path: a content-addressed object belongs to
|
|
57
|
+
* no single draft, so nothing may delete it when the draft goes. A per-draft key
|
|
58
|
+
* dies with the draft.
|
|
59
|
+
*/
|
|
60
|
+
export interface StoreOutboxAttachmentParams {
|
|
61
|
+
accountConfigId: string;
|
|
62
|
+
accountId: string;
|
|
63
|
+
outboxMessageId: string;
|
|
64
|
+
outboxAttachmentId: string;
|
|
65
|
+
content: Buffer;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* One uploaded object under a draft's attachment prefix. Reservations are not
|
|
70
|
+
* here — they live in the ledger, which is the only thing that can say whether
|
|
71
|
+
* an object present in storage is an attachment or garbage.
|
|
72
|
+
*/
|
|
73
|
+
export interface OutboxAttachmentListItem {
|
|
74
|
+
outboxAttachmentId: string;
|
|
75
|
+
key: string;
|
|
76
|
+
sizeBytes: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Somewhere for a client to PUT one file, for a while */
|
|
80
|
+
export interface OutboxAttachmentUploadTarget {
|
|
81
|
+
/**
|
|
82
|
+
* Absolute URL the client PUTs raw bytes to. Opaque by design: block storage
|
|
83
|
+
* on one deployment, this deployment's own upload route on another, and a
|
|
84
|
+
* client cannot tell which it was handed.
|
|
85
|
+
*/
|
|
86
|
+
uploadUrl: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface CreateOutboxAttachmentUploadUrlParams {
|
|
90
|
+
accountConfigId: string;
|
|
91
|
+
accountId: string;
|
|
92
|
+
outboxMessageId: string;
|
|
93
|
+
outboxAttachmentId: string;
|
|
94
|
+
/** Exact byte count the upload may carry. */
|
|
95
|
+
sizeBytes: number;
|
|
96
|
+
expiresAt: number;
|
|
97
|
+
}
|
|
98
|
+
|
|
53
99
|
/** Parameters for storing deduplicated content (attachments shared across messages) */
|
|
54
100
|
export interface StoreDeduplicatedParams {
|
|
55
101
|
accountConfigId: string;
|
|
@@ -157,6 +203,80 @@ export interface StorageService {
|
|
|
157
203
|
partPath: string,
|
|
158
204
|
): Promise<Buffer | null>;
|
|
159
205
|
|
|
206
|
+
/**
|
|
207
|
+
* Store a file attached to a draft outbox message, uncompressed: the size on
|
|
208
|
+
* the backend is then the decoded size the per-message cap is expressed in,
|
|
209
|
+
* and `listOutboxAttachments` can total a draft without opening anything.
|
|
210
|
+
*/
|
|
211
|
+
storeOutboxAttachment(
|
|
212
|
+
params: StoreOutboxAttachmentParams,
|
|
213
|
+
): Promise<StorageReference>;
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Every object stored against a draft, paged to the end. Only the sweep reads
|
|
217
|
+
* this, and a sweep that stops early leaves exactly the orphan it was called
|
|
218
|
+
* to collect — so there is no limit to get wrong.
|
|
219
|
+
*/
|
|
220
|
+
listOutboxAttachments(
|
|
221
|
+
accountConfigId: string,
|
|
222
|
+
accountId: string,
|
|
223
|
+
outboxMessageId: string,
|
|
224
|
+
): Promise<OutboxAttachmentListItem[]>;
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Remove every file and reservation stored against a draft. Unlike the read
|
|
228
|
+
* above this takes no limit and pages to the end: a sweep that stops early
|
|
229
|
+
* leaves exactly the orphan it was called to prevent.
|
|
230
|
+
*/
|
|
231
|
+
deleteOutboxAttachments(
|
|
232
|
+
accountConfigId: string,
|
|
233
|
+
accountId: string,
|
|
234
|
+
outboxMessageId: string,
|
|
235
|
+
): Promise<void>;
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Every draft under an account that has attachment objects in storage,
|
|
239
|
+
* including drafts whose row is long gone. The sweep needs the prefixes that
|
|
240
|
+
* exist, not the rows that exist — a discarded draft is invisible to the
|
|
241
|
+
* database and is exactly the case worth collecting.
|
|
242
|
+
*/
|
|
243
|
+
listOutboxDraftsWithAttachments(
|
|
244
|
+
accountConfigId: string,
|
|
245
|
+
accountId: string,
|
|
246
|
+
): Promise<string[]>;
|
|
247
|
+
|
|
248
|
+
/** Remove one attachment object. */
|
|
249
|
+
deleteOutboxAttachment(
|
|
250
|
+
accountConfigId: string,
|
|
251
|
+
accountId: string,
|
|
252
|
+
outboxMessageId: string,
|
|
253
|
+
outboxAttachmentId: string,
|
|
254
|
+
): Promise<void>;
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* The size storage actually holds for an attachment, or null when nothing
|
|
258
|
+
* was uploaded. This is the only number a completion may believe — the
|
|
259
|
+
* client's word for it is not evidence, and on a deployment where the bytes
|
|
260
|
+
* went straight to block storage it is the only reading available.
|
|
261
|
+
*/
|
|
262
|
+
statOutboxAttachment(
|
|
263
|
+
accountConfigId: string,
|
|
264
|
+
accountId: string,
|
|
265
|
+
outboxMessageId: string,
|
|
266
|
+
outboxAttachmentId: string,
|
|
267
|
+
): Promise<{ sizeBytes: number } | null>;
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Mint somewhere to PUT one file. The backend decides what that is — block
|
|
271
|
+
* storage direct, or this deployment's upload route — and the caller never
|
|
272
|
+
* branches on which it got. Whatever it returns must bind the size: an
|
|
273
|
+
* upload that carries more than was reserved has to fail at the thing
|
|
274
|
+
* receiving it, not afterwards.
|
|
275
|
+
*/
|
|
276
|
+
createOutboxAttachmentUploadUrl(
|
|
277
|
+
params: CreateOutboxAttachmentUploadUrlParams,
|
|
278
|
+
): Promise<OutboxAttachmentUploadTarget>;
|
|
279
|
+
|
|
160
280
|
/** Store deduplicated content (content-addressable, for attachments) */
|
|
161
281
|
storeDeduplicated(params: StoreDeduplicatedParams): Promise<StorageReference>;
|
|
162
282
|
|
|
@@ -264,8 +384,8 @@ export interface StorageService {
|
|
|
264
384
|
|
|
265
385
|
// Path builders - centralized path formatting per RFC 011 + #224.
|
|
266
386
|
// The outer segment is `accountConfigId` (Cognito-derived tenant scope) so
|
|
267
|
-
// the
|
|
268
|
-
// loading any
|
|
387
|
+
// the edge can verify cross-tenant isolation at the URL prefix without
|
|
388
|
+
// loading any database record. The inner `accountId` distinguishes between
|
|
269
389
|
// multiple IMAP-account records owned by the same tenant.
|
|
270
390
|
export const buildMessageBodyKey = (
|
|
271
391
|
accountConfigId: string,
|
|
@@ -312,6 +432,54 @@ export const buildExtractedPrefix = (
|
|
|
312
432
|
): string =>
|
|
313
433
|
`accounts/${accountConfigId}/${accountId}/messages/${messageId}/extracted/`;
|
|
314
434
|
|
|
435
|
+
export const buildOutboxAttachmentPrefix = (
|
|
436
|
+
accountConfigId: string,
|
|
437
|
+
accountId: string,
|
|
438
|
+
outboxMessageId: string,
|
|
439
|
+
): string =>
|
|
440
|
+
`accounts/${accountConfigId}/${accountId}/outbox/${outboxMessageId}/attachments/`;
|
|
441
|
+
|
|
442
|
+
export const buildOutboxAttachmentKey = (
|
|
443
|
+
accountConfigId: string,
|
|
444
|
+
accountId: string,
|
|
445
|
+
outboxMessageId: string,
|
|
446
|
+
outboxAttachmentId: string,
|
|
447
|
+
): string =>
|
|
448
|
+
`${buildOutboxAttachmentPrefix(accountConfigId, accountId, outboxMessageId)}${outboxAttachmentId}`;
|
|
449
|
+
|
|
450
|
+
// Every segment is a generated id, so the shape is closed rather than
|
|
451
|
+
// "anything without a slash": `..` matches `[^/]+` and the write side joins
|
|
452
|
+
// this key onto a filesystem root. The read side has `resolveContentPath` for
|
|
453
|
+
// the same reason (#310 review P1) — a signature should not be the only thing
|
|
454
|
+
// standing between a URL and the storage root.
|
|
455
|
+
const OUTBOX_ATTACHMENT_KEY =
|
|
456
|
+
/^accounts\/([A-Za-z0-9_-]+)\/([A-Za-z0-9_-]+)\/outbox\/([A-Za-z0-9_-]+)\/attachments\/([A-Za-z0-9_-]+)$/;
|
|
457
|
+
export interface ParsedOutboxAttachmentKey {
|
|
458
|
+
accountConfigId: string;
|
|
459
|
+
accountId: string;
|
|
460
|
+
outboxMessageId: string;
|
|
461
|
+
outboxAttachmentId: string;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Read the ids back out of an attachment key — the inverse of
|
|
466
|
+
* `buildOutboxAttachmentKey`, for the upload route, which is handed a path and
|
|
467
|
+
* has to work out what it addresses. Returns null on anything else, including a
|
|
468
|
+
* reservation key: bytes are never written to one.
|
|
469
|
+
*/
|
|
470
|
+
export const parseOutboxAttachmentKey = (
|
|
471
|
+
storageKey: string,
|
|
472
|
+
): ParsedOutboxAttachmentKey | null => {
|
|
473
|
+
const match = storageKey.match(OUTBOX_ATTACHMENT_KEY);
|
|
474
|
+
if (!match) return null;
|
|
475
|
+
return {
|
|
476
|
+
accountConfigId: match[1],
|
|
477
|
+
accountId: match[2],
|
|
478
|
+
outboxMessageId: match[3],
|
|
479
|
+
outboxAttachmentId: match[4],
|
|
480
|
+
};
|
|
481
|
+
};
|
|
482
|
+
|
|
315
483
|
export const buildDeduplicatedKey = (
|
|
316
484
|
accountConfigId: string,
|
|
317
485
|
accountId: string,
|
|
@@ -411,6 +579,90 @@ export const createMockStorageService = (): StorageService => {
|
|
|
411
579
|
);
|
|
412
580
|
};
|
|
413
581
|
|
|
582
|
+
const storeOutboxAttachment: StorageService["storeOutboxAttachment"] = async (
|
|
583
|
+
params,
|
|
584
|
+
) => {
|
|
585
|
+
const {
|
|
586
|
+
accountConfigId,
|
|
587
|
+
accountId,
|
|
588
|
+
outboxMessageId,
|
|
589
|
+
outboxAttachmentId,
|
|
590
|
+
content,
|
|
591
|
+
} = params;
|
|
592
|
+
return storeInternal(
|
|
593
|
+
buildOutboxAttachmentKey(
|
|
594
|
+
accountConfigId,
|
|
595
|
+
accountId,
|
|
596
|
+
outboxMessageId,
|
|
597
|
+
outboxAttachmentId,
|
|
598
|
+
),
|
|
599
|
+
content,
|
|
600
|
+
);
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
const listOutboxAttachments: StorageService["listOutboxAttachments"] = async (
|
|
604
|
+
accountConfigId,
|
|
605
|
+
accountId,
|
|
606
|
+
outboxMessageId,
|
|
607
|
+
) => {
|
|
608
|
+
const prefix = `mock://${buildOutboxAttachmentPrefix(accountConfigId, accountId, outboxMessageId)}`;
|
|
609
|
+
const items: OutboxAttachmentListItem[] = [];
|
|
610
|
+
for (const [uri, content] of storage.entries()) {
|
|
611
|
+
if (!uri.startsWith(prefix)) continue;
|
|
612
|
+
items.push({
|
|
613
|
+
outboxAttachmentId: uri.slice(prefix.length),
|
|
614
|
+
key: uri.slice("mock://".length),
|
|
615
|
+
sizeBytes: content.length,
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
return items;
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
const listOutboxDraftsWithAttachments: StorageService["listOutboxDraftsWithAttachments"] =
|
|
622
|
+
async (accountConfigId, accountId) => {
|
|
623
|
+
const root = `mock://accounts/${accountConfigId}/${accountId}/outbox/`;
|
|
624
|
+
const drafts = new Set<string>();
|
|
625
|
+
for (const uri of storage.keys()) {
|
|
626
|
+
if (!uri.startsWith(root)) continue;
|
|
627
|
+
const rest = uri.slice(root.length);
|
|
628
|
+
const slash = rest.indexOf("/");
|
|
629
|
+
if (slash > 0) drafts.add(rest.slice(0, slash));
|
|
630
|
+
}
|
|
631
|
+
return [...drafts];
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
const deleteOutboxAttachment: StorageService["deleteOutboxAttachment"] =
|
|
635
|
+
async (accountConfigId, accountId, outboxMessageId, outboxAttachmentId) => {
|
|
636
|
+
storage.delete(
|
|
637
|
+
`mock://${buildOutboxAttachmentKey(accountConfigId, accountId, outboxMessageId, outboxAttachmentId)}`,
|
|
638
|
+
);
|
|
639
|
+
};
|
|
640
|
+
|
|
641
|
+
const statOutboxAttachment: StorageService["statOutboxAttachment"] = async (
|
|
642
|
+
accountConfigId,
|
|
643
|
+
accountId,
|
|
644
|
+
outboxMessageId,
|
|
645
|
+
outboxAttachmentId,
|
|
646
|
+
) => {
|
|
647
|
+
const content = storage.get(
|
|
648
|
+
`mock://${buildOutboxAttachmentKey(accountConfigId, accountId, outboxMessageId, outboxAttachmentId)}`,
|
|
649
|
+
);
|
|
650
|
+
return content ? { sizeBytes: content.length } : null;
|
|
651
|
+
};
|
|
652
|
+
|
|
653
|
+
const createOutboxAttachmentUploadUrl: StorageService["createOutboxAttachmentUploadUrl"] =
|
|
654
|
+
async (params) => ({
|
|
655
|
+
uploadUrl: `mock://upload/${buildOutboxAttachmentKey(params.accountConfigId, params.accountId, params.outboxMessageId, params.outboxAttachmentId)}?max=${params.sizeBytes}&exp=${params.expiresAt}`,
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
const deleteOutboxAttachments: StorageService["deleteOutboxAttachments"] =
|
|
659
|
+
async (accountConfigId, accountId, outboxMessageId) => {
|
|
660
|
+
const prefix = `mock://${buildOutboxAttachmentPrefix(accountConfigId, accountId, outboxMessageId)}`;
|
|
661
|
+
for (const uri of [...storage.keys()]) {
|
|
662
|
+
if (uri.startsWith(prefix)) storage.delete(uri);
|
|
663
|
+
}
|
|
664
|
+
};
|
|
665
|
+
|
|
414
666
|
const storeDeduplicated: StorageService["storeDeduplicated"] = async (
|
|
415
667
|
params,
|
|
416
668
|
) => {
|
|
@@ -547,6 +799,13 @@ export const createMockStorageService = (): StorageService => {
|
|
|
547
799
|
storeBodyPart,
|
|
548
800
|
bodyPartExists,
|
|
549
801
|
retrieveBodyPart,
|
|
802
|
+
storeOutboxAttachment,
|
|
803
|
+
listOutboxAttachments,
|
|
804
|
+
listOutboxDraftsWithAttachments,
|
|
805
|
+
deleteOutboxAttachments,
|
|
806
|
+
deleteOutboxAttachment,
|
|
807
|
+
statOutboxAttachment,
|
|
808
|
+
createOutboxAttachmentUploadUrl,
|
|
550
809
|
storeDeduplicated,
|
|
551
810
|
storeParsedBody,
|
|
552
811
|
retrieveParsedBody,
|