@remit/backend 0.0.30 → 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.30",
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 {
@@ -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
- };