@remit/backend 0.0.26 → 0.0.28

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.26",
3
+ "version": "0.0.28",
4
4
  "description": "Remit Mail Inspector API backend",
5
5
  "license": "MIT",
6
6
  "author": "",
@@ -16,6 +16,7 @@ import { OrganizeJobDetailOperations, OrganizeOperations } from "./organize.js";
16
16
  import { OutboxDetailOperations, OutboxOperations } from "./outbox.js";
17
17
  import { SemanticSearchOperations } from "./search.js";
18
18
  import { SyncOperations } from "./sync.js";
19
+ import { SystemOperations } from "./system-update.js";
19
20
  import { ThreadDetailOperations, ThreadOperations } from "./thread.js";
20
21
  import { UnifiedThreadOperations } from "./unified-threads.js";
21
22
 
@@ -23,6 +24,7 @@ import { UnifiedThreadOperations } from "./unified-threads.js";
23
24
  export const handlers: Record<OperationIds, OperationHandler<any>> = {
24
25
  ...MeOperations,
25
26
  ...ConfigOperations,
27
+ ...SystemOperations,
26
28
  ...AccountOperations,
27
29
  ...AccountDetailOperations,
28
30
  ...MicrosoftOAuthOperations,
@@ -0,0 +1,348 @@
1
+ import assert from "node:assert/strict";
2
+ import {
3
+ mkdirSync,
4
+ mkdtempSync,
5
+ readFileSync,
6
+ rmSync,
7
+ writeFileSync,
8
+ } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { after, afterEach, before, beforeEach, describe, it } from "node:test";
11
+ import { fileURLToPath } from "node:url";
12
+ import { createInstanceOwnerStore } from "@remit/auth-service";
13
+ import type { APIGatewayProxyEvent } from "aws-lambda";
14
+ import Database from "better-sqlite3";
15
+ import type { Context } from "openapi-backend";
16
+ import { SystemOperations } from "./system-update.js";
17
+
18
+ const tmpRoot = join(
19
+ fileURLToPath(new URL(".", import.meta.url)),
20
+ "..",
21
+ "..",
22
+ ".tmp",
23
+ "system-update",
24
+ );
25
+
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";
37
+ const MANIFEST_URL = "https://updates.example.com/stable.json";
38
+
39
+ let dbPath: string;
40
+ let controlDir: string;
41
+
42
+ const okState = {
43
+ currentVersion: "v1.4.1",
44
+ check: {
45
+ status: "ok",
46
+ lastCheckedAt: "2026-07-20T08:00:00Z",
47
+ latestVersion: "v1.5.0",
48
+ publishedAt: "2026-07-18T09:00:00Z",
49
+ summary: "Faster search.",
50
+ releaseNotesUrl: "https://example.com/notes",
51
+ updateAvailable: true,
52
+ },
53
+ run: null,
54
+ };
55
+
56
+ const writeState = (state: unknown): void => {
57
+ writeFileSync(join(controlDir, "state.json"), JSON.stringify(state));
58
+ };
59
+
60
+ before(async () => {
61
+ 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
+ });
84
+
85
+ after(() => {
86
+ rmSync(tmpRoot, { recursive: true, force: true });
87
+ });
88
+
89
+ beforeEach(() => {
90
+ controlDir = mkdtempSync(join(tmpRoot, "control-"));
91
+ process.env.REMIT_UPDATE_CONTROL_DIR = controlDir;
92
+ process.env.REMIT_UPDATE_MANIFEST_URL = MANIFEST_URL;
93
+ process.env.REMIT_TAG = "v1.4.1";
94
+ });
95
+
96
+ afterEach(() => {
97
+ delete process.env.REMIT_UPDATE_MANIFEST_URL;
98
+ delete process.env.REMIT_UPDATE_CONTROL_DIR;
99
+ delete process.env.REMIT_TAG;
100
+ });
101
+
102
+ const buildEvent = (sub?: string): APIGatewayProxyEvent =>
103
+ ({
104
+ headers: {},
105
+ requestContext: sub ? { authorizer: { claims: { sub } } } : {},
106
+ }) as unknown as APIGatewayProxyEvent;
107
+
108
+ const postContext = (targetVersion: string): Context =>
109
+ ({ request: { requestBody: { targetVersion } } }) as unknown as Context;
110
+
111
+ const getSystemUpdate =
112
+ SystemOperations.SystemOperations_getSystemUpdate as unknown as (
113
+ context: Context,
114
+ event: APIGatewayProxyEvent,
115
+ ) => Promise<unknown>;
116
+
117
+ const applySystemUpdate =
118
+ SystemOperations.SystemOperations_applySystemUpdate as unknown as (
119
+ context: Context,
120
+ event: APIGatewayProxyEvent,
121
+ ) => Promise<unknown>;
122
+
123
+ const getUpdate = (event: APIGatewayProxyEvent) =>
124
+ getSystemUpdate({} as unknown as Context, event);
125
+
126
+ const applyUpdate = (targetVersion: string, event: APIGatewayProxyEvent) =>
127
+ applySystemUpdate(postContext(targetVersion), event);
128
+
129
+ const hasStatus = (value: unknown, code: number): boolean =>
130
+ typeof value === "object" &&
131
+ value !== null &&
132
+ (value as { statusCode?: number }).statusCode === code;
133
+
134
+ describe("GET /system/update", () => {
135
+ it("returns 404 when no manifest URL is configured", async () => {
136
+ delete process.env.REMIT_UPDATE_MANIFEST_URL;
137
+
138
+ const result = await getUpdate(buildEvent(OWNER));
139
+
140
+ assert.ok(hasStatus(result, 404));
141
+ });
142
+
143
+ it("returns 403 for a non-owner", async () => {
144
+ const result = await getUpdate(buildEvent("someone-else"));
145
+
146
+ assert.ok(hasStatus(result, 403));
147
+ });
148
+
149
+ it("returns 200 with the state file verbatim", async () => {
150
+ writeState(okState);
151
+
152
+ const result = await getUpdate(buildEvent(OWNER));
153
+
154
+ assert.deepEqual(result, okState);
155
+ });
156
+
157
+ it("returns a disabled, empty resource when no state file exists", async () => {
158
+ const result = await getUpdate(buildEvent(OWNER));
159
+
160
+ assert.deepEqual(result, {
161
+ currentVersion: "v1.4.1",
162
+ check: { status: "disabled" },
163
+ run: null,
164
+ });
165
+ });
166
+
167
+ it("returns 200 with the run block untouched when the check has failed", async () => {
168
+ const run = {
169
+ runId: "run-1",
170
+ fromVersion: "v1.3.0",
171
+ targetVersion: "v1.4.1",
172
+ phase: "committing",
173
+ outcome: "succeeded",
174
+ startedAt: "2026-07-19T08:00:00Z",
175
+ updatedAt: "2026-07-19T08:05:00Z",
176
+ message: "Update installed.",
177
+ logCommand: "remit logs backend",
178
+ };
179
+ writeState({
180
+ currentVersion: "v1.4.1",
181
+ check: {
182
+ status: "failed",
183
+ lastCheckedAt: "2026-07-20T08:00:00Z",
184
+ error: "The update manifest could not be fetched.",
185
+ },
186
+ run,
187
+ });
188
+
189
+ const result = (await getUpdate(buildEvent(OWNER))) as {
190
+ check: { status: string };
191
+ run: unknown;
192
+ };
193
+
194
+ assert.equal(result.check.status, "failed");
195
+ assert.deepEqual(result.run, run);
196
+ });
197
+
198
+ it("passes both schema-version fields through when the state file carries them", async () => {
199
+ writeState({
200
+ currentVersion: "v1.4.1",
201
+ currentSchemaVersion: 5,
202
+ check: { ...okState.check, schemaVersion: 6 },
203
+ run: null,
204
+ });
205
+
206
+ const result = (await getUpdate(buildEvent(OWNER))) as {
207
+ currentSchemaVersion?: number;
208
+ check: { schemaVersion?: number };
209
+ };
210
+
211
+ assert.equal(result.currentSchemaVersion, 5);
212
+ assert.equal(result.check.schemaVersion, 6);
213
+ });
214
+
215
+ it("omits both schema-version fields when the state file predates them", async () => {
216
+ writeState(okState);
217
+
218
+ const result = (await getUpdate(buildEvent(OWNER))) as {
219
+ currentSchemaVersion?: number;
220
+ check: { schemaVersion?: number };
221
+ };
222
+
223
+ assert.ok(!("currentSchemaVersion" in result));
224
+ assert.ok(!("schemaVersion" in result.check));
225
+ });
226
+
227
+ it("carries currentSchemaVersion alone when the check has no schema version", async () => {
228
+ writeState({
229
+ currentVersion: "v1.4.1",
230
+ currentSchemaVersion: 5,
231
+ check: okState.check,
232
+ run: null,
233
+ });
234
+
235
+ const result = (await getUpdate(buildEvent(OWNER))) as {
236
+ currentSchemaVersion?: number;
237
+ check: { schemaVersion?: number };
238
+ };
239
+
240
+ assert.equal(result.currentSchemaVersion, 5);
241
+ assert.ok(!("schemaVersion" in result.check));
242
+ });
243
+
244
+ it("carries check.schemaVersion alone when currentSchemaVersion is absent", async () => {
245
+ writeState({
246
+ currentVersion: "v1.4.1",
247
+ check: { ...okState.check, schemaVersion: 6 },
248
+ run: null,
249
+ });
250
+
251
+ const result = (await getUpdate(buildEvent(OWNER))) as {
252
+ currentSchemaVersion?: number;
253
+ check: { schemaVersion?: number };
254
+ };
255
+
256
+ assert.ok(!("currentSchemaVersion" in result));
257
+ assert.equal(result.check.schemaVersion, 6);
258
+ });
259
+ });
260
+
261
+ describe("POST /system/update", () => {
262
+ it("returns 404 when no manifest URL is configured", async () => {
263
+ delete process.env.REMIT_UPDATE_MANIFEST_URL;
264
+
265
+ const result = await applyUpdate("v1.5.0", buildEvent(OWNER));
266
+
267
+ assert.ok(hasStatus(result, 404));
268
+ });
269
+
270
+ it("returns 403 for a non-owner", async () => {
271
+ writeState(okState);
272
+
273
+ const result = await applyUpdate("v1.5.0", buildEvent("someone-else"));
274
+
275
+ assert.ok(hasStatus(result, 403));
276
+ });
277
+
278
+ it("returns 409 when a run is already in flight", async () => {
279
+ writeState({
280
+ ...okState,
281
+ run: {
282
+ runId: "run-9",
283
+ fromVersion: "v1.4.1",
284
+ targetVersion: "v1.5.0",
285
+ phase: "verifying",
286
+ outcome: null,
287
+ startedAt: "2026-07-20T08:01:00Z",
288
+ updatedAt: "2026-07-20T08:04:00Z",
289
+ message: "Waiting for the new version to answer.",
290
+ logCommand: "remit logs backend",
291
+ },
292
+ });
293
+
294
+ await assert.rejects(applyUpdate("v1.5.0", buildEvent(OWNER)), {
295
+ statusCode: 409,
296
+ });
297
+ });
298
+
299
+ it("returns 422 when the target is not the checked version", async () => {
300
+ writeState(okState);
301
+
302
+ await assert.rejects(applyUpdate("v9.9.9", buildEvent(OWNER)), {
303
+ statusCode: 422,
304
+ });
305
+ });
306
+
307
+ it("returns 422 when no check has ever succeeded", async () => {
308
+ await assert.rejects(applyUpdate("v1.5.0", buildEvent(OWNER)), {
309
+ statusCode: 422,
310
+ });
311
+ });
312
+
313
+ it("returns 202 with a run block carrying a fresh runId", async () => {
314
+ writeState(okState);
315
+
316
+ const result = (await applyUpdate("v1.5.0", buildEvent(OWNER))) as {
317
+ statusCode: number;
318
+ body: { run: { runId: string; targetVersion: string; outcome: null } };
319
+ };
320
+
321
+ assert.equal(result.statusCode, 202);
322
+ assert.equal(result.body.run.targetVersion, "v1.5.0");
323
+ assert.equal(result.body.run.outcome, null);
324
+ assert.ok(result.body.run.runId.length > 0);
325
+ });
326
+
327
+ it("writes request.json with exactly the four seam fields and nothing else", async () => {
328
+ writeState(okState);
329
+
330
+ await applyUpdate("v1.5.0", buildEvent(OWNER));
331
+
332
+ const request = JSON.parse(
333
+ readFileSync(join(controlDir, "request.json"), "utf8"),
334
+ );
335
+
336
+ assert.deepEqual(Object.keys(request).sort(), [
337
+ "requestedAt",
338
+ "requestedBy",
339
+ "runId",
340
+ "targetVersion",
341
+ ]);
342
+ assert.equal(request.targetVersion, "v1.5.0");
343
+ assert.equal(request.requestedBy, OWNER);
344
+ for (const forbidden of ["registry", "image", "digest"]) {
345
+ assert.ok(!(forbidden in request));
346
+ }
347
+ });
348
+ });
@@ -0,0 +1,189 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { readFileSync, renameSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import type {
5
+ SystemUpdateResponse,
6
+ SystemUpdateRun,
7
+ } from "@remit/api-openapi-types";
8
+ import { ConflictError, HTTPError } from "@remit/data-ports/errors";
9
+ import type { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
10
+ import type { Context } from "openapi-backend";
11
+ import { getSubFromEvent } from "../auth.js";
12
+ import type { OperationHandler, SystemOperationIds } from "../types.js";
13
+ import { requireInstanceOwner } from "./owner-guard.js";
14
+
15
+ class TargetVersionMismatchError extends HTTPError {
16
+ name = "TargetVersionMismatchError";
17
+ statusCode = 422;
18
+ }
19
+
20
+ const DEFAULT_CONTROL_DIR = "/data/control";
21
+
22
+ const controlDir = (): string =>
23
+ process.env.REMIT_UPDATE_CONTROL_DIR ?? DEFAULT_CONTROL_DIR;
24
+
25
+ const manifestUrl = (): string | undefined =>
26
+ process.env.REMIT_UPDATE_MANIFEST_URL;
27
+
28
+ const currentVersion = (): string => process.env.REMIT_TAG ?? "unknown";
29
+
30
+ const notFound = (): APIGatewayProxyResult => ({
31
+ statusCode: 404,
32
+ headers: { "Content-Type": "application/json" },
33
+ body: JSON.stringify({ message: "Not found" }),
34
+ });
35
+
36
+ /**
37
+ * The self-update seam is off unless a manifest URL is configured (RFC 037 D8),
38
+ * 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.
41
+ */
42
+ const guardManifestConfigured = (): APIGatewayProxyResult | null =>
43
+ manifestUrl() ? null : notFound();
44
+
45
+ /**
46
+ * Read `state.json` off the control volume. A missing file is an instance that
47
+ * has never checked or updated (RFC 037 D8), not an error, so it reads as
48
+ * `null`; the caller renders the disabled/empty resource. Any other read or
49
+ * parse failure propagates.
50
+ */
51
+ const readState = (): SystemUpdateResponse | null => {
52
+ let raw: string;
53
+ try {
54
+ raw = readFileSync(join(controlDir(), "state.json"), "utf8");
55
+ } catch (error) {
56
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
57
+ throw error;
58
+ }
59
+ return JSON.parse(raw) as SystemUpdateResponse;
60
+ };
61
+
62
+ const emptyResource = (): SystemUpdateResponse => ({
63
+ currentVersion: currentVersion(),
64
+ check: { status: "disabled" },
65
+ run: null,
66
+ });
67
+
68
+ const isRunInFlight = (run: SystemUpdateRun | null | undefined): boolean =>
69
+ run != null && run.outcome === null;
70
+
71
+ /**
72
+ * The version the caller may install is the one the last check reported. It
73
+ * exists only on a successful check; a disabled or failed check offers nothing
74
+ * to match, so any target is a mismatch.
75
+ */
76
+ const checkedVersion = (
77
+ state: SystemUpdateResponse | null,
78
+ ): string | undefined =>
79
+ state?.check.status === "ok" ? state.check.latestVersion : undefined;
80
+
81
+ /**
82
+ * Write `request.json` atomically with exactly the four fields the seam accepts
83
+ * (#133). The updater resolves the registry and every image reference from the
84
+ * manifest it fetches itself; a request naming any of those is rejected whole,
85
+ * so the backend never writes one. The temp file sits in the same directory as
86
+ * the target for the rename to be atomic on one filesystem.
87
+ */
88
+ const writeRequest = (request: {
89
+ runId: string;
90
+ targetVersion: string;
91
+ requestedAt: string;
92
+ requestedBy: string;
93
+ }): void => {
94
+ const dir = controlDir();
95
+ const tmp = join(dir, `.request.json.${request.runId}.tmp`);
96
+ writeFileSync(tmp, JSON.stringify(request), { mode: 0o644 });
97
+ renameSync(tmp, join(dir, "request.json"));
98
+ };
99
+
100
+ /**
101
+ * The resource returned by the POST. The updater has not yet written the
102
+ * authoritative run — it polls the seam — so this bootstraps the run block with
103
+ * the id just requested and the first phase, giving the client a `runId` to
104
+ * persist and poll (RFC 037 D9). The updater's own `state.json` supersedes it on
105
+ * the next read.
106
+ */
107
+ const requestedResource = (
108
+ state: SystemUpdateResponse | null,
109
+ runId: string,
110
+ targetVersion: string,
111
+ requestedAt: string,
112
+ ): SystemUpdateResponse => {
113
+ const from = state?.currentVersion ?? currentVersion();
114
+ return {
115
+ currentVersion: from,
116
+ check: state?.check ?? { status: "disabled" },
117
+ run: {
118
+ runId,
119
+ fromVersion: from,
120
+ targetVersion,
121
+ phase: "checking",
122
+ outcome: null,
123
+ startedAt: requestedAt,
124
+ updatedAt: requestedAt,
125
+ message: "The update has been requested.",
126
+ logCommand: "remit logs updater",
127
+ },
128
+ };
129
+ };
130
+
131
+ export const SystemOperations: Record<
132
+ SystemOperationIds,
133
+ OperationHandler<SystemOperationIds>
134
+ > = {
135
+ SystemOperations_getSystemUpdate: async (
136
+ _context: Context,
137
+ ...args: unknown[]
138
+ ): Promise<SystemUpdateResponse | APIGatewayProxyResult> => {
139
+ const offSurface = guardManifestConfigured();
140
+ if (offSurface) return offSurface;
141
+
142
+ const event = args[0] as APIGatewayProxyEvent;
143
+ const forbidden = await requireInstanceOwner(event);
144
+ if (forbidden) return forbidden;
145
+
146
+ return readState() ?? emptyResource();
147
+ },
148
+
149
+ SystemOperations_applySystemUpdate: async (
150
+ context: Context,
151
+ ...args: unknown[]
152
+ ): Promise<SystemUpdateResponse | APIGatewayProxyResult> => {
153
+ const offSurface = guardManifestConfigured();
154
+ if (offSurface) return offSurface;
155
+
156
+ const event = args[0] as APIGatewayProxyEvent;
157
+ const forbidden = await requireInstanceOwner(event);
158
+ if (forbidden) return forbidden;
159
+
160
+ const { targetVersion } = context.request.requestBody as {
161
+ targetVersion: string;
162
+ };
163
+ const state = readState();
164
+
165
+ if (isRunInFlight(state?.run)) {
166
+ throw new ConflictError("An update is already in progress.");
167
+ }
168
+
169
+ if (targetVersion !== checkedVersion(state)) {
170
+ throw new TargetVersionMismatchError(
171
+ "targetVersion does not match the latest checked release.",
172
+ );
173
+ }
174
+
175
+ const runId = randomUUID();
176
+ const requestedAt = new Date().toISOString();
177
+ writeRequest({
178
+ runId,
179
+ targetVersion,
180
+ requestedAt,
181
+ requestedBy: getSubFromEvent(event) ?? "",
182
+ });
183
+
184
+ return {
185
+ statusCode: 202,
186
+ body: requestedResource(state, runId, targetVersion, requestedAt),
187
+ } as unknown as APIGatewayProxyResult;
188
+ },
189
+ };
package/src/types.ts CHANGED
@@ -12,6 +12,8 @@ export type OperationIds =
12
12
  | "MeOperations_getExport"
13
13
  | "MeOperations_listQuarantine"
14
14
  | "ConfigOperations_getConfig"
15
+ | "SystemOperations_getSystemUpdate"
16
+ | "SystemOperations_applySystemUpdate"
15
17
  | "AccountOperations_createAccount"
16
18
  | "AccountOperations_testConnection"
17
19
  | "AccountDetailOperations_updateAccount"
@@ -60,6 +62,8 @@ export type MeOperationIds = MatchPrefix<"MeOperations_", OperationIds>;
60
62
 
61
63
  export type ConfigOperationIds = MatchPrefix<"ConfigOperations_", OperationIds>;
62
64
 
65
+ export type SystemOperationIds = MatchPrefix<"SystemOperations_", OperationIds>;
66
+
63
67
  export type AccountOperationIds = MatchPrefix<
64
68
  "AccountOperations_",
65
69
  OperationIds