@remit/backend 0.0.17 → 0.0.19

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.
@@ -0,0 +1,91 @@
1
+ import assert from "node:assert/strict";
2
+ import { chmodSync, mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { afterEach, describe, it } from "node:test";
6
+ import Database from "better-sqlite3";
7
+ import { checkRelationalStore } from "./relational-health.js";
8
+
9
+ describe("checkRelationalStore", () => {
10
+ const ORIGINAL_DATA_BACKEND = process.env.DATA_BACKEND;
11
+ const ORIGINAL_SQLITE_DB_PATH = process.env.SQLITE_DB_PATH;
12
+ let dir: string | undefined;
13
+
14
+ afterEach(() => {
15
+ if (ORIGINAL_DATA_BACKEND === undefined) delete process.env.DATA_BACKEND;
16
+ else process.env.DATA_BACKEND = ORIGINAL_DATA_BACKEND;
17
+ if (ORIGINAL_SQLITE_DB_PATH === undefined) {
18
+ delete process.env.SQLITE_DB_PATH;
19
+ } else {
20
+ process.env.SQLITE_DB_PATH = ORIGINAL_SQLITE_DB_PATH;
21
+ }
22
+ if (dir) {
23
+ rmSync(dir, { recursive: true, force: true });
24
+ dir = undefined;
25
+ }
26
+ });
27
+
28
+ it("is true without touching disk on the AWS/DynamoDB path", async () => {
29
+ delete process.env.DATA_BACKEND;
30
+ delete process.env.SQLITE_DB_PATH;
31
+ assert.equal(await checkRelationalStore(), true);
32
+
33
+ process.env.DATA_BACKEND = "dynamodb";
34
+ assert.equal(await checkRelationalStore(), true);
35
+ });
36
+
37
+ it("is true when the SQLite file is reachable", async () => {
38
+ dir = mkdtempSync(join(tmpdir(), "remit-health-"));
39
+ const dbPath = join(dir, "remit.db");
40
+ new Database(dbPath).close();
41
+
42
+ process.env.DATA_BACKEND = "sqlite";
43
+ process.env.SQLITE_DB_PATH = dbPath;
44
+
45
+ assert.equal(await checkRelationalStore(), true);
46
+ });
47
+
48
+ it("is false when SQLITE_DB_PATH points at a directory that does not exist", async () => {
49
+ process.env.DATA_BACKEND = "sqlite";
50
+ process.env.SQLITE_DB_PATH = "/nonexistent/remit.db";
51
+
52
+ assert.equal(await checkRelationalStore(), false);
53
+ });
54
+
55
+ it("is false when the SQLite file exists but is unreadable", async () => {
56
+ dir = mkdtempSync(join(tmpdir(), "remit-health-"));
57
+ const dbPath = join(dir, "remit.db");
58
+ new Database(dbPath).close();
59
+ chmodSync(dbPath, 0o000);
60
+
61
+ process.env.DATA_BACKEND = "sqlite";
62
+ process.env.SQLITE_DB_PATH = dbPath;
63
+
64
+ assert.equal(await checkRelationalStore(), false);
65
+ });
66
+
67
+ it("is false when the file is readable but no longer writable", async () => {
68
+ dir = mkdtempSync(join(tmpdir(), "remit-health-"));
69
+ const dbPath = join(dir, "remit.db");
70
+ new Database(dbPath).close();
71
+
72
+ process.env.DATA_BACKEND = "sqlite";
73
+ process.env.SQLITE_DB_PATH = dbPath;
74
+ assert.equal(await checkRelationalStore(), true);
75
+
76
+ // Readable, not writable: the read-only SELECT ping still opens and
77
+ // succeeds, so without `access(W_OK)` this reports healthy on a store the
78
+ // backend can no longer write to. This is the case the writability check
79
+ // exists to catch — dropping `access()` from pingSqlite fails this test.
80
+ chmodSync(dbPath, 0o400);
81
+ assert.equal(await checkRelationalStore(), false);
82
+ });
83
+
84
+ it("is false when Postgres connection fails", async () => {
85
+ process.env.DATA_BACKEND = "postgres";
86
+ process.env.PG_CONNECTION_URL =
87
+ "postgresql://remit:remit@127.0.0.1:1/nonexistent";
88
+
89
+ assert.equal(await checkRelationalStore(), false);
90
+ });
91
+ });
@@ -0,0 +1,57 @@
1
+ import { access, constants } from "node:fs/promises";
2
+ import { isSelfHostSqlBackend } from "../src/data-backend.js";
3
+
4
+ // The SELECT ping opens a read-only connection, so it keeps succeeding on a
5
+ // file that can still be read but no longer written — permissions revoked to
6
+ // read-only, or the volume remounted read-only under the process. The backend
7
+ // must write (better-auth persists sessions to this same file, RFC 036 D3), so
8
+ // a readable-but-unwritable store has to report unhealthy. `access(R_OK|W_OK)`
9
+ // checks both bits in one fresh syscall; the read-only query never covers
10
+ // writability. The query after it still exercises the SQL engine itself.
11
+ const pingSqlite = async (): Promise<void> => {
12
+ const path = process.env.SQLITE_DB_PATH ?? "";
13
+ await access(path, constants.R_OK | constants.W_OK);
14
+
15
+ const { default: Database } = await import("better-sqlite3");
16
+ const db = new Database(path, { readonly: true });
17
+ try {
18
+ db.prepare("SELECT 1").get();
19
+ } finally {
20
+ db.close();
21
+ }
22
+ };
23
+
24
+ const pingPostgres = async (): Promise<void> => {
25
+ const { Client } = await import("pg");
26
+ const client = new Client({
27
+ connectionString: process.env.PG_CONNECTION_URL,
28
+ connectionTimeoutMillis: 3000,
29
+ });
30
+ await client.connect();
31
+ try {
32
+ await client.query("SELECT 1");
33
+ } finally {
34
+ await client.end();
35
+ }
36
+ };
37
+
38
+ /**
39
+ * A trivial read against whichever relational store DATA_BACKEND selects — the
40
+ * dependency `/health` must actually exercise (RFC 037 D5, R9) so a backend that
41
+ * booted with an unusable database reports unhealthy instead of a bare 200. The
42
+ * AWS/DynamoDB path has no relational store and is never checked.
43
+ */
44
+ export const checkRelationalStore = async (): Promise<boolean> => {
45
+ if (!isSelfHostSqlBackend()) return true;
46
+
47
+ try {
48
+ if (process.env.DATA_BACKEND === "sqlite") {
49
+ await pingSqlite();
50
+ } else {
51
+ await pingPostgres();
52
+ }
53
+ return true;
54
+ } catch {
55
+ return false;
56
+ }
57
+ };
@@ -16,6 +16,7 @@ import { serveContent } from "./content-handler.js";
16
16
  import { resolveContentPath } from "./content-path.js";
17
17
  import { parseAllowedOrigins, resolveAllowOrigin } from "./cors.js";
18
18
  import { createLambdaContext, createLambdaEvent } from "./lambda-helpers.js";
19
+ import { checkRelationalStore } from "./relational-health.js";
19
20
 
20
21
  const app = express();
21
22
 
@@ -123,7 +124,12 @@ app.use((req: Request, res: Response, next: NextFunction) => {
123
124
 
124
125
  app.get("/.well-known/appspecific/com.chrome.devtools.json", () => ({}));
125
126
 
126
- app.get("/health", (_req: Request, res: Response) => {
127
+ app.get("/health", async (_req: Request, res: Response) => {
128
+ const relationalStoreReachable = await checkRelationalStore();
129
+ if (!relationalStoreReachable) {
130
+ res.status(503).json({ status: "unhealthy" });
131
+ return;
132
+ }
127
133
  res.json({
128
134
  status: "ok",
129
135
  timestamp: new Date().toISOString(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/backend",
3
- "version": "0.0.17",
3
+ "version": "0.0.19",
4
4
  "description": "Remit Mail Inspector API backend",
5
5
  "license": "MIT",
6
6
  "author": "",
@@ -45,8 +45,10 @@
45
45
  "@aws-sdk/core": "*",
46
46
  "@remit/electrodb-entities": "*",
47
47
  "@remit/search-index-worker": "*",
48
+ "@types/better-sqlite3": "^7.6.13",
48
49
  "@types/express": "^5.0.3",
49
50
  "@types/swagger-ui-express": "^4.1.8",
51
+ "better-sqlite3": "^12.11.1",
50
52
  "electrodb": "*",
51
53
  "esbuild": "^0.27.7",
52
54
  "express": "^5.1.0",
@@ -70,11 +72,15 @@
70
72
  "@remit/sqs-client": "*",
71
73
  "@aws-sdk/client-secrets-manager": "*",
72
74
  "@aws-sdk/client-sqs": "*",
75
+ "better-sqlite3": "^12.11.1",
73
76
  "drizzle-orm": "^0.45.2",
74
77
  "expect-env": "*",
75
78
  "openapi-backend": "*",
76
79
  "p-map": "*",
77
- "@types/aws-lambda": "*"
80
+ "pg": "^8.0.0",
81
+ "@types/aws-lambda": "*",
82
+ "@types/better-sqlite3": "^7.6.13",
83
+ "@types/pg": "^8.0.0"
78
84
  },
79
85
  "publishConfig": {
80
86
  "access": "public"
@@ -0,0 +1,89 @@
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
+ });
@@ -0,0 +1,25 @@
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
+ };