@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.
@@ -1,56 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { mkdtemp, rm } from "node:fs/promises";
3
- import { tmpdir } from "node:os";
4
- import { join } from "node:path";
5
- import { describe, it } from "node:test";
6
- import { createFilesystemStorageService } from "./backends/filesystem.js";
7
- import { createMockStorageService, type StorageService } from "./storage.js";
8
-
9
- const OWNER = {
10
- accountConfigId: "cfg-1",
11
- accountId: "acc-1",
12
- outboxMessageId: "ob-1",
13
- };
14
-
15
- const readsBackWhatWasStored = async (storage: StorageService) => {
16
- const content = Buffer.from([0x25, 0x50, 0x44, 0x46, 0x00, 0xff]);
17
- await storage.storeOutboxAttachment({
18
- ...OWNER,
19
- outboxAttachmentId: "att-1",
20
- content,
21
- });
22
-
23
- assert.deepEqual(
24
- await storage.retrieveOutboxAttachment(
25
- OWNER.accountConfigId,
26
- OWNER.accountId,
27
- OWNER.outboxMessageId,
28
- "att-1",
29
- ),
30
- content,
31
- );
32
- assert.equal(
33
- await storage.retrieveOutboxAttachment(
34
- OWNER.accountConfigId,
35
- OWNER.accountId,
36
- OWNER.outboxMessageId,
37
- "att-missing",
38
- ),
39
- null,
40
- );
41
- };
42
-
43
- describe("retrieveOutboxAttachment", () => {
44
- it("reads back the exact bytes on the filesystem backend, and null for none", async () => {
45
- const basePath = await mkdtemp(join(tmpdir(), "remit-outbox-retrieve-"));
46
- try {
47
- await readsBackWhatWasStored(createFilesystemStorageService(basePath));
48
- } finally {
49
- await rm(basePath, { recursive: true, force: true });
50
- }
51
- });
52
-
53
- it("reads back the exact bytes on the mock backend, and null for none", async () => {
54
- await readsBackWhatWasStored(createMockStorageService());
55
- });
56
- });
@@ -1,196 +0,0 @@
1
- import assert from "node:assert";
2
- import { describe, test } from "node:test";
3
- import { sweepAbandonedOutboxAttachments } from "./outbox-sweep.js";
4
- import { createMockStorageService, type StorageService } from "./storage.js";
5
-
6
- const CFG = "cfg1";
7
- const ACC = "acc1";
8
-
9
- const withObjects = async (
10
- drafts: Record<string, string[]>,
11
- ): Promise<StorageService> => {
12
- const storage = createMockStorageService();
13
- for (const [outboxMessageId, ids] of Object.entries(drafts)) {
14
- for (const outboxAttachmentId of ids) {
15
- await storage.storeOutboxAttachment({
16
- accountConfigId: CFG,
17
- accountId: ACC,
18
- outboxMessageId,
19
- outboxAttachmentId,
20
- content: Buffer.alloc(4),
21
- });
22
- }
23
- }
24
- return storage;
25
- };
26
-
27
- const remaining = (storage: StorageService, draft: string) =>
28
- storage
29
- .listOutboxAttachments(CFG, ACC, draft)
30
- .then((items) => items.map((item) => item.outboxAttachmentId).sort());
31
-
32
- describe("sweepAbandonedOutboxAttachments", () => {
33
- test("deletes objects the database does not know about", async () => {
34
- const storage = await withObjects({ draft1: ["known", "orphan"] });
35
-
36
- const result = await sweepAbandonedOutboxAttachments(
37
- {
38
- storage,
39
- listKnownAttachmentIds: async () => ["known"],
40
- onSkipped: () => assert.fail("nothing should be skipped"),
41
- },
42
- CFG,
43
- ACC,
44
- );
45
-
46
- assert.deepStrictEqual(await remaining(storage, "draft1"), ["known"]);
47
- assert.strictEqual(result.deleted, 1);
48
- assert.strictEqual(result.skipped, 0);
49
- });
50
-
51
- test("empties the prefix of a draft the database has forgotten", async () => {
52
- // A discarded draft has no rows at all, and a presigned PUT can still have
53
- // landed under it. This is the case the sweep exists for.
54
- const storage = await withObjects({ discarded: ["a", "b"] });
55
-
56
- const result = await sweepAbandonedOutboxAttachments(
57
- {
58
- storage,
59
- listKnownAttachmentIds: async () => [],
60
- onSkipped: () => assert.fail("nothing should be skipped"),
61
- },
62
- CFG,
63
- ACC,
64
- );
65
-
66
- assert.deepStrictEqual(await remaining(storage, "discarded"), []);
67
- assert.strictEqual(result.deleted, 2);
68
- });
69
-
70
- test("leaves a draft alone when its rows cannot be read", async () => {
71
- // "I could not find out what is live" must never be acted on as "nothing
72
- // is live" — that would delete a whole draft's files on a transient error.
73
- const storage = await withObjects({ draft1: ["a", "b"] });
74
- const skips: string[] = [];
75
-
76
- const result = await sweepAbandonedOutboxAttachments(
77
- {
78
- storage,
79
- listKnownAttachmentIds: async () => {
80
- throw new Error("database unavailable");
81
- },
82
- onSkipped: (outboxMessageId) => skips.push(outboxMessageId),
83
- },
84
- CFG,
85
- ACC,
86
- );
87
-
88
- assert.deepStrictEqual(await remaining(storage, "draft1"), ["a", "b"]);
89
- assert.strictEqual(result.deleted, 0);
90
- assert.strictEqual(result.skipped, 1);
91
- assert.deepStrictEqual(skips, ["draft1"]);
92
- });
93
-
94
- test("one unreadable draft does not stop the others being collected", async () => {
95
- const storage = await withObjects({ bad: ["x"], good: ["orphan"] });
96
-
97
- const result = await sweepAbandonedOutboxAttachments(
98
- {
99
- storage,
100
- listKnownAttachmentIds: async (_cfg, outboxMessageId) => {
101
- if (outboxMessageId === "bad") throw new Error("unavailable");
102
- return [];
103
- },
104
- onSkipped: () => {},
105
- },
106
- CFG,
107
- ACC,
108
- );
109
-
110
- assert.deepStrictEqual(await remaining(storage, "bad"), ["x"]);
111
- assert.deepStrictEqual(await remaining(storage, "good"), []);
112
- assert.strictEqual(result.skipped, 1);
113
- assert.strictEqual(result.deleted, 1);
114
- });
115
-
116
- test("an attachment that lands between the two reads is kept", async () => {
117
- // Objects are listed before the rows are read. A mint that lands in that
118
- // window is seen as a row and not as an object, so it survives. Read the
119
- // rows first and the same mint is an object with no row behind it — this
120
- // fails if those two lines ever swap.
121
- const storage = await withObjects({ draft1: ["already-there"] });
122
- let listedObjects = false;
123
-
124
- const inner = storage.listOutboxAttachments.bind(storage);
125
- const observing = {
126
- ...storage,
127
- listOutboxAttachments: async (
128
- accountConfigId: string,
129
- accountId: string,
130
- outboxMessageId: string,
131
- ) => {
132
- listedObjects = true;
133
- return inner(accountConfigId, accountId, outboxMessageId);
134
- },
135
- } as StorageService;
136
-
137
- await sweepAbandonedOutboxAttachments(
138
- {
139
- storage: observing,
140
- listKnownAttachmentIds: async () => {
141
- assert.strictEqual(
142
- listedObjects,
143
- true,
144
- "rows must be read after the objects were listed, never before",
145
- );
146
- // The mint that landed in the window.
147
- await storage.storeOutboxAttachment({
148
- accountConfigId: CFG,
149
- accountId: ACC,
150
- outboxMessageId: "draft1",
151
- outboxAttachmentId: "just-reserved",
152
- content: Buffer.alloc(4),
153
- });
154
- return ["already-there", "just-reserved"];
155
- },
156
- onSkipped: () => {},
157
- },
158
- CFG,
159
- ACC,
160
- );
161
-
162
- assert.deepStrictEqual(await remaining(storage, "draft1"), [
163
- "already-there",
164
- "just-reserved",
165
- ]);
166
- });
167
-
168
- test("never reaches another tenant's objects", async () => {
169
- const storage = createMockStorageService();
170
- await storage.storeOutboxAttachment({
171
- accountConfigId: "cfg-other",
172
- accountId: ACC,
173
- outboxMessageId: "draft1",
174
- outboxAttachmentId: "theirs",
175
- content: Buffer.alloc(4),
176
- });
177
-
178
- const result = await sweepAbandonedOutboxAttachments(
179
- {
180
- storage,
181
- listKnownAttachmentIds: async () => [],
182
- onSkipped: () => {},
183
- },
184
- CFG,
185
- ACC,
186
- );
187
-
188
- assert.strictEqual(result.deleted, 0);
189
- assert.deepStrictEqual(
190
- (await storage.listOutboxAttachments("cfg-other", ACC, "draft1")).map(
191
- (item) => item.outboxAttachmentId,
192
- ),
193
- ["theirs"],
194
- );
195
- });
196
- });
@@ -1,103 +0,0 @@
1
- import type { StorageService } from "./storage.js";
2
-
3
- /**
4
- * Collect attachment bytes the database does not know about.
5
- *
6
- * A presigned upload URL stays valid for its whole window, including after the
7
- * draft it was minted for is discarded, and on a deployment where the browser
8
- * PUTs straight to block storage there is nothing in that path to consult. So
9
- * an object can exist that no row names. That is the only case this exists for,
10
- * and the rule is the whole of it: an object survives if the database still has
11
- * an attachment row for it.
12
- *
13
- * Two orderings matter and both are deliberate. Objects are listed *before* the
14
- * rows are read, so an attachment reserved between the two reads is seen as a
15
- * row and not as an object, and is kept. And a draft whose row lookup fails is
16
- * skipped entirely — "I could not find out what is live" must never be acted on
17
- * as "nothing is live".
18
- */
19
- export interface OutboxSweepResult {
20
- inspected: number;
21
- deleted: number;
22
- /** Drafts skipped because their attachment rows could not be read. */
23
- skipped: number;
24
- }
25
-
26
- export interface OutboxSweepDeps {
27
- storage: StorageService;
28
- /**
29
- * The attachment ids the database holds for a draft. Throwing is the right
30
- * way to say "unknown" — the sweep then leaves that draft alone.
31
- */
32
- listKnownAttachmentIds: (
33
- accountConfigId: string,
34
- outboxMessageId: string,
35
- ) => Promise<string[]>;
36
- onSkipped: (outboxMessageId: string, error: unknown) => void;
37
- }
38
-
39
- export const sweepAbandonedOutboxAttachments = async (
40
- deps: OutboxSweepDeps,
41
- accountConfigId: string,
42
- accountId: string,
43
- ): Promise<OutboxSweepResult> => {
44
- let inspected = 0;
45
- let deleted = 0;
46
- let skipped = 0;
47
-
48
- const outboxMessageIds = await deps.storage.listOutboxDraftsWithAttachments(
49
- accountConfigId,
50
- accountId,
51
- );
52
-
53
- for (const outboxMessageId of outboxMessageIds) {
54
- const objects = await deps.storage.listOutboxAttachments(
55
- accountConfigId,
56
- accountId,
57
- outboxMessageId,
58
- );
59
- if (objects.length === 0) continue;
60
-
61
- const known = await deps
62
- .listKnownAttachmentIds(accountConfigId, outboxMessageId)
63
- .then((ids) => new Set(ids))
64
- .catch((error: unknown) => {
65
- deps.onSkipped(outboxMessageId, error);
66
- return null;
67
- });
68
- if (known === null) {
69
- skipped += 1;
70
- continue;
71
- }
72
-
73
- inspected += objects.length;
74
- const abandoned = objects.filter(
75
- (object) => !known.has(object.outboxAttachmentId),
76
- );
77
-
78
- // A draft whose every object is abandoned is the common case — it was
79
- // discarded — and taking the prefix in one call beats one round trip per
80
- // object.
81
- if (abandoned.length === objects.length) {
82
- await deps.storage.deleteOutboxAttachments(
83
- accountConfigId,
84
- accountId,
85
- outboxMessageId,
86
- );
87
- deleted += abandoned.length;
88
- continue;
89
- }
90
-
91
- for (const object of abandoned) {
92
- await deps.storage.deleteOutboxAttachment(
93
- accountConfigId,
94
- accountId,
95
- outboxMessageId,
96
- object.outboxAttachmentId,
97
- );
98
- deleted += 1;
99
- }
100
- }
101
-
102
- return { inspected, deleted, skipped };
103
- };
@@ -1,290 +0,0 @@
1
- import assert from "node:assert";
2
- import { mkdtemp, rm } from "node:fs/promises";
3
- import { tmpdir } from "node:os";
4
- import { join } from "node:path";
5
- import { describe, test } from "node:test";
6
- import { S3Client } from "@aws-sdk/client-s3";
7
- import { createFilesystemStorageService } from "./backends/filesystem.js";
8
- import { createS3StorageService } from "./backends/s3.js";
9
- import {
10
- authorizeUploadRequest,
11
- UPLOAD_ROUTE_PREFIX,
12
- UPLOAD_URL_SIGNING_LABEL,
13
- } from "./outbox-upload-url.js";
14
- import { signStoragePath } from "./signed-path.js";
15
- import {
16
- buildOutboxAttachmentKey,
17
- parseOutboxAttachmentKey,
18
- } from "./storage.js";
19
-
20
- const SECRET = "a-signing-secret-of-at-least-32-characters";
21
- const KEY = buildOutboxAttachmentKey("cfg1", "acc1", "draft1", "att1");
22
-
23
- const sign = (path: string, exp: number, max: number): string =>
24
- signStoragePath(SECRET, UPLOAD_URL_SIGNING_LABEL, path, [exp, max]);
25
-
26
- describe("authorizeUploadRequest", () => {
27
- const now = 1_000_000;
28
- const exp = now + 900;
29
-
30
- test("admits a request presenting the signature that was minted for it", () => {
31
- const result = authorizeUploadRequest({
32
- secret: SECRET,
33
- relativePath: KEY,
34
- exp: String(exp),
35
- max: "2048",
36
- sig: sign(KEY, exp, 2048),
37
- nowSeconds: now,
38
- });
39
- assert.deepStrictEqual(result, { authorized: true, maxBytes: 2048 });
40
- });
41
-
42
- test("refuses a signature minted for another attachment's key", () => {
43
- const otherKey = buildOutboxAttachmentKey("cfg1", "acc1", "draft1", "att2");
44
- const result = authorizeUploadRequest({
45
- secret: SECRET,
46
- relativePath: otherKey,
47
- exp: String(exp),
48
- max: "2048",
49
- sig: sign(KEY, exp, 2048),
50
- nowSeconds: now,
51
- });
52
- assert.strictEqual(result.authorized, false);
53
- });
54
-
55
- test("refuses a signature minted for another account's key", () => {
56
- const otherTenant = buildOutboxAttachmentKey(
57
- "cfg2",
58
- "acc1",
59
- "draft1",
60
- "att1",
61
- );
62
- const result = authorizeUploadRequest({
63
- secret: SECRET,
64
- relativePath: otherTenant,
65
- exp: String(exp),
66
- max: "2048",
67
- sig: sign(KEY, exp, 2048),
68
- nowSeconds: now,
69
- });
70
- assert.strictEqual(result.authorized, false);
71
- });
72
-
73
- test("refuses a raised byte count — the size is part of what was signed", () => {
74
- const result = authorizeUploadRequest({
75
- secret: SECRET,
76
- relativePath: KEY,
77
- exp: String(exp),
78
- max: "999999",
79
- sig: sign(KEY, exp, 2048),
80
- nowSeconds: now,
81
- });
82
- assert.strictEqual(result.authorized, false);
83
- });
84
-
85
- test("refuses an extended expiry", () => {
86
- const result = authorizeUploadRequest({
87
- secret: SECRET,
88
- relativePath: KEY,
89
- exp: String(exp + 10_000),
90
- max: "2048",
91
- sig: sign(KEY, exp, 2048),
92
- nowSeconds: now,
93
- });
94
- assert.strictEqual(result.authorized, false);
95
- });
96
-
97
- test("refuses a lapsed URL", () => {
98
- const result = authorizeUploadRequest({
99
- secret: SECRET,
100
- relativePath: KEY,
101
- exp: String(exp),
102
- max: "2048",
103
- sig: sign(KEY, exp, 2048),
104
- nowSeconds: exp + 1,
105
- });
106
- assert.strictEqual(result.authorized, false);
107
- assert.strictEqual(result.authorized === false && result.reason, "expired");
108
- });
109
-
110
- test("refuses a request presenting no signature at all", () => {
111
- const result = authorizeUploadRequest({
112
- secret: SECRET,
113
- relativePath: KEY,
114
- exp: String(exp),
115
- max: "2048",
116
- sig: undefined,
117
- nowSeconds: now,
118
- });
119
- assert.strictEqual(result.authorized === false && result.status, 401);
120
- });
121
-
122
- test("fails closed when no secret is configured", () => {
123
- const result = authorizeUploadRequest({
124
- secret: undefined,
125
- relativePath: KEY,
126
- exp: String(exp),
127
- max: "2048",
128
- sig: sign(KEY, exp, 2048),
129
- nowSeconds: now,
130
- });
131
- assert.strictEqual(result.authorized === false && result.status, 500);
132
- });
133
-
134
- test("a read grant for the same path is not a write grant", () => {
135
- // The content signer covers the path and an expiry under its own label.
136
- const readSignature = signStoragePath(
137
- SECRET,
138
- "remit-content-url-signing-v1",
139
- KEY,
140
- [exp],
141
- );
142
- const result = authorizeUploadRequest({
143
- secret: SECRET,
144
- relativePath: KEY,
145
- exp: String(exp),
146
- max: "2048",
147
- sig: readSignature,
148
- nowSeconds: now,
149
- });
150
- assert.strictEqual(result.authorized, false);
151
- });
152
- });
153
-
154
- describe("attachment keys", () => {
155
- test("parseOutboxAttachmentKey recovers the ids the upload route needs", () => {
156
- assert.deepStrictEqual(parseOutboxAttachmentKey(KEY), {
157
- accountConfigId: "cfg1",
158
- accountId: "acc1",
159
- outboxMessageId: "draft1",
160
- outboxAttachmentId: "att1",
161
- });
162
- });
163
-
164
- test("parseOutboxAttachmentKey refuses a message content key", () => {
165
- assert.strictEqual(
166
- parseOutboxAttachmentKey("accounts/cfg1/acc1/messages/msg1/parts/1.2"),
167
- null,
168
- );
169
- });
170
-
171
- test("parseOutboxAttachmentKey refuses a traversal, signature or no signature", () => {
172
- // `..` matches a naive [^/]+ and the write side joins this onto a
173
- // filesystem root, so the shape has to refuse it before the HMAC is the
174
- // only thing left standing.
175
- assert.strictEqual(
176
- parseOutboxAttachmentKey(
177
- "accounts/cfg1/acc1/outbox/draft1/attachments/..",
178
- ),
179
- null,
180
- );
181
- assert.strictEqual(
182
- parseOutboxAttachmentKey("accounts/../../etc/outbox/x/attachments/y"),
183
- null,
184
- );
185
- assert.strictEqual(
186
- parseOutboxAttachmentKey(
187
- "accounts/cfg1/acc1/outbox/draft1/attachments/a.b",
188
- ),
189
- null,
190
- );
191
- });
192
- });
193
-
194
- describe("minting an upload URL", () => {
195
- test("the filesystem backend mints a URL its own route will admit", async () => {
196
- const basePath = await mkdtemp(join(tmpdir(), "remit-upload-url-"));
197
- try {
198
- const storage = createFilesystemStorageService(basePath, {
199
- origin: "https://mail.example.test/",
200
- signingSecret: SECRET,
201
- });
202
- const expiresAt = Math.floor(Date.now() / 1000) + 900;
203
-
204
- const { uploadUrl } = await storage.createOutboxAttachmentUploadUrl({
205
- accountConfigId: "cfg1",
206
- accountId: "acc1",
207
- outboxMessageId: "draft1",
208
- outboxAttachmentId: "att1",
209
- sizeBytes: 2048,
210
- expiresAt,
211
- });
212
-
213
- const url = new URL(uploadUrl);
214
- assert.strictEqual(url.origin, "https://mail.example.test");
215
- assert.strictEqual(url.pathname, `${UPLOAD_ROUTE_PREFIX}${KEY}`);
216
-
217
- const admitted = authorizeUploadRequest({
218
- secret: SECRET,
219
- relativePath: url.pathname.slice(UPLOAD_ROUTE_PREFIX.length),
220
- exp: url.searchParams.get("exp") ?? undefined,
221
- max: url.searchParams.get("max") ?? undefined,
222
- sig: url.searchParams.get("sig") ?? undefined,
223
- nowSeconds: Math.floor(Date.now() / 1000),
224
- });
225
- assert.deepStrictEqual(admitted, { authorized: true, maxBytes: 2048 });
226
- } finally {
227
- await rm(basePath, { recursive: true, force: true });
228
- }
229
- });
230
-
231
- test("the filesystem backend refuses to mint without an origin and a secret", async () => {
232
- const basePath = await mkdtemp(join(tmpdir(), "remit-upload-url-"));
233
- try {
234
- const storage = createFilesystemStorageService(basePath);
235
- await assert.rejects(
236
- () =>
237
- storage.createOutboxAttachmentUploadUrl({
238
- accountConfigId: "cfg1",
239
- accountId: "acc1",
240
- outboxMessageId: "draft1",
241
- outboxAttachmentId: "att1",
242
- sizeBytes: 1,
243
- expiresAt: Math.floor(Date.now() / 1000) + 60,
244
- }),
245
- /cannot mint an upload URL/,
246
- );
247
- } finally {
248
- await rm(basePath, { recursive: true, force: true });
249
- }
250
- });
251
-
252
- test("the S3 backend mints a presigned PUT with the size inside the signature", async () => {
253
- const client = new S3Client({
254
- region: "eu-west-1",
255
- credentials: {
256
- accessKeyId: "AKIATESTTESTTEST",
257
- secretAccessKey: "test-secret",
258
- },
259
- });
260
- const storage = createS3StorageService(client, "a-bucket");
261
- const params = {
262
- accountConfigId: "cfg1",
263
- accountId: "acc1",
264
- outboxMessageId: "draft1",
265
- outboxAttachmentId: "att1",
266
- sizeBytes: 2048,
267
- expiresAt: Math.floor(Date.now() / 1000) + 900,
268
- };
269
-
270
- const { uploadUrl } = await storage.createOutboxAttachmentUploadUrl(params);
271
- const url = new URL(uploadUrl);
272
-
273
- assert.ok(url.pathname.endsWith(KEY));
274
- // content-length inside SignedHeaders is what makes the declared size
275
- // unforgeable: S3 recomputes the signature over the length it is sent.
276
- assert.match(
277
- url.searchParams.get("X-Amz-SignedHeaders") ?? "",
278
- /content-length/,
279
- );
280
-
281
- const larger = await storage.createOutboxAttachmentUploadUrl({
282
- ...params,
283
- sizeBytes: 4096,
284
- });
285
- assert.notStrictEqual(
286
- url.searchParams.get("X-Amz-Signature"),
287
- new URL(larger.uploadUrl).searchParams.get("X-Amz-Signature"),
288
- );
289
- });
290
- });
package/src/s3.ts DELETED
@@ -1,36 +0,0 @@
1
- import { S3Client } from "@aws-sdk/client-s3";
2
- import { createFilesystemStorageService } from "./backends/filesystem.js";
3
- import { createS3StorageService } from "./backends/s3.js";
4
- import type { StorageService } from "./storage.js";
5
-
6
- export { createS3StorageService } from "./backends/s3.js";
7
-
8
- export const createStorageService = (): StorageService => {
9
- const bucketName = process.env.S3_BUCKET_NAME;
10
-
11
- if (bucketName) {
12
- const client = new S3Client({
13
- endpoint: process.env.S3_ENDPOINT,
14
- // The SDK default (WHEN_SUPPORTED) hangs a CRC32 of an empty body off
15
- // every presigned PUT as signed query params. Real S3 tolerates it;
16
- // MinIO and LocalStack reject the request, and S3_ENDPOINT exists to
17
- // point at exactly those.
18
- requestChecksumCalculation: "WHEN_REQUIRED",
19
- });
20
- return createS3StorageService(client, bucketName);
21
- }
22
-
23
- const basePath = process.env.STORAGE_LOCAL_PATH ?? ".remit/storage";
24
- // BETTER_AUTH_URL is the deployment's own public base — the value the token
25
- // issuer and the JWKS discovery document already agree on — so a minted
26
- // upload URL resolves from wherever the browser reached the API. Both it and
27
- // the signing secret are required on the self-host stack; without them a
28
- // mint fails loud inside the backend rather than handing out a URL that
29
- // cannot be verified.
30
- const origin = process.env.BETTER_AUTH_URL;
31
- const signingSecret = process.env.BETTER_AUTH_SECRET;
32
- return createFilesystemStorageService(
33
- basePath,
34
- origin && signingSecret ? { origin, signingSecret } : undefined,
35
- );
36
- };