@remit/backend 0.0.30 → 0.0.32

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.32",
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,30 +101,43 @@ 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);
145
111
 
146
- assert.ok(hasStatus(result, 403));
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"));
121
+
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
- it("returns a disabled, empty resource when no state file exists", async () => {
158
- const result = await getUpdate(buildEvent(OWNER));
133
+ it("reports an unknown version, not its own process env, when no state file exists", async () => {
134
+ // The updater owns the running version and writes it into state.json; with
135
+ // no state file the backend has nothing authoritative, so it says unknown
136
+ // rather than surfacing REMIT_TAG, which drifts from the real running tag.
137
+ const result = await getUpdate(buildEvent(USER));
159
138
 
160
139
  assert.deepEqual(result, {
161
- currentVersion: "v1.4.1",
140
+ currentVersion: "unknown",
162
141
  check: { status: "disabled" },
163
142
  run: null,
164
143
  });
@@ -186,7 +165,7 @@ describe("GET /system/update", () => {
186
165
  run,
187
166
  });
188
167
 
189
- const result = (await getUpdate(buildEvent(OWNER))) as {
168
+ const result = (await getUpdate(buildEvent(USER))) as {
190
169
  check: { status: string };
191
170
  run: unknown;
192
171
  };
@@ -203,7 +182,7 @@ describe("GET /system/update", () => {
203
182
  run: null,
204
183
  });
205
184
 
206
- const result = (await getUpdate(buildEvent(OWNER))) as {
185
+ const result = (await getUpdate(buildEvent(USER))) as {
207
186
  currentSchemaVersion?: number;
208
187
  check: { schemaVersion?: number };
209
188
  };
@@ -215,7 +194,7 @@ describe("GET /system/update", () => {
215
194
  it("omits both schema-version fields when the state file predates them", async () => {
216
195
  writeState(okState);
217
196
 
218
- const result = (await getUpdate(buildEvent(OWNER))) as {
197
+ const result = (await getUpdate(buildEvent(USER))) as {
219
198
  currentSchemaVersion?: number;
220
199
  check: { schemaVersion?: number };
221
200
  };
@@ -232,7 +211,7 @@ describe("GET /system/update", () => {
232
211
  run: null,
233
212
  });
234
213
 
235
- const result = (await getUpdate(buildEvent(OWNER))) as {
214
+ const result = (await getUpdate(buildEvent(USER))) as {
236
215
  currentSchemaVersion?: number;
237
216
  check: { schemaVersion?: number };
238
217
  };
@@ -248,7 +227,7 @@ describe("GET /system/update", () => {
248
227
  run: null,
249
228
  });
250
229
 
251
- const result = (await getUpdate(buildEvent(OWNER))) as {
230
+ const result = (await getUpdate(buildEvent(USER))) as {
252
231
  currentSchemaVersion?: number;
253
232
  check: { schemaVersion?: number };
254
233
  };
@@ -262,17 +241,34 @@ describe("POST /system/update", () => {
262
241
  it("returns 404 when no manifest URL is configured", async () => {
263
242
  delete process.env.REMIT_UPDATE_MANIFEST_URL;
264
243
 
265
- const result = await applyUpdate("v1.5.0", buildEvent(OWNER));
244
+ const result = await applyUpdate("v1.5.0", buildEvent(USER));
266
245
 
267
246
  assert.ok(hasStatus(result, 404));
268
247
  });
269
248
 
270
- it("returns 403 for a non-owner", async () => {
249
+ it("returns 401 without writing request.json when the caller is not authenticated", async () => {
250
+ writeState(okState);
251
+
252
+ const result = await applyUpdate("v1.5.0", buildEvent());
253
+
254
+ assert.ok(hasStatus(result, 401));
255
+ assert.throws(() => readFileSync(join(controlDir, "request.json"), "utf8"));
256
+ });
257
+
258
+ it("returns 202 for any authenticated caller and records that identity", async () => {
271
259
  writeState(okState);
272
260
 
273
- const result = await applyUpdate("v1.5.0", buildEvent("someone-else"));
261
+ const result = (await applyUpdate(
262
+ "v1.5.0",
263
+ buildEvent("another-signed-in-user"),
264
+ )) as { statusCode: number };
265
+
266
+ assert.equal(result.statusCode, 202);
274
267
 
275
- assert.ok(hasStatus(result, 403));
268
+ const request = JSON.parse(
269
+ readFileSync(join(controlDir, "request.json"), "utf8"),
270
+ );
271
+ assert.equal(request.requestedBy, "another-signed-in-user");
276
272
  });
277
273
 
278
274
  it("returns 409 when a run is already in flight", async () => {
@@ -291,7 +287,7 @@ describe("POST /system/update", () => {
291
287
  },
292
288
  });
293
289
 
294
- await assert.rejects(applyUpdate("v1.5.0", buildEvent(OWNER)), {
290
+ await assert.rejects(applyUpdate("v1.5.0", buildEvent(USER)), {
295
291
  statusCode: 409,
296
292
  });
297
293
  });
@@ -299,13 +295,13 @@ describe("POST /system/update", () => {
299
295
  it("returns 422 when the target is not the checked version", async () => {
300
296
  writeState(okState);
301
297
 
302
- await assert.rejects(applyUpdate("v9.9.9", buildEvent(OWNER)), {
298
+ await assert.rejects(applyUpdate("v9.9.9", buildEvent(USER)), {
303
299
  statusCode: 422,
304
300
  });
305
301
  });
306
302
 
307
303
  it("returns 422 when no check has ever succeeded", async () => {
308
- await assert.rejects(applyUpdate("v1.5.0", buildEvent(OWNER)), {
304
+ await assert.rejects(applyUpdate("v1.5.0", buildEvent(USER)), {
309
305
  statusCode: 422,
310
306
  });
311
307
  });
@@ -313,7 +309,7 @@ describe("POST /system/update", () => {
313
309
  it("returns 202 with a run block carrying a fresh runId", async () => {
314
310
  writeState(okState);
315
311
 
316
- const result = (await applyUpdate("v1.5.0", buildEvent(OWNER))) as {
312
+ const result = (await applyUpdate("v1.5.0", buildEvent(USER))) as {
317
313
  statusCode: number;
318
314
  body: { run: { runId: string; targetVersion: string; outcome: null } };
319
315
  };
@@ -327,7 +323,7 @@ describe("POST /system/update", () => {
327
323
  it("writes request.json with exactly the four seam fields and nothing else", async () => {
328
324
  writeState(okState);
329
325
 
330
- await applyUpdate("v1.5.0", buildEvent(OWNER));
326
+ await applyUpdate("v1.5.0", buildEvent(USER));
331
327
 
332
328
  const request = JSON.parse(
333
329
  readFileSync(join(controlDir, "request.json"), "utf8"),
@@ -340,7 +336,7 @@ describe("POST /system/update", () => {
340
336
  "targetVersion",
341
337
  ]);
342
338
  assert.equal(request.targetVersion, "v1.5.0");
343
- assert.equal(request.requestedBy, OWNER);
339
+ assert.equal(request.requestedBy, USER);
344
340
  for (const forbidden of ["registry", "image", "digest"]) {
345
341
  assert.ok(!(forbidden in request));
346
342
  }
@@ -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";
@@ -25,7 +24,17 @@ const controlDir = (): string =>
25
24
  const manifestUrl = (): string | undefined =>
26
25
  process.env.REMIT_UPDATE_MANIFEST_URL;
27
26
 
28
- const currentVersion = (): string => process.env.REMIT_TAG ?? "unknown";
27
+ /**
28
+ * The running version is a fact the updater owns: it reads the tag from `.env`
29
+ * and writes it into `state.json`, which is the only honest source across an
30
+ * update that rewrites that tag underneath a long-lived backend process. When no
31
+ * state file exists yet the backend has nothing authoritative to report, so it
32
+ * reports the version as unknown rather than fabricating one from its own
33
+ * process environment — a value captured at container start that drifts from the
34
+ * real running tag the moment an update lands. The first updater check writes a
35
+ * real `currentVersion` within seconds of the stack coming up.
36
+ */
37
+ const UNKNOWN_VERSION = "unknown";
29
38
 
30
39
  const notFound = (): APIGatewayProxyResult => ({
31
40
  statusCode: 404,
@@ -33,11 +42,17 @@ const notFound = (): APIGatewayProxyResult => ({
33
42
  body: JSON.stringify({ message: "Not found" }),
34
43
  });
35
44
 
45
+ const unauthorized = (): APIGatewayProxyResult => ({
46
+ statusCode: 401,
47
+ headers: { "Content-Type": "application/json" },
48
+ body: JSON.stringify({ message: "Unauthorized" }),
49
+ });
50
+
36
51
  /**
37
52
  * The self-update seam is off unless a manifest URL is configured (RFC 037 D8),
38
53
  * 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.
54
+ * case. Where it is on, any authenticated caller may use it the account list
55
+ * is the trust boundary of a self-hosted instance.
41
56
  */
42
57
  const guardManifestConfigured = (): APIGatewayProxyResult | null =>
43
58
  manifestUrl() ? null : notFound();
@@ -60,7 +75,7 @@ const readState = (): SystemUpdateResponse | null => {
60
75
  };
61
76
 
62
77
  const emptyResource = (): SystemUpdateResponse => ({
63
- currentVersion: currentVersion(),
78
+ currentVersion: UNKNOWN_VERSION,
64
79
  check: { status: "disabled" },
65
80
  run: null,
66
81
  });
@@ -110,7 +125,7 @@ const requestedResource = (
110
125
  targetVersion: string,
111
126
  requestedAt: string,
112
127
  ): SystemUpdateResponse => {
113
- const from = state?.currentVersion ?? currentVersion();
128
+ const from = state?.currentVersion ?? UNKNOWN_VERSION;
114
129
  return {
115
130
  currentVersion: from,
116
131
  check: state?.check ?? { status: "disabled" },
@@ -140,8 +155,7 @@ export const SystemOperations: Record<
140
155
  if (offSurface) return offSurface;
141
156
 
142
157
  const event = args[0] as APIGatewayProxyEvent;
143
- const forbidden = await requireInstanceOwner(event);
144
- if (forbidden) return forbidden;
158
+ if (!getSubFromEvent(event)) return unauthorized();
145
159
 
146
160
  return readState() ?? emptyResource();
147
161
  },
@@ -154,8 +168,8 @@ export const SystemOperations: Record<
154
168
  if (offSurface) return offSurface;
155
169
 
156
170
  const event = args[0] as APIGatewayProxyEvent;
157
- const forbidden = await requireInstanceOwner(event);
158
- if (forbidden) return forbidden;
171
+ const sub = getSubFromEvent(event);
172
+ if (!sub) return unauthorized();
159
173
 
160
174
  const { targetVersion } = context.request.requestBody as {
161
175
  targetVersion: string;
@@ -178,7 +192,7 @@ export const SystemOperations: Record<
178
192
  runId,
179
193
  targetVersion,
180
194
  requestedAt,
181
- requestedBy: getSubFromEvent(event) ?? "",
195
+ requestedBy: sub,
182
196
  });
183
197
 
184
198
  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
- };