@remit/backend 0.0.29 → 0.0.31

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/backend",
3
- "version": "0.0.29",
3
+ "version": "0.0.31",
4
4
  "description": "Remit Mail Inspector API backend",
5
5
  "license": "MIT",
6
6
  "author": "",
@@ -9,9 +9,7 @@ import {
9
9
  import { join } from "node:path";
10
10
  import { after, afterEach, before, beforeEach, describe, it } from "node:test";
11
11
  import { fileURLToPath } from "node:url";
12
- import { createInstanceOwnerStore } from "@remit/auth-service";
13
12
  import type { APIGatewayProxyEvent } from "aws-lambda";
14
- import Database from "better-sqlite3";
15
13
  import type { Context } from "openapi-backend";
16
14
  import { SystemOperations } from "./system-update.js";
17
15
 
@@ -23,20 +21,9 @@ const tmpRoot = join(
23
21
  "system-update",
24
22
  );
25
23
 
26
- const readMigrationSql = (relativePath: string): string =>
27
- readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), "utf8");
28
-
29
- const authMigrationSql = readMigrationSql(
30
- "../../../../deploy/vps/migrations-sqlite/auth/0000_rainy_iron_monger.sql",
31
- );
32
- const metaMigrationSql = readMigrationSql(
33
- "../../../../deploy/vps/migrations-sqlite/meta/0000_demonic_lilith.sql",
34
- );
35
-
36
- const OWNER = "the-owner";
24
+ const USER = "any-signed-in-user";
37
25
  const MANIFEST_URL = "https://updates.example.com/stable.json";
38
26
 
39
- let dbPath: string;
40
27
  let controlDir: string;
41
28
 
42
29
  const okState = {
@@ -57,29 +44,8 @@ const writeState = (state: unknown): void => {
57
44
  writeFileSync(join(controlDir, "state.json"), JSON.stringify(state));
58
45
  };
59
46
 
60
- before(async () => {
47
+ before(() => {
61
48
  mkdirSync(tmpRoot, { recursive: true });
62
- dbPath = join(mkdtempSync(join(tmpRoot, "db-")), "app.db");
63
- const sqlite = new Database(dbPath);
64
- for (const sql of [authMigrationSql, metaMigrationSql]) {
65
- for (const statement of sql.split("--> statement-breakpoint")) {
66
- sqlite.exec(statement);
67
- }
68
- }
69
- sqlite
70
- .prepare(
71
- "INSERT INTO auth_user (id, name, email, email_verified, created_at, updated_at) VALUES (?, ?, ?, 0, ?, ?)",
72
- )
73
- .run(OWNER, OWNER, "owner@example.com", 1000, 1000);
74
- sqlite.close();
75
- process.env.DATA_BACKEND = "sqlite";
76
- process.env.SQLITE_DB_PATH = dbPath;
77
- const store = await createInstanceOwnerStore({
78
- provider: "sqlite",
79
- connectionString: dbPath,
80
- });
81
- await store.claimIfUnclaimed(OWNER);
82
- await store.close();
83
49
  });
84
50
 
85
51
  after(() => {
@@ -135,27 +101,37 @@ describe("GET /system/update", () => {
135
101
  it("returns 404 when no manifest URL is configured", async () => {
136
102
  delete process.env.REMIT_UPDATE_MANIFEST_URL;
137
103
 
138
- const result = await getUpdate(buildEvent(OWNER));
104
+ const result = await getUpdate(buildEvent(USER));
139
105
 
140
106
  assert.ok(hasStatus(result, 404));
141
107
  });
142
108
 
143
- it("returns 403 for a non-owner", async () => {
144
- const result = await getUpdate(buildEvent("someone-else"));
109
+ it("returns 401 when the caller is not authenticated", async () => {
110
+ writeState(okState);
111
+
112
+ const result = await getUpdate(buildEvent());
113
+
114
+ assert.ok(hasStatus(result, 401));
115
+ });
116
+
117
+ it("returns 200 for any authenticated caller", async () => {
118
+ writeState(okState);
119
+
120
+ const result = await getUpdate(buildEvent("another-signed-in-user"));
145
121
 
146
- assert.ok(hasStatus(result, 403));
122
+ assert.deepEqual(result, okState);
147
123
  });
148
124
 
149
125
  it("returns 200 with the state file verbatim", async () => {
150
126
  writeState(okState);
151
127
 
152
- const result = await getUpdate(buildEvent(OWNER));
128
+ const result = await getUpdate(buildEvent(USER));
153
129
 
154
130
  assert.deepEqual(result, okState);
155
131
  });
156
132
 
157
133
  it("returns a disabled, empty resource when no state file exists", async () => {
158
- const result = await getUpdate(buildEvent(OWNER));
134
+ const result = await getUpdate(buildEvent(USER));
159
135
 
160
136
  assert.deepEqual(result, {
161
137
  currentVersion: "v1.4.1",
@@ -186,7 +162,7 @@ describe("GET /system/update", () => {
186
162
  run,
187
163
  });
188
164
 
189
- const result = (await getUpdate(buildEvent(OWNER))) as {
165
+ const result = (await getUpdate(buildEvent(USER))) as {
190
166
  check: { status: string };
191
167
  run: unknown;
192
168
  };
@@ -203,7 +179,7 @@ describe("GET /system/update", () => {
203
179
  run: null,
204
180
  });
205
181
 
206
- const result = (await getUpdate(buildEvent(OWNER))) as {
182
+ const result = (await getUpdate(buildEvent(USER))) as {
207
183
  currentSchemaVersion?: number;
208
184
  check: { schemaVersion?: number };
209
185
  };
@@ -215,7 +191,7 @@ describe("GET /system/update", () => {
215
191
  it("omits both schema-version fields when the state file predates them", async () => {
216
192
  writeState(okState);
217
193
 
218
- const result = (await getUpdate(buildEvent(OWNER))) as {
194
+ const result = (await getUpdate(buildEvent(USER))) as {
219
195
  currentSchemaVersion?: number;
220
196
  check: { schemaVersion?: number };
221
197
  };
@@ -232,7 +208,7 @@ describe("GET /system/update", () => {
232
208
  run: null,
233
209
  });
234
210
 
235
- const result = (await getUpdate(buildEvent(OWNER))) as {
211
+ const result = (await getUpdate(buildEvent(USER))) as {
236
212
  currentSchemaVersion?: number;
237
213
  check: { schemaVersion?: number };
238
214
  };
@@ -248,7 +224,7 @@ describe("GET /system/update", () => {
248
224
  run: null,
249
225
  });
250
226
 
251
- const result = (await getUpdate(buildEvent(OWNER))) as {
227
+ const result = (await getUpdate(buildEvent(USER))) as {
252
228
  currentSchemaVersion?: number;
253
229
  check: { schemaVersion?: number };
254
230
  };
@@ -262,17 +238,34 @@ describe("POST /system/update", () => {
262
238
  it("returns 404 when no manifest URL is configured", async () => {
263
239
  delete process.env.REMIT_UPDATE_MANIFEST_URL;
264
240
 
265
- const result = await applyUpdate("v1.5.0", buildEvent(OWNER));
241
+ const result = await applyUpdate("v1.5.0", buildEvent(USER));
266
242
 
267
243
  assert.ok(hasStatus(result, 404));
268
244
  });
269
245
 
270
- it("returns 403 for a non-owner", async () => {
246
+ it("returns 401 without writing request.json when the caller is not authenticated", async () => {
247
+ writeState(okState);
248
+
249
+ const result = await applyUpdate("v1.5.0", buildEvent());
250
+
251
+ assert.ok(hasStatus(result, 401));
252
+ assert.throws(() => readFileSync(join(controlDir, "request.json"), "utf8"));
253
+ });
254
+
255
+ it("returns 202 for any authenticated caller and records that identity", async () => {
271
256
  writeState(okState);
272
257
 
273
- const result = await applyUpdate("v1.5.0", buildEvent("someone-else"));
258
+ const result = (await applyUpdate(
259
+ "v1.5.0",
260
+ buildEvent("another-signed-in-user"),
261
+ )) as { statusCode: number };
262
+
263
+ assert.equal(result.statusCode, 202);
274
264
 
275
- assert.ok(hasStatus(result, 403));
265
+ const request = JSON.parse(
266
+ readFileSync(join(controlDir, "request.json"), "utf8"),
267
+ );
268
+ assert.equal(request.requestedBy, "another-signed-in-user");
276
269
  });
277
270
 
278
271
  it("returns 409 when a run is already in flight", async () => {
@@ -291,7 +284,7 @@ describe("POST /system/update", () => {
291
284
  },
292
285
  });
293
286
 
294
- await assert.rejects(applyUpdate("v1.5.0", buildEvent(OWNER)), {
287
+ await assert.rejects(applyUpdate("v1.5.0", buildEvent(USER)), {
295
288
  statusCode: 409,
296
289
  });
297
290
  });
@@ -299,13 +292,13 @@ describe("POST /system/update", () => {
299
292
  it("returns 422 when the target is not the checked version", async () => {
300
293
  writeState(okState);
301
294
 
302
- await assert.rejects(applyUpdate("v9.9.9", buildEvent(OWNER)), {
295
+ await assert.rejects(applyUpdate("v9.9.9", buildEvent(USER)), {
303
296
  statusCode: 422,
304
297
  });
305
298
  });
306
299
 
307
300
  it("returns 422 when no check has ever succeeded", async () => {
308
- await assert.rejects(applyUpdate("v1.5.0", buildEvent(OWNER)), {
301
+ await assert.rejects(applyUpdate("v1.5.0", buildEvent(USER)), {
309
302
  statusCode: 422,
310
303
  });
311
304
  });
@@ -313,7 +306,7 @@ describe("POST /system/update", () => {
313
306
  it("returns 202 with a run block carrying a fresh runId", async () => {
314
307
  writeState(okState);
315
308
 
316
- const result = (await applyUpdate("v1.5.0", buildEvent(OWNER))) as {
309
+ const result = (await applyUpdate("v1.5.0", buildEvent(USER))) as {
317
310
  statusCode: number;
318
311
  body: { run: { runId: string; targetVersion: string; outcome: null } };
319
312
  };
@@ -327,7 +320,7 @@ describe("POST /system/update", () => {
327
320
  it("writes request.json with exactly the four seam fields and nothing else", async () => {
328
321
  writeState(okState);
329
322
 
330
- await applyUpdate("v1.5.0", buildEvent(OWNER));
323
+ await applyUpdate("v1.5.0", buildEvent(USER));
331
324
 
332
325
  const request = JSON.parse(
333
326
  readFileSync(join(controlDir, "request.json"), "utf8"),
@@ -340,7 +333,7 @@ describe("POST /system/update", () => {
340
333
  "targetVersion",
341
334
  ]);
342
335
  assert.equal(request.targetVersion, "v1.5.0");
343
- assert.equal(request.requestedBy, OWNER);
336
+ assert.equal(request.requestedBy, USER);
344
337
  for (const forbidden of ["registry", "image", "digest"]) {
345
338
  assert.ok(!(forbidden in request));
346
339
  }
@@ -10,7 +10,6 @@ import type { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
10
10
  import type { Context } from "openapi-backend";
11
11
  import { getSubFromEvent } from "../auth.js";
12
12
  import type { OperationHandler, SystemOperationIds } from "../types.js";
13
- import { requireInstanceOwner } from "./owner-guard.js";
14
13
 
15
14
  class TargetVersionMismatchError extends HTTPError {
16
15
  name = "TargetVersionMismatchError";
@@ -33,11 +32,17 @@ const notFound = (): APIGatewayProxyResult => ({
33
32
  body: JSON.stringify({ message: "Not found" }),
34
33
  });
35
34
 
35
+ const unauthorized = (): APIGatewayProxyResult => ({
36
+ statusCode: 401,
37
+ headers: { "Content-Type": "application/json" },
38
+ body: JSON.stringify({ message: "Unauthorized" }),
39
+ });
40
+
36
41
  /**
37
42
  * The self-update seam is off unless a manifest URL is configured (RFC 037 D8),
38
43
  * which the hosted deployment leaves unset. Both endpoints answer 404 in that
39
- * case before the owner guard, so a probe cannot tell an off surface from a
40
- * forbidden one.
44
+ * case. Where it is on, any authenticated caller may use it the account list
45
+ * is the trust boundary of a self-hosted instance.
41
46
  */
42
47
  const guardManifestConfigured = (): APIGatewayProxyResult | null =>
43
48
  manifestUrl() ? null : notFound();
@@ -140,8 +145,7 @@ export const SystemOperations: Record<
140
145
  if (offSurface) return offSurface;
141
146
 
142
147
  const event = args[0] as APIGatewayProxyEvent;
143
- const forbidden = await requireInstanceOwner(event);
144
- if (forbidden) return forbidden;
148
+ if (!getSubFromEvent(event)) return unauthorized();
145
149
 
146
150
  return readState() ?? emptyResource();
147
151
  },
@@ -154,8 +158,8 @@ export const SystemOperations: Record<
154
158
  if (offSurface) return offSurface;
155
159
 
156
160
  const event = args[0] as APIGatewayProxyEvent;
157
- const forbidden = await requireInstanceOwner(event);
158
- if (forbidden) return forbidden;
161
+ const sub = getSubFromEvent(event);
162
+ if (!sub) return unauthorized();
159
163
 
160
164
  const { targetVersion } = context.request.requestBody as {
161
165
  targetVersion: string;
@@ -178,7 +182,7 @@ export const SystemOperations: Record<
178
182
  runId,
179
183
  targetVersion,
180
184
  requestedAt,
181
- requestedBy: getSubFromEvent(event) ?? "",
185
+ requestedBy: sub,
182
186
  });
183
187
 
184
188
  return {
@@ -213,7 +213,14 @@ const candidate = (
213
213
  over: Partial<OrganizeCandidate["message"]> = {},
214
214
  ): OrganizeCandidate => ({
215
215
  messageId,
216
- message: { from: "", fromName: "", subject: "", text: "", ...over },
216
+ message: {
217
+ from: "",
218
+ fromName: "",
219
+ subject: "",
220
+ text: "",
221
+ listId: "",
222
+ ...over,
223
+ },
217
224
  });
218
225
 
219
226
  describe("matchOrganize", () => {
@@ -597,3 +604,115 @@ describe("back-apply pipeline (matchOrganize -> applyOrganize)", () => {
597
604
  );
598
605
  });
599
606
  });
607
+
608
+ describe("matchOrganize with ListId and FromDomain clauses", () => {
609
+ const senderChunk = (
610
+ messageId: string,
611
+ fromEmail: string,
612
+ over: Partial<ChunkMetadata> = {},
613
+ ): VectorRecord => ({
614
+ chunkId: `${messageId}#sender`,
615
+ vector: ANCHOR_VECTOR,
616
+ metadata: metadata({
617
+ messageId,
618
+ chunkType: "sender",
619
+ textPreview: fromEmail,
620
+ ...over,
621
+ }),
622
+ });
623
+
624
+ it("matches a ListId clause exactly off the vector-free projection", async () => {
625
+ const deps = vectorlessDeps([
626
+ candidate("msg-1", { listId: "weekly.news.example.com" }),
627
+ candidate("msg-2", { listId: "news.example.com" }),
628
+ candidate("msg-3", {}),
629
+ ]);
630
+
631
+ const { messageIds } = await matchOrganize(deps, ACCOUNT_CONFIG_ID, {
632
+ ...predicate(),
633
+ anchorMessageId: "None",
634
+ literalClauses: [{ field: "ListId", value: "weekly.news.example.com" }],
635
+ });
636
+
637
+ assert.deepEqual(messageIds, ["msg-1"]);
638
+ });
639
+
640
+ it("matches a FromDomain clause public-suffix aware off the vector-free projection", async () => {
641
+ const deps = vectorlessDeps([
642
+ candidate("msg-1", { from: "notifications@github.com" }),
643
+ candidate("msg-2", { from: "ci@sub.github.com" }),
644
+ candidate("msg-3", { from: "attacker@github.com.evil.example" }),
645
+ ]);
646
+
647
+ const { messageIds } = await matchOrganize(deps, ACCOUNT_CONFIG_ID, {
648
+ ...predicate(),
649
+ anchorMessageId: "None",
650
+ literalClauses: [{ field: "FromDomain", value: "github.com" }],
651
+ });
652
+
653
+ assert.deepEqual(messageIds, ["msg-1", "msg-2"]);
654
+ });
655
+
656
+ it("round-trips ListId and FromDomain through the semantic-arm chunk projection", async () => {
657
+ const store = createMemoryVectorStore();
658
+ await store.upsert([
659
+ senderChunk("msg-1", "ci@github.com", {
660
+ listId: "actions.github.com",
661
+ }),
662
+ bodyChunk("msg-1", ANCHOR_VECTOR, { listId: "actions.github.com" }),
663
+ senderChunk("msg-2", "hi@othersender.example", {
664
+ listId: "other.list.example",
665
+ }),
666
+ bodyChunk("msg-2", ANCHOR_VECTOR, { listId: "other.list.example" }),
667
+ ]);
668
+
669
+ const byListId = await matchOrganize(matchDeps(store), ACCOUNT_CONFIG_ID, {
670
+ ...predicate(),
671
+ literalClauses: [{ field: "ListId", value: "actions.github.com" }],
672
+ });
673
+ assert.deepEqual(byListId.messageIds, ["msg-1"]);
674
+
675
+ const byFromDomain = await matchOrganize(
676
+ matchDeps(store),
677
+ ACCOUNT_CONFIG_ID,
678
+ {
679
+ ...predicate(),
680
+ literalClauses: [{ field: "FromDomain", value: "github.com" }],
681
+ },
682
+ );
683
+ assert.deepEqual(byFromDomain.messageIds, ["msg-1"]);
684
+ });
685
+
686
+ it("applies a ListId predicate to exactly the previewed set (preview == apply)", async () => {
687
+ const deps = vectorlessDeps([
688
+ candidate("msg-1", { listId: "weekly.news.example.com" }),
689
+ candidate("msg-2", { listId: "weekly.news.example.com" }),
690
+ candidate("msg-3", { listId: "other.example.com" }),
691
+ ]);
692
+ const p = predicate({
693
+ anchorMessageId: "None",
694
+ actionLabelId: "lbl-list",
695
+ literalClauses: [{ field: "ListId", value: "weekly.news.example.com" }],
696
+ });
697
+
698
+ const previewed = await matchOrganize(deps, ACCOUNT_CONFIG_ID, p);
699
+ const applied = await matchOrganize(deps, ACCOUNT_CONFIG_ID, p);
700
+ assert.deepEqual(previewed, applied);
701
+ assert.deepEqual(previewed.messageIds, ["msg-1", "msg-2"]);
702
+
703
+ const tracked = trackingClient();
704
+ const result = await applyOrganize(
705
+ { client: tracked.client },
706
+ ACCOUNT_CONFIG_ID,
707
+ applied.messageIds,
708
+ p,
709
+ );
710
+
711
+ assert.equal(result.applied, 2);
712
+ assert.equal(result.failed, 0);
713
+ assert.deepEqual(tracked.labeled.map((row) => row.messageId).sort(), [
714
+ "msg-1",
715
+ "msg-2",
716
+ ]);
717
+ });
718
+ });
@@ -131,11 +131,13 @@ const filterMessageFromChunks = (
131
131
  let subject = "";
132
132
  let fromName = "";
133
133
  let from = "";
134
+ let listId = "";
134
135
  const textParts: string[] = [];
135
136
  for (const record of records) {
136
137
  const meta = record.metadata;
137
138
  if (meta.subject && !subject) subject = meta.subject;
138
139
  if (meta.fromName && !fromName) fromName = meta.fromName;
140
+ if (meta.listId && !listId) listId = meta.listId;
139
141
  if (meta.chunkType === "sender" && meta.textPreview && !from) {
140
142
  from = meta.textPreview;
141
143
  }
@@ -146,7 +148,7 @@ const filterMessageFromChunks = (
146
148
  textParts.push(meta.textPreview);
147
149
  }
148
150
  }
149
- return { from, fromName, subject, text: textParts.join("\n") };
151
+ return { from, fromName, subject, listId, text: textParts.join("\n") };
150
152
  };
151
153
 
152
154
  /**
@@ -355,8 +357,10 @@ const buildSemanticFromEnv = (): OrganizeSemanticDeps => {
355
357
  * deployment that ships no vector pipeline. Deduped by message id — the same
356
358
  * mail filed in two folders is one candidate.
357
359
  *
358
- * `From`/`Subject` come from the row verbatim (full fidelity). `text` (the body)
359
- * is left empty: the thread row carries only a truncated `snippet`, and matching
360
+ * `From`/`Subject`/`listId` come from the row verbatim (full fidelity), so a
361
+ * `From`, `Subject`, `FromDomain` or `ListId` clause matches here exactly as it
362
+ * does at index time. `text` (the body) is left empty: the thread row carries
363
+ * only a truncated `snippet`, and matching
360
364
  * a body-content clause against a preview would silently diverge from the live
361
365
  * index-time filter's full-body match. Body-content (`HasWords`) clauses are
362
366
  * rejected before this is read (see {@link assertNoBodyContentClause}), so the
@@ -384,6 +388,7 @@ const listAccountFilterMessagesFromClient =
384
388
  fromName: row.fromName ?? "",
385
389
  subject: row.subject ?? "",
386
390
  text: "",
391
+ listId: row.listId ?? "",
387
392
  },
388
393
  });
389
394
  if (candidates.length >= limit) return candidates;
@@ -1,89 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
3
- import { join } from "node:path";
4
- import { after, afterEach, before, describe, it } from "node:test";
5
- import { fileURLToPath } from "node:url";
6
- import { createInstanceOwnerStore } from "@remit/auth-service";
7
- import type { APIGatewayProxyEvent } from "aws-lambda";
8
- import Database from "better-sqlite3";
9
- import { requireInstanceOwner } from "./owner-guard.js";
10
-
11
- const tmpRoot = join(
12
- fileURLToPath(new URL(".", import.meta.url)),
13
- "..",
14
- "..",
15
- ".tmp",
16
- "owner-guard",
17
- );
18
-
19
- const readMigrationSql = (relativePath: string): string =>
20
- readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), "utf8");
21
-
22
- const authMigrationSql = readMigrationSql(
23
- "../../../../deploy/vps/migrations-sqlite/auth/0000_rainy_iron_monger.sql",
24
- );
25
- const metaMigrationSql = readMigrationSql(
26
- "../../../../deploy/vps/migrations-sqlite/meta/0000_demonic_lilith.sql",
27
- );
28
-
29
- let dbPath: string;
30
-
31
- before(() => {
32
- mkdirSync(tmpRoot, { recursive: true });
33
- dbPath = join(mkdtempSync(join(tmpRoot, "db-")), "app.db");
34
- const sqlite = new Database(dbPath);
35
- for (const sql of [authMigrationSql, metaMigrationSql]) {
36
- for (const statement of sql.split("--> statement-breakpoint")) {
37
- sqlite.exec(statement);
38
- }
39
- }
40
- sqlite
41
- .prepare(
42
- "INSERT INTO auth_user (id, name, email, email_verified, created_at, updated_at) VALUES (?, ?, ?, 0, ?, ?)",
43
- )
44
- .run("the-owner", "the-owner", "the-owner@example.com", 1000, 1000);
45
- sqlite.close();
46
- process.env.DATA_BACKEND = "sqlite";
47
- process.env.SQLITE_DB_PATH = dbPath;
48
- });
49
-
50
- after(() => {
51
- rmSync(tmpRoot, { recursive: true, force: true });
52
- });
53
-
54
- afterEach(() => {
55
- delete process.env.REMIT_OWNER_EMAIL;
56
- });
57
-
58
- const buildEvent = (sub?: string): APIGatewayProxyEvent =>
59
- ({
60
- headers: {},
61
- requestContext: sub ? { authorizer: { claims: { sub } } } : {},
62
- }) as unknown as APIGatewayProxyEvent;
63
-
64
- describe("requireInstanceOwner", () => {
65
- it("rejects a request with no authenticated caller", async () => {
66
- const result = await requireInstanceOwner(buildEvent());
67
-
68
- assert.equal(result?.statusCode, 403);
69
- });
70
-
71
- it("rejects a caller who has not claimed ownership", async () => {
72
- const result = await requireInstanceOwner(buildEvent("not-the-owner"));
73
-
74
- assert.equal(result?.statusCode, 403);
75
- });
76
-
77
- it("lets the instance owner through", async () => {
78
- const store = await createInstanceOwnerStore({
79
- provider: "sqlite",
80
- connectionString: dbPath,
81
- });
82
- await store.claimIfUnclaimed("the-owner");
83
- await store.close();
84
-
85
- const result = await requireInstanceOwner(buildEvent("the-owner"));
86
-
87
- assert.equal(result, null);
88
- });
89
- });
@@ -1,25 +0,0 @@
1
- import { isInstanceOwner } from "@remit/auth-service";
2
- import type { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
3
- import { getSubFromEvent } from "../auth.js";
4
-
5
- const forbidden = (message: string): APIGatewayProxyResult => ({
6
- statusCode: 403,
7
- headers: { "Content-Type": "application/json" },
8
- body: JSON.stringify({ message }),
9
- });
10
-
11
- /**
12
- * Gate a request to the standalone instance owner (RFC 037 D8) — the first
13
- * account to register, or whoever `REMIT_OWNER_EMAIL` names. Returns `null` to
14
- * let the request proceed, or a 403 `APIGatewayProxyResult` otherwise. Callers
15
- * wire this in ahead of the handler; it does not run the handler itself.
16
- */
17
- export const requireInstanceOwner = async (
18
- event: APIGatewayProxyEvent,
19
- ): Promise<APIGatewayProxyResult | null> => {
20
- const sub = getSubFromEvent(event);
21
- if (!sub) return forbidden("Instance owner required");
22
- if (!(await isInstanceOwner(sub)))
23
- return forbidden("Instance owner required");
24
- return null;
25
- };