pgserve 2.2.4 → 2.4.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/src/control-db.js DELETED
@@ -1,330 +0,0 @@
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
- function query(client, text, params = [], opts = {}) {
39
- if (client.supportsQueryOptions && opts && Object.keys(opts).length > 0) {
40
- return client.query(text, params, opts);
41
- }
42
- return client.query(text, params);
43
- }
44
-
45
- /**
46
- * Create the `pgserve_meta` table if it does not already exist.
47
- * Safe to call repeatedly — used at daemon boot and in tests.
48
- *
49
- * @param {{query: Function}} client
50
- * @returns {Promise<void>}
51
- */
52
- export async function ensureMetaSchema(client) {
53
- await client.query(`
54
- CREATE TABLE IF NOT EXISTS pgserve_meta (
55
- database_name TEXT PRIMARY KEY,
56
- fingerprint TEXT NOT NULL,
57
- peer_uid INTEGER NOT NULL,
58
- package_realpath TEXT,
59
- created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
60
- last_connection_at TIMESTAMPTZ NOT NULL DEFAULT now(),
61
- liveness_pid INTEGER,
62
- persist BOOLEAN NOT NULL DEFAULT false,
63
- allowed_tokens JSONB NOT NULL DEFAULT '[]'::jsonb
64
- )
65
- `);
66
- // Group 6 migration: existing v2-pre-tcp installs predate allowed_tokens.
67
- // ADD COLUMN IF NOT EXISTS lets the first daemon boot after upgrade fold
68
- // the new column into a populated table without operator intervention.
69
- await client.query(`
70
- ALTER TABLE pgserve_meta
71
- ADD COLUMN IF NOT EXISTS allowed_tokens JSONB NOT NULL DEFAULT '[]'::jsonb
72
- `);
73
- await client.query(`
74
- CREATE INDEX IF NOT EXISTS pgserve_meta_fingerprint_idx
75
- ON pgserve_meta (fingerprint)
76
- `);
77
- }
78
-
79
- /**
80
- * Insert (or upsert) a row marking a freshly-created user DB.
81
- *
82
- * @param {{query: Function}} client
83
- * @param {object} row
84
- * @param {string} row.databaseName
85
- * @param {string} row.fingerprint
86
- * @param {number} row.peerUid
87
- * @param {string|null} [row.packageRealpath]
88
- * @param {number|null} [row.livenessPid]
89
- * @param {boolean} [row.persist]
90
- */
91
- export async function recordDbCreated(client, {
92
- databaseName,
93
- fingerprint,
94
- peerUid,
95
- packageRealpath = null,
96
- livenessPid = null,
97
- persist = false,
98
- }, opts = {}) {
99
- if (!databaseName) throw new Error('recordDbCreated: databaseName required');
100
- if (!fingerprint) throw new Error('recordDbCreated: fingerprint required');
101
- if (typeof peerUid !== 'number') throw new Error('recordDbCreated: peerUid must be number');
102
-
103
- await query(
104
- client,
105
- `
106
- INSERT INTO pgserve_meta
107
- (database_name, fingerprint, peer_uid, package_realpath, liveness_pid, persist)
108
- VALUES ($1, $2, $3, $4, $5, $6)
109
- ON CONFLICT (database_name) DO UPDATE SET
110
- fingerprint = EXCLUDED.fingerprint,
111
- peer_uid = EXCLUDED.peer_uid,
112
- package_realpath = EXCLUDED.package_realpath,
113
- liveness_pid = EXCLUDED.liveness_pid,
114
- persist = EXCLUDED.persist,
115
- last_connection_at = now()
116
- `,
117
- [databaseName, fingerprint, peerUid, packageRealpath, livenessPid, persist],
118
- opts,
119
- );
120
- }
121
-
122
- /**
123
- * Slide the connection window: bump last_connection_at and refresh
124
- * liveness_pid on every accept for an existing fingerprint.
125
- *
126
- * @param {{query: Function}} client
127
- * @param {{databaseName: string, livenessPid?: number|null}} args
128
- */
129
- export async function touchLastConnection(client, { databaseName, livenessPid = null }, opts = {}) {
130
- if (!databaseName) throw new Error('touchLastConnection: databaseName required');
131
- await query(
132
- client,
133
- `
134
- UPDATE pgserve_meta
135
- SET last_connection_at = now(),
136
- liveness_pid = $2
137
- WHERE database_name = $1
138
- `,
139
- [databaseName, livenessPid],
140
- opts,
141
- );
142
- }
143
-
144
- /**
145
- * Set the persist flag for a database (true = exempt from GC).
146
- *
147
- * @param {{query: Function}} client
148
- * @param {string} databaseName
149
- * @param {boolean} value
150
- */
151
- export async function markPersist(client, databaseName, value, opts = {}) {
152
- if (!databaseName) throw new Error('markPersist: databaseName required');
153
- await query(
154
- client,
155
- `UPDATE pgserve_meta SET persist = $2 WHERE database_name = $1`,
156
- [databaseName, !!value],
157
- opts,
158
- );
159
- }
160
-
161
- /**
162
- * Async iterator over candidate DBs for the GC sweep.
163
- * Skips persist=true rows entirely (they are never reaped).
164
- *
165
- * Group 5 consumes this and applies its liveness/TTL policy.
166
- *
167
- * @param {{query: Function}} client
168
- * @param {{now?: Date}} [opts] — `now` accepted for caller symmetry; the
169
- * policy decision (TTL elapsed?) lives in Group 5, not here.
170
- * @returns {AsyncIterable<{
171
- * databaseName: string,
172
- * fingerprint: string,
173
- * lastConnectionAt: Date,
174
- * livenessPid: number|null,
175
- * persist: boolean,
176
- * }>}
177
- */
178
- export async function* forEachReapable(client, _opts = {}) {
179
- const result = await client.query(REAPABLE_QUERY);
180
- for (const row of result.rows) {
181
- yield {
182
- databaseName: row.database_name,
183
- fingerprint: row.fingerprint,
184
- lastConnectionAt: row.last_connection_at,
185
- livenessPid: row.liveness_pid,
186
- persist: row.persist,
187
- };
188
- }
189
- }
190
-
191
- /**
192
- * Delete a row after the user DB has been DROPped. Group 5 helper.
193
- *
194
- * @param {{query: Function}} client
195
- * @param {string} databaseName
196
- */
197
- export async function deleteMetaRow(client, databaseName) {
198
- await client.query(`DELETE FROM pgserve_meta WHERE database_name = $1`, [databaseName]);
199
- }
200
-
201
- // ---------------------------------------------------------------------------
202
- // Group 6: TCP bearer-token CRUD
203
- //
204
- // `allowed_tokens` is a JSONB array on pgserve_meta. Each entry is shaped
205
- // `{id, hash, issued_at}` where `hash` is sha256 of the cleartext bearer
206
- // token. Tokens are scoped to the `database_name` row's `fingerprint`; a
207
- // fingerprint without a row cannot have tokens issued (the peer must have
208
- // connected over the Unix socket at least once so its DB exists).
209
- // ---------------------------------------------------------------------------
210
-
211
- /**
212
- * Look up the metadata row for a fingerprint. Returns null if the fingerprint
213
- * has not yet been provisioned (the peer never connected via Unix socket).
214
- *
215
- * @param {{query: Function}} client
216
- * @param {string} fingerprint — 12 hex chars
217
- * @returns {Promise<{databaseName: string, fingerprint: string, peerUid: number, allowedTokens: Array<{id: string, hash: string, issued_at: string}>} | null>}
218
- */
219
- export async function findRowByFingerprint(client, fingerprint, opts = {}) {
220
- if (!fingerprint) throw new Error('findRowByFingerprint: fingerprint required');
221
- const r = await query(
222
- client,
223
- `SELECT database_name, fingerprint, peer_uid, allowed_tokens
224
- FROM pgserve_meta WHERE fingerprint = $1 LIMIT 1`,
225
- [fingerprint],
226
- opts,
227
- );
228
- if (r.rows.length === 0) return null;
229
- const row = r.rows[0];
230
- return {
231
- databaseName: row.database_name,
232
- fingerprint: row.fingerprint,
233
- peerUid: row.peer_uid,
234
- allowedTokens: parseTokens(row.allowed_tokens),
235
- };
236
- }
237
-
238
- function parseTokens(raw) {
239
- if (!raw) return [];
240
- if (Array.isArray(raw)) return raw;
241
- try {
242
- const parsed = JSON.parse(raw);
243
- return Array.isArray(parsed) ? parsed : [];
244
- } catch {
245
- return [];
246
- }
247
- }
248
-
249
- /**
250
- * Append a hashed bearer token to a fingerprint's allowed list.
251
- *
252
- * @param {{query: Function}} client
253
- * @param {{fingerprint: string, tokenId: string, tokenHash: string}} args
254
- * @returns {Promise<{databaseName: string}>}
255
- * @throws if the fingerprint has no pgserve_meta row
256
- */
257
- export async function addAllowedToken(client, { fingerprint, tokenId, tokenHash }, opts = {}) {
258
- if (!fingerprint) throw new Error('addAllowedToken: fingerprint required');
259
- if (!tokenId) throw new Error('addAllowedToken: tokenId required');
260
- if (!tokenHash) throw new Error('addAllowedToken: tokenHash required');
261
-
262
- const row = await findRowByFingerprint(client, fingerprint, opts);
263
- if (!row) {
264
- const err = new Error(
265
- `addAllowedToken: no pgserve_meta row for fingerprint ${fingerprint}; ` +
266
- `peer must connect once via Unix socket before tokens can be issued`,
267
- );
268
- err.code = 'EUNKNOWNFINGERPRINT';
269
- throw err;
270
- }
271
-
272
- const entry = {
273
- id: tokenId,
274
- hash: tokenHash,
275
- issued_at: new Date().toISOString(),
276
- };
277
- await query(
278
- client,
279
- `UPDATE pgserve_meta
280
- SET allowed_tokens = allowed_tokens || $2::jsonb
281
- WHERE database_name = $1`,
282
- [row.databaseName, JSON.stringify([entry])],
283
- opts,
284
- );
285
- return { databaseName: row.databaseName };
286
- }
287
-
288
- /**
289
- * Remove a token by its id from any fingerprint's allowed list. Returns the
290
- * number of rows affected.
291
- *
292
- * @param {{query: Function}} client
293
- * @param {string} tokenId
294
- * @returns {Promise<number>}
295
- */
296
- export async function revokeAllowedToken(client, tokenId) {
297
- if (!tokenId) throw new Error('revokeAllowedToken: tokenId required');
298
- // jsonb_path_query_array would be cleaner but isn't on every PG; the array
299
- // filter via SELECT/UPDATE works on any version >= 12.
300
- const r = await client.query(
301
- `UPDATE pgserve_meta
302
- SET allowed_tokens = COALESCE((
303
- SELECT jsonb_agg(elem)
304
- FROM jsonb_array_elements(allowed_tokens) elem
305
- WHERE elem->>'id' <> $1
306
- ), '[]'::jsonb)
307
- WHERE allowed_tokens @> jsonb_build_array(jsonb_build_object('id', $1::text))`,
308
- [tokenId],
309
- );
310
- return r.rowCount ?? r.rows?.length ?? 0;
311
- }
312
-
313
- /**
314
- * Verify a presented bearer-token hash against a fingerprint's allowed list.
315
- * Returns the matched token id (so audit events can attribute the connection)
316
- * plus the resolved database name on success, or null if the token is unknown.
317
- *
318
- * @param {{query: Function}} client
319
- * @param {{fingerprint: string, tokenHash: string}} args
320
- * @returns {Promise<{tokenId: string, databaseName: string} | null>}
321
- */
322
- export async function verifyToken(client, { fingerprint, tokenHash }, opts = {}) {
323
- if (!fingerprint) throw new Error('verifyToken: fingerprint required');
324
- if (!tokenHash) throw new Error('verifyToken: tokenHash required');
325
- const row = await findRowByFingerprint(client, fingerprint, opts);
326
- if (!row) return null;
327
- const match = row.allowedTokens.find((t) => timingSafeEqual(t.hash, tokenHash));
328
- if (!match) return null;
329
- return { tokenId: match.id, databaseName: row.databaseName };
330
- }