pgserve 1.1.10 → 2.0.0
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/.genie/brainstorms/pgserve-v2/DESIGN.md +174 -0
- package/.genie/wishes/pgserve-v2/BRIEF-from-genie-pgserve.md +99 -0
- package/.genie/wishes/pgserve-v2/WISH.md +442 -0
- package/.genie/wishes/release-system-genie-pattern/WISH.md +268 -0
- package/.genie/wishes/release-system-genie-pattern/validation.md +205 -0
- package/.github/workflows/ci.yml +8 -4
- package/.github/workflows/release.yml +233 -111
- package/.github/workflows/{build-all-platforms.yml → version.yml} +32 -8
- package/AGENTS.md +10 -8
- package/CHANGELOG.md +150 -0
- package/Makefile +18 -41
- package/README.md +186 -1
- package/SECURITY.md +109 -0
- package/bin/pglite-server.js +253 -1
- package/eslint.config.js +2 -0
- package/package.json +1 -1
- package/src/admin-client.js +171 -0
- package/src/audit.js +168 -0
- package/src/control-db.js +313 -0
- package/src/daemon-control.js +408 -0
- package/src/daemon-shared.js +18 -0
- package/src/daemon-tcp.js +296 -0
- package/src/daemon.js +629 -0
- package/src/fingerprint.js +453 -0
- package/src/gc.js +351 -0
- package/src/index.js +11 -0
- package/src/postgres.js +54 -0
- package/src/protocol.js +131 -0
- package/src/router.js +78 -5
- package/src/tenancy.js +75 -0
- package/src/tokens.js +102 -0
- package/tests/audit.test.js +189 -0
- package/tests/control-db.test.js +285 -0
- package/tests/daemon-fingerprint-integration.test.js +109 -0
- package/tests/daemon-pr24-regression.test.js +201 -0
- package/tests/fingerprint.test.js +249 -0
- package/tests/fixtures/240-orphan-seed.sql +30 -0
- package/tests/multi-tenant.test.js +164 -0
- package/tests/orphan-cleanup.test.js +390 -0
- package/tests/tcp-listen.test.js +368 -0
- package/tests/tenancy.test.js +403 -0
- package/.github/release.yml +0 -30
- package/scripts/release.cjs +0 -198
package/package.json
CHANGED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admin DB client — small Bun.SQL wrapper exposing the `{query, end}`
|
|
3
|
+
* surface that `src/control-db.js` expects.
|
|
4
|
+
*
|
|
5
|
+
* The daemon and the `pgserve daemon issue-token / revoke-token` CLI
|
|
6
|
+
* subcommands both need a privileged connection to the underlying
|
|
7
|
+
* Postgres instance owned by `PostgresManager`. The pg npm module is
|
|
8
|
+
* a devDependency only (it backs the test harness); rather than promote
|
|
9
|
+
* it to runtime we wrap Bun.SQL — which is shipped with the runtime —
|
|
10
|
+
* in the parameterised-query interface control-db.js documents.
|
|
11
|
+
*
|
|
12
|
+
* Connection target:
|
|
13
|
+
* - Local Unix socket when `socketDir` is provided (the daemon's
|
|
14
|
+
* hot path) — drops the bytes onto the kernel-local socket.
|
|
15
|
+
* - TCP fallback when `socketDir` is null (e.g. CI hosts without
|
|
16
|
+
* the embedded socket directory present).
|
|
17
|
+
*
|
|
18
|
+
* The CLI side reads the daemon's discovery file at
|
|
19
|
+
* `${controlSocketDir}/admin.json` to learn `{socketDir, port}`.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { SQL } from 'bun';
|
|
23
|
+
import fs from 'fs';
|
|
24
|
+
import path from 'path';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {object} args
|
|
28
|
+
* @param {string|null} [args.socketDir] — accepted for parity with the
|
|
29
|
+
* embedded-postgres callers but unused; Bun.SQL's startup auth path
|
|
30
|
+
* does not currently traverse `pg_hba.conf` Unix-socket trust rules
|
|
31
|
+
* against `embedded-postgres`, so we always go TCP for admin work.
|
|
32
|
+
* Keeping the parameter avoids a churning call-site signature.
|
|
33
|
+
* @param {string} [args.host='127.0.0.1']
|
|
34
|
+
* @param {number} args.port
|
|
35
|
+
* @param {string} [args.database='postgres']
|
|
36
|
+
* @param {string} [args.user='postgres']
|
|
37
|
+
* @param {string} [args.password='postgres']
|
|
38
|
+
* @param {number} [args.max=2]
|
|
39
|
+
* @returns {Promise<{query: (text: string, params?: any[]) => Promise<{rows: any[], rowCount: number}>, end: () => Promise<void>, sql: any}>}
|
|
40
|
+
*/
|
|
41
|
+
export async function createAdminClient({
|
|
42
|
+
socketDir: _socketDir = null,
|
|
43
|
+
host = '127.0.0.1',
|
|
44
|
+
port,
|
|
45
|
+
database = 'postgres',
|
|
46
|
+
user = 'postgres',
|
|
47
|
+
password = 'postgres',
|
|
48
|
+
max = 2,
|
|
49
|
+
} = {}) {
|
|
50
|
+
if (typeof port !== 'number') throw new Error('createAdminClient: port required');
|
|
51
|
+
const sql = new SQL({
|
|
52
|
+
hostname: host,
|
|
53
|
+
port,
|
|
54
|
+
database,
|
|
55
|
+
username: user,
|
|
56
|
+
password,
|
|
57
|
+
max,
|
|
58
|
+
// TODO #38: investigate GC perf for 240-orphan sweep on shared CI runners;
|
|
59
|
+
// bumped 10s→30s during Felipe deadline 2026-04-29 to unblock pgserve v2.0 ship.
|
|
60
|
+
idleTimeout: 30,
|
|
61
|
+
});
|
|
62
|
+
// Light probe so a misconfigured daemon fails loudly here rather than at
|
|
63
|
+
// first query.
|
|
64
|
+
await sql`SELECT 1`;
|
|
65
|
+
return {
|
|
66
|
+
sql,
|
|
67
|
+
async query(text, params = []) {
|
|
68
|
+
// control-db.js is written for the pg npm module's contract, which
|
|
69
|
+
// requires JSON-stringified payloads bound to JSONB parameters.
|
|
70
|
+
// Bun.SQL goes the other way: it stringifies JS objects when they
|
|
71
|
+
// hit JSONB columns, but a JS string headed for `::jsonb` is sent
|
|
72
|
+
// as a JSON string literal (i.e. `"\"..."\"` rather than the array
|
|
73
|
+
// it represents). Bridge the impedance mismatch here so the same
|
|
74
|
+
// call sites work against either driver.
|
|
75
|
+
const adapted = params.map(coerceJsonbParam);
|
|
76
|
+
const rows = await sql.unsafe(text, adapted);
|
|
77
|
+
// Bun returns an Array of plain objects with `count` set on it; turn
|
|
78
|
+
// JSONB columns back into JS values so control-db.js's parseTokens
|
|
79
|
+
// sees the array-of-objects shape it would receive from pg.
|
|
80
|
+
const out = Array.from(rows).map(decodeJsonColumns);
|
|
81
|
+
return { rows: out, rowCount: rows.count ?? rows.length ?? 0 };
|
|
82
|
+
},
|
|
83
|
+
async end() {
|
|
84
|
+
try { await sql.close(); } catch { /* swallow */ }
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Strings shaped like a JSON array or object are unwrapped so Bun.SQL's
|
|
91
|
+
* automatic JSONB serialiser sees the JS value (not a quoted JSON string).
|
|
92
|
+
* Anything else is passed through untouched. This mirrors what node-pg
|
|
93
|
+
* does implicitly when the column type is JSONB.
|
|
94
|
+
*/
|
|
95
|
+
function coerceJsonbParam(p) {
|
|
96
|
+
if (typeof p !== 'string') return p;
|
|
97
|
+
const trimmed = p.trim();
|
|
98
|
+
if (trimmed.length === 0) return p;
|
|
99
|
+
const first = trimmed[0];
|
|
100
|
+
if (first !== '[' && first !== '{') return p;
|
|
101
|
+
try {
|
|
102
|
+
return JSON.parse(p);
|
|
103
|
+
} catch {
|
|
104
|
+
return p;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Bun.SQL returns JSONB values as the JSON text rather than parsed JS.
|
|
110
|
+
* Re-parse the obvious cases so callers expecting node-pg's auto-decoded
|
|
111
|
+
* shape get arrays/objects.
|
|
112
|
+
*/
|
|
113
|
+
function decodeJsonColumns(row) {
|
|
114
|
+
const out = {};
|
|
115
|
+
for (const key of Object.keys(row)) {
|
|
116
|
+
const v = row[key];
|
|
117
|
+
if (typeof v === 'string' && (v.startsWith('[') || v.startsWith('{'))) {
|
|
118
|
+
try { out[key] = JSON.parse(v); } catch { out[key] = v; }
|
|
119
|
+
} else {
|
|
120
|
+
out[key] = v;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Daemon-side: write a small JSON file that issue-token / revoke-token
|
|
128
|
+
* subcommands read to find the admin socket.
|
|
129
|
+
*
|
|
130
|
+
* @param {object} args
|
|
131
|
+
* @param {string} args.controlSocketDir
|
|
132
|
+
* @param {string|null} args.socketDir — PG socket directory (nullable on Windows)
|
|
133
|
+
* @param {number} args.port
|
|
134
|
+
* @returns {string} the absolute path to the discovery file
|
|
135
|
+
*/
|
|
136
|
+
export function writeAdminDiscovery({ controlSocketDir, socketDir, port }) {
|
|
137
|
+
const file = path.join(controlSocketDir, 'admin.json');
|
|
138
|
+
const payload = {
|
|
139
|
+
socketDir,
|
|
140
|
+
port,
|
|
141
|
+
host: socketDir ? null : '127.0.0.1',
|
|
142
|
+
pid: process.pid,
|
|
143
|
+
written_at: new Date().toISOString(),
|
|
144
|
+
};
|
|
145
|
+
fs.writeFileSync(file, JSON.stringify(payload), { mode: 0o600 });
|
|
146
|
+
return file;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* CLI-side: read the daemon's discovery file.
|
|
151
|
+
*
|
|
152
|
+
* @param {string} controlSocketDir
|
|
153
|
+
* @returns {{socketDir: string|null, port: number, host: string|null}}
|
|
154
|
+
*/
|
|
155
|
+
export function readAdminDiscovery(controlSocketDir) {
|
|
156
|
+
const file = path.join(controlSocketDir, 'admin.json');
|
|
157
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
158
|
+
return JSON.parse(raw);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* CLI-side: best-effort cleanup at daemon shutdown.
|
|
163
|
+
*
|
|
164
|
+
* @param {string} controlSocketDir
|
|
165
|
+
*/
|
|
166
|
+
export function removeAdminDiscovery(controlSocketDir) {
|
|
167
|
+
const file = path.join(controlSocketDir, 'admin.json');
|
|
168
|
+
try { fs.unlinkSync(file); } catch (e) {
|
|
169
|
+
if (e.code !== 'ENOENT') throw e;
|
|
170
|
+
}
|
|
171
|
+
}
|
package/src/audit.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pgserve audit log — JSONL writer with in-process rotation + syslog tier.
|
|
3
|
+
*
|
|
4
|
+
* Tier 1 (default): `~/.pgserve/audit.log`, rotated 50 MB × 5 files.
|
|
5
|
+
* Tier 2 (opt-in): local syslog via `logger -t pgserve-audit`, one spawn per event.
|
|
6
|
+
* Tier 3 (HTTP webhook): deferred to v2.1.
|
|
7
|
+
*
|
|
8
|
+
* Configuration source-of-truth is the active package.json's
|
|
9
|
+
* `pgserve.audit.target` field; the daemon (Group 3) resolves it per peer
|
|
10
|
+
* and threads the value through `audit(event, fields, { target })`.
|
|
11
|
+
*
|
|
12
|
+
* The event names defined for v2.0 (one row per audit() call):
|
|
13
|
+
* db_created
|
|
14
|
+
* db_reaped_ttl
|
|
15
|
+
* db_reaped_liveness
|
|
16
|
+
* db_persist_honored
|
|
17
|
+
* connection_routed
|
|
18
|
+
* connection_denied_fingerprint_mismatch
|
|
19
|
+
* enforcement_kill_switch_used
|
|
20
|
+
* tcp_token_issued
|
|
21
|
+
* tcp_token_used
|
|
22
|
+
* tcp_token_denied
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import fs from 'fs';
|
|
26
|
+
import os from 'os';
|
|
27
|
+
import path from 'path';
|
|
28
|
+
import { spawn } from 'child_process';
|
|
29
|
+
|
|
30
|
+
export const AUDIT_EVENTS = Object.freeze({
|
|
31
|
+
DB_CREATED: 'db_created',
|
|
32
|
+
DB_REAPED_TTL: 'db_reaped_ttl',
|
|
33
|
+
DB_REAPED_LIVENESS: 'db_reaped_liveness',
|
|
34
|
+
DB_PERSIST_HONORED: 'db_persist_honored',
|
|
35
|
+
CONNECTION_ROUTED: 'connection_routed',
|
|
36
|
+
CONNECTION_DENIED_FINGERPRINT_MISMATCH: 'connection_denied_fingerprint_mismatch',
|
|
37
|
+
ENFORCEMENT_KILL_SWITCH_USED: 'enforcement_kill_switch_used',
|
|
38
|
+
TCP_TOKEN_ISSUED: 'tcp_token_issued',
|
|
39
|
+
TCP_TOKEN_USED: 'tcp_token_used',
|
|
40
|
+
TCP_TOKEN_DENIED: 'tcp_token_denied',
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const VALID_EVENTS = new Set(Object.values(AUDIT_EVENTS));
|
|
44
|
+
|
|
45
|
+
const ROTATE_THRESHOLD_BYTES = 50 * 1024 * 1024; // 50 MB
|
|
46
|
+
const ROTATE_KEEP = 5;
|
|
47
|
+
|
|
48
|
+
let DEFAULT_LOG_DIR = path.join(os.homedir(), '.pgserve');
|
|
49
|
+
let DEFAULT_LOG_PATH = path.join(DEFAULT_LOG_DIR, 'audit.log');
|
|
50
|
+
let DEFAULT_TARGET = process.env.PGSERVE_AUDIT_TARGET || 'file';
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Override the default log path. Used by tests and by the daemon if it
|
|
54
|
+
* needs to redirect audit output (e.g. when XDG_DATA_HOME is set).
|
|
55
|
+
*
|
|
56
|
+
* @param {{logFile?: string, target?: 'file'|'syslog'}} cfg
|
|
57
|
+
*/
|
|
58
|
+
export function configureAudit(cfg = {}) {
|
|
59
|
+
if (cfg.logFile) {
|
|
60
|
+
DEFAULT_LOG_PATH = cfg.logFile;
|
|
61
|
+
DEFAULT_LOG_DIR = path.dirname(cfg.logFile);
|
|
62
|
+
}
|
|
63
|
+
if (cfg.target) {
|
|
64
|
+
DEFAULT_TARGET = cfg.target;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Read pgserve.audit.target from a package.json (returns 'file' if absent).
|
|
70
|
+
* Group 3 calls this per-peer once it has resolved the peer's package.json.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} packageJsonPath
|
|
73
|
+
* @returns {'file'|'syslog'}
|
|
74
|
+
*/
|
|
75
|
+
export function readAuditTarget(packageJsonPath) {
|
|
76
|
+
try {
|
|
77
|
+
const raw = fs.readFileSync(packageJsonPath, 'utf8');
|
|
78
|
+
const pkg = JSON.parse(raw);
|
|
79
|
+
const target = pkg?.pgserve?.audit?.target;
|
|
80
|
+
if (target === 'syslog') return 'syslog';
|
|
81
|
+
return 'file';
|
|
82
|
+
} catch {
|
|
83
|
+
return 'file';
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Write one audit event.
|
|
89
|
+
*
|
|
90
|
+
* @param {string} event — one of AUDIT_EVENTS values
|
|
91
|
+
* @param {Record<string, unknown>} [fields] — event-specific payload
|
|
92
|
+
* @param {object} [opts]
|
|
93
|
+
* @param {'file'|'syslog'} [opts.target]
|
|
94
|
+
* @param {string} [opts.logFile]
|
|
95
|
+
*/
|
|
96
|
+
export function audit(event, fields = {}, opts = {}) {
|
|
97
|
+
if (!VALID_EVENTS.has(event)) {
|
|
98
|
+
throw new Error(`audit: unknown event "${event}". Allowed: ${[...VALID_EVENTS].join(', ')}`);
|
|
99
|
+
}
|
|
100
|
+
const record = {
|
|
101
|
+
ts: new Date().toISOString(),
|
|
102
|
+
event,
|
|
103
|
+
...fields,
|
|
104
|
+
};
|
|
105
|
+
const line = JSON.stringify(record);
|
|
106
|
+
const target = opts.target || DEFAULT_TARGET;
|
|
107
|
+
|
|
108
|
+
if (target === 'syslog') {
|
|
109
|
+
writeSyslog(line);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
writeFile(line, opts.logFile || DEFAULT_LOG_PATH);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function writeFile(line, logFile) {
|
|
116
|
+
const dir = path.dirname(logFile);
|
|
117
|
+
if (!fs.existsSync(dir)) {
|
|
118
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
119
|
+
}
|
|
120
|
+
rotateIfNeeded(logFile, Buffer.byteLength(line, 'utf8') + 1 /* newline */);
|
|
121
|
+
fs.appendFileSync(logFile, line + '\n', { mode: 0o600 });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function rotateIfNeeded(logFile, incomingBytes) {
|
|
125
|
+
let size = 0;
|
|
126
|
+
try {
|
|
127
|
+
size = fs.statSync(logFile).size;
|
|
128
|
+
} catch {
|
|
129
|
+
return; // file does not exist yet
|
|
130
|
+
}
|
|
131
|
+
if (size + incomingBytes <= ROTATE_THRESHOLD_BYTES) return;
|
|
132
|
+
|
|
133
|
+
// Cascade .N → .(N+1), drop the eldest.
|
|
134
|
+
const oldest = `${logFile}.${ROTATE_KEEP}`;
|
|
135
|
+
if (fs.existsSync(oldest)) {
|
|
136
|
+
fs.unlinkSync(oldest);
|
|
137
|
+
}
|
|
138
|
+
for (let i = ROTATE_KEEP - 1; i >= 1; i--) {
|
|
139
|
+
const src = `${logFile}.${i}`;
|
|
140
|
+
const dst = `${logFile}.${i + 1}`;
|
|
141
|
+
if (fs.existsSync(src)) fs.renameSync(src, dst);
|
|
142
|
+
}
|
|
143
|
+
fs.renameSync(logFile, `${logFile}.1`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function writeSyslog(line) {
|
|
147
|
+
// logger -t <tag> is POSIX-standard; spawn detached, do not block.
|
|
148
|
+
// Stderr/stdout discarded — audit must never throw at call sites.
|
|
149
|
+
try {
|
|
150
|
+
const child = spawn('logger', ['-t', 'pgserve-audit', line], {
|
|
151
|
+
stdio: 'ignore',
|
|
152
|
+
detached: false,
|
|
153
|
+
});
|
|
154
|
+
child.on('error', () => { /* logger missing — swallow */ });
|
|
155
|
+
} catch {
|
|
156
|
+
// ENOENT / EACCES — swallow; audit must never break the daemon.
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Internal: expose rotation constants so tests can drive coverage cleanly
|
|
162
|
+
* without depending on actual 50 MB writes.
|
|
163
|
+
*/
|
|
164
|
+
export const _internals = Object.freeze({
|
|
165
|
+
ROTATE_THRESHOLD_BYTES,
|
|
166
|
+
ROTATE_KEEP,
|
|
167
|
+
rotateIfNeeded,
|
|
168
|
+
});
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pgserve control DB — `pgserve_meta` schema + accessors.
|
|
3
|
+
*
|
|
4
|
+
* The pgserve daemon owns a control database (the "admin DB"). This module
|
|
5
|
+
* defines the `pgserve_meta` table that records every user database the
|
|
6
|
+
* daemon provisions per peer fingerprint, plus the small set of accessors
|
|
7
|
+
* the daemon (Wave 2+) and GC sweep (Group 5) call against it.
|
|
8
|
+
*
|
|
9
|
+
* Schema (see DESIGN.md §9 + Group 6 token migration):
|
|
10
|
+
* database_name TEXT PRIMARY KEY
|
|
11
|
+
* fingerprint TEXT NOT NULL -- 12 hex chars from sha256
|
|
12
|
+
* peer_uid INTEGER NOT NULL
|
|
13
|
+
* package_realpath TEXT -- NULL for script fallback
|
|
14
|
+
* created_at TIMESTAMPTZ DEFAULT now()
|
|
15
|
+
* last_connection_at TIMESTAMPTZ DEFAULT now()
|
|
16
|
+
* liveness_pid INTEGER
|
|
17
|
+
* persist BOOLEAN DEFAULT false
|
|
18
|
+
* allowed_tokens JSONB DEFAULT '[]' -- Group 6: bearer tokens for TCP path
|
|
19
|
+
*
|
|
20
|
+
* Each `allowed_tokens` entry is `{id, hash, issued_at}` where `hash` is the
|
|
21
|
+
* sha256 of the bearer token (the cleartext is shown to the operator once
|
|
22
|
+
* during `pgserve daemon issue-token` and never persisted).
|
|
23
|
+
*
|
|
24
|
+
* Client contract: any object exposing
|
|
25
|
+
* `query(text: string, params?: unknown[]) => Promise<{ rows: object[] }>`
|
|
26
|
+
* (matches `pg.Client` / `pg.Pool` directly; trivial to wrap Bun.SQL).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { timingSafeEqual } from './tokens.js';
|
|
30
|
+
|
|
31
|
+
const REAPABLE_QUERY = `
|
|
32
|
+
SELECT database_name, fingerprint, last_connection_at, liveness_pid, persist
|
|
33
|
+
FROM pgserve_meta
|
|
34
|
+
WHERE persist = false
|
|
35
|
+
ORDER BY last_connection_at ASC
|
|
36
|
+
`;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Create the `pgserve_meta` table if it does not already exist.
|
|
40
|
+
* Safe to call repeatedly — used at daemon boot and in tests.
|
|
41
|
+
*
|
|
42
|
+
* @param {{query: Function}} client
|
|
43
|
+
* @returns {Promise<void>}
|
|
44
|
+
*/
|
|
45
|
+
export async function ensureMetaSchema(client) {
|
|
46
|
+
await client.query(`
|
|
47
|
+
CREATE TABLE IF NOT EXISTS pgserve_meta (
|
|
48
|
+
database_name TEXT PRIMARY KEY,
|
|
49
|
+
fingerprint TEXT NOT NULL,
|
|
50
|
+
peer_uid INTEGER NOT NULL,
|
|
51
|
+
package_realpath TEXT,
|
|
52
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
53
|
+
last_connection_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
54
|
+
liveness_pid INTEGER,
|
|
55
|
+
persist BOOLEAN NOT NULL DEFAULT false,
|
|
56
|
+
allowed_tokens JSONB NOT NULL DEFAULT '[]'::jsonb
|
|
57
|
+
)
|
|
58
|
+
`);
|
|
59
|
+
// Group 6 migration: existing v2-pre-tcp installs predate allowed_tokens.
|
|
60
|
+
// ADD COLUMN IF NOT EXISTS lets the first daemon boot after upgrade fold
|
|
61
|
+
// the new column into a populated table without operator intervention.
|
|
62
|
+
await client.query(`
|
|
63
|
+
ALTER TABLE pgserve_meta
|
|
64
|
+
ADD COLUMN IF NOT EXISTS allowed_tokens JSONB NOT NULL DEFAULT '[]'::jsonb
|
|
65
|
+
`);
|
|
66
|
+
await client.query(`
|
|
67
|
+
CREATE INDEX IF NOT EXISTS pgserve_meta_fingerprint_idx
|
|
68
|
+
ON pgserve_meta (fingerprint)
|
|
69
|
+
`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Insert (or upsert) a row marking a freshly-created user DB.
|
|
74
|
+
*
|
|
75
|
+
* @param {{query: Function}} client
|
|
76
|
+
* @param {object} row
|
|
77
|
+
* @param {string} row.databaseName
|
|
78
|
+
* @param {string} row.fingerprint
|
|
79
|
+
* @param {number} row.peerUid
|
|
80
|
+
* @param {string|null} [row.packageRealpath]
|
|
81
|
+
* @param {number|null} [row.livenessPid]
|
|
82
|
+
* @param {boolean} [row.persist]
|
|
83
|
+
*/
|
|
84
|
+
export async function recordDbCreated(client, {
|
|
85
|
+
databaseName,
|
|
86
|
+
fingerprint,
|
|
87
|
+
peerUid,
|
|
88
|
+
packageRealpath = null,
|
|
89
|
+
livenessPid = null,
|
|
90
|
+
persist = false,
|
|
91
|
+
}) {
|
|
92
|
+
if (!databaseName) throw new Error('recordDbCreated: databaseName required');
|
|
93
|
+
if (!fingerprint) throw new Error('recordDbCreated: fingerprint required');
|
|
94
|
+
if (typeof peerUid !== 'number') throw new Error('recordDbCreated: peerUid must be number');
|
|
95
|
+
|
|
96
|
+
await client.query(
|
|
97
|
+
`
|
|
98
|
+
INSERT INTO pgserve_meta
|
|
99
|
+
(database_name, fingerprint, peer_uid, package_realpath, liveness_pid, persist)
|
|
100
|
+
VALUES ($1, $2, $3, $4, $5, $6)
|
|
101
|
+
ON CONFLICT (database_name) DO UPDATE SET
|
|
102
|
+
fingerprint = EXCLUDED.fingerprint,
|
|
103
|
+
peer_uid = EXCLUDED.peer_uid,
|
|
104
|
+
package_realpath = EXCLUDED.package_realpath,
|
|
105
|
+
liveness_pid = EXCLUDED.liveness_pid,
|
|
106
|
+
persist = EXCLUDED.persist,
|
|
107
|
+
last_connection_at = now()
|
|
108
|
+
`,
|
|
109
|
+
[databaseName, fingerprint, peerUid, packageRealpath, livenessPid, persist],
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Slide the connection window: bump last_connection_at and refresh
|
|
115
|
+
* liveness_pid on every accept for an existing fingerprint.
|
|
116
|
+
*
|
|
117
|
+
* @param {{query: Function}} client
|
|
118
|
+
* @param {{databaseName: string, livenessPid?: number|null}} args
|
|
119
|
+
*/
|
|
120
|
+
export async function touchLastConnection(client, { databaseName, livenessPid = null }) {
|
|
121
|
+
if (!databaseName) throw new Error('touchLastConnection: databaseName required');
|
|
122
|
+
await client.query(
|
|
123
|
+
`
|
|
124
|
+
UPDATE pgserve_meta
|
|
125
|
+
SET last_connection_at = now(),
|
|
126
|
+
liveness_pid = $2
|
|
127
|
+
WHERE database_name = $1
|
|
128
|
+
`,
|
|
129
|
+
[databaseName, livenessPid],
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Set the persist flag for a database (true = exempt from GC).
|
|
135
|
+
*
|
|
136
|
+
* @param {{query: Function}} client
|
|
137
|
+
* @param {string} databaseName
|
|
138
|
+
* @param {boolean} value
|
|
139
|
+
*/
|
|
140
|
+
export async function markPersist(client, databaseName, value) {
|
|
141
|
+
if (!databaseName) throw new Error('markPersist: databaseName required');
|
|
142
|
+
await client.query(
|
|
143
|
+
`UPDATE pgserve_meta SET persist = $2 WHERE database_name = $1`,
|
|
144
|
+
[databaseName, !!value],
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Async iterator over candidate DBs for the GC sweep.
|
|
150
|
+
* Skips persist=true rows entirely (they are never reaped).
|
|
151
|
+
*
|
|
152
|
+
* Group 5 consumes this and applies its liveness/TTL policy.
|
|
153
|
+
*
|
|
154
|
+
* @param {{query: Function}} client
|
|
155
|
+
* @param {{now?: Date}} [opts] — `now` accepted for caller symmetry; the
|
|
156
|
+
* policy decision (TTL elapsed?) lives in Group 5, not here.
|
|
157
|
+
* @returns {AsyncIterable<{
|
|
158
|
+
* databaseName: string,
|
|
159
|
+
* fingerprint: string,
|
|
160
|
+
* lastConnectionAt: Date,
|
|
161
|
+
* livenessPid: number|null,
|
|
162
|
+
* persist: boolean,
|
|
163
|
+
* }>}
|
|
164
|
+
*/
|
|
165
|
+
export async function* forEachReapable(client, _opts = {}) {
|
|
166
|
+
const result = await client.query(REAPABLE_QUERY);
|
|
167
|
+
for (const row of result.rows) {
|
|
168
|
+
yield {
|
|
169
|
+
databaseName: row.database_name,
|
|
170
|
+
fingerprint: row.fingerprint,
|
|
171
|
+
lastConnectionAt: row.last_connection_at,
|
|
172
|
+
livenessPid: row.liveness_pid,
|
|
173
|
+
persist: row.persist,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Delete a row after the user DB has been DROPped. Group 5 helper.
|
|
180
|
+
*
|
|
181
|
+
* @param {{query: Function}} client
|
|
182
|
+
* @param {string} databaseName
|
|
183
|
+
*/
|
|
184
|
+
export async function deleteMetaRow(client, databaseName) {
|
|
185
|
+
await client.query(`DELETE FROM pgserve_meta WHERE database_name = $1`, [databaseName]);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
// Group 6: TCP bearer-token CRUD
|
|
190
|
+
//
|
|
191
|
+
// `allowed_tokens` is a JSONB array on pgserve_meta. Each entry is shaped
|
|
192
|
+
// `{id, hash, issued_at}` where `hash` is sha256 of the cleartext bearer
|
|
193
|
+
// token. Tokens are scoped to the `database_name` row's `fingerprint`; a
|
|
194
|
+
// fingerprint without a row cannot have tokens issued (the peer must have
|
|
195
|
+
// connected over the Unix socket at least once so its DB exists).
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Look up the metadata row for a fingerprint. Returns null if the fingerprint
|
|
200
|
+
* has not yet been provisioned (the peer never connected via Unix socket).
|
|
201
|
+
*
|
|
202
|
+
* @param {{query: Function}} client
|
|
203
|
+
* @param {string} fingerprint — 12 hex chars
|
|
204
|
+
* @returns {Promise<{databaseName: string, fingerprint: string, peerUid: number, allowedTokens: Array<{id: string, hash: string, issued_at: string}>} | null>}
|
|
205
|
+
*/
|
|
206
|
+
export async function findRowByFingerprint(client, fingerprint) {
|
|
207
|
+
if (!fingerprint) throw new Error('findRowByFingerprint: fingerprint required');
|
|
208
|
+
const r = await client.query(
|
|
209
|
+
`SELECT database_name, fingerprint, peer_uid, allowed_tokens
|
|
210
|
+
FROM pgserve_meta WHERE fingerprint = $1 LIMIT 1`,
|
|
211
|
+
[fingerprint],
|
|
212
|
+
);
|
|
213
|
+
if (r.rows.length === 0) return null;
|
|
214
|
+
const row = r.rows[0];
|
|
215
|
+
return {
|
|
216
|
+
databaseName: row.database_name,
|
|
217
|
+
fingerprint: row.fingerprint,
|
|
218
|
+
peerUid: row.peer_uid,
|
|
219
|
+
allowedTokens: parseTokens(row.allowed_tokens),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function parseTokens(raw) {
|
|
224
|
+
if (!raw) return [];
|
|
225
|
+
if (Array.isArray(raw)) return raw;
|
|
226
|
+
try {
|
|
227
|
+
const parsed = JSON.parse(raw);
|
|
228
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
229
|
+
} catch {
|
|
230
|
+
return [];
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Append a hashed bearer token to a fingerprint's allowed list.
|
|
236
|
+
*
|
|
237
|
+
* @param {{query: Function}} client
|
|
238
|
+
* @param {{fingerprint: string, tokenId: string, tokenHash: string}} args
|
|
239
|
+
* @returns {Promise<{databaseName: string}>}
|
|
240
|
+
* @throws if the fingerprint has no pgserve_meta row
|
|
241
|
+
*/
|
|
242
|
+
export async function addAllowedToken(client, { fingerprint, tokenId, tokenHash }) {
|
|
243
|
+
if (!fingerprint) throw new Error('addAllowedToken: fingerprint required');
|
|
244
|
+
if (!tokenId) throw new Error('addAllowedToken: tokenId required');
|
|
245
|
+
if (!tokenHash) throw new Error('addAllowedToken: tokenHash required');
|
|
246
|
+
|
|
247
|
+
const row = await findRowByFingerprint(client, fingerprint);
|
|
248
|
+
if (!row) {
|
|
249
|
+
const err = new Error(
|
|
250
|
+
`addAllowedToken: no pgserve_meta row for fingerprint ${fingerprint}; ` +
|
|
251
|
+
`peer must connect once via Unix socket before tokens can be issued`,
|
|
252
|
+
);
|
|
253
|
+
err.code = 'EUNKNOWNFINGERPRINT';
|
|
254
|
+
throw err;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const entry = {
|
|
258
|
+
id: tokenId,
|
|
259
|
+
hash: tokenHash,
|
|
260
|
+
issued_at: new Date().toISOString(),
|
|
261
|
+
};
|
|
262
|
+
await client.query(
|
|
263
|
+
`UPDATE pgserve_meta
|
|
264
|
+
SET allowed_tokens = allowed_tokens || $2::jsonb
|
|
265
|
+
WHERE database_name = $1`,
|
|
266
|
+
[row.databaseName, JSON.stringify([entry])],
|
|
267
|
+
);
|
|
268
|
+
return { databaseName: row.databaseName };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Remove a token by its id from any fingerprint's allowed list. Returns the
|
|
273
|
+
* number of rows affected.
|
|
274
|
+
*
|
|
275
|
+
* @param {{query: Function}} client
|
|
276
|
+
* @param {string} tokenId
|
|
277
|
+
* @returns {Promise<number>}
|
|
278
|
+
*/
|
|
279
|
+
export async function revokeAllowedToken(client, tokenId) {
|
|
280
|
+
if (!tokenId) throw new Error('revokeAllowedToken: tokenId required');
|
|
281
|
+
// jsonb_path_query_array would be cleaner but isn't on every PG; the array
|
|
282
|
+
// filter via SELECT/UPDATE works on any version >= 12.
|
|
283
|
+
const r = await client.query(
|
|
284
|
+
`UPDATE pgserve_meta
|
|
285
|
+
SET allowed_tokens = COALESCE((
|
|
286
|
+
SELECT jsonb_agg(elem)
|
|
287
|
+
FROM jsonb_array_elements(allowed_tokens) elem
|
|
288
|
+
WHERE elem->>'id' <> $1
|
|
289
|
+
), '[]'::jsonb)
|
|
290
|
+
WHERE allowed_tokens @> jsonb_build_array(jsonb_build_object('id', $1::text))`,
|
|
291
|
+
[tokenId],
|
|
292
|
+
);
|
|
293
|
+
return r.rowCount ?? r.rows?.length ?? 0;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Verify a presented bearer-token hash against a fingerprint's allowed list.
|
|
298
|
+
* Returns the matched token id (so audit events can attribute the connection)
|
|
299
|
+
* plus the resolved database name on success, or null if the token is unknown.
|
|
300
|
+
*
|
|
301
|
+
* @param {{query: Function}} client
|
|
302
|
+
* @param {{fingerprint: string, tokenHash: string}} args
|
|
303
|
+
* @returns {Promise<{tokenId: string, databaseName: string} | null>}
|
|
304
|
+
*/
|
|
305
|
+
export async function verifyToken(client, { fingerprint, tokenHash }) {
|
|
306
|
+
if (!fingerprint) throw new Error('verifyToken: fingerprint required');
|
|
307
|
+
if (!tokenHash) throw new Error('verifyToken: tokenHash required');
|
|
308
|
+
const row = await findRowByFingerprint(client, fingerprint);
|
|
309
|
+
if (!row) return null;
|
|
310
|
+
const match = row.allowedTokens.find((t) => timingSafeEqual(t.hash, tokenHash));
|
|
311
|
+
if (!match) return null;
|
|
312
|
+
return { tokenId: match.id, databaseName: row.databaseName };
|
|
313
|
+
}
|