@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
|
+
};
|
package/dev-server/server.ts
CHANGED
|
@@ -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.
|
|
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
|
-
"
|
|
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
|
+
};
|