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.
Files changed (43) hide show
  1. package/.genie/brainstorms/pgserve-v2/DESIGN.md +174 -0
  2. package/.genie/wishes/pgserve-v2/BRIEF-from-genie-pgserve.md +99 -0
  3. package/.genie/wishes/pgserve-v2/WISH.md +442 -0
  4. package/.genie/wishes/release-system-genie-pattern/WISH.md +268 -0
  5. package/.genie/wishes/release-system-genie-pattern/validation.md +205 -0
  6. package/.github/workflows/ci.yml +8 -4
  7. package/.github/workflows/release.yml +233 -111
  8. package/.github/workflows/{build-all-platforms.yml → version.yml} +32 -8
  9. package/AGENTS.md +10 -8
  10. package/CHANGELOG.md +150 -0
  11. package/Makefile +18 -41
  12. package/README.md +186 -1
  13. package/SECURITY.md +109 -0
  14. package/bin/pglite-server.js +253 -1
  15. package/eslint.config.js +2 -0
  16. package/package.json +1 -1
  17. package/src/admin-client.js +171 -0
  18. package/src/audit.js +168 -0
  19. package/src/control-db.js +313 -0
  20. package/src/daemon-control.js +408 -0
  21. package/src/daemon-shared.js +18 -0
  22. package/src/daemon-tcp.js +296 -0
  23. package/src/daemon.js +629 -0
  24. package/src/fingerprint.js +453 -0
  25. package/src/gc.js +351 -0
  26. package/src/index.js +11 -0
  27. package/src/postgres.js +54 -0
  28. package/src/protocol.js +131 -0
  29. package/src/router.js +78 -5
  30. package/src/tenancy.js +75 -0
  31. package/src/tokens.js +102 -0
  32. package/tests/audit.test.js +189 -0
  33. package/tests/control-db.test.js +285 -0
  34. package/tests/daemon-fingerprint-integration.test.js +109 -0
  35. package/tests/daemon-pr24-regression.test.js +201 -0
  36. package/tests/fingerprint.test.js +249 -0
  37. package/tests/fixtures/240-orphan-seed.sql +30 -0
  38. package/tests/multi-tenant.test.js +164 -0
  39. package/tests/orphan-cleanup.test.js +390 -0
  40. package/tests/tcp-listen.test.js +368 -0
  41. package/tests/tenancy.test.js +403 -0
  42. package/.github/release.yml +0 -30
  43. package/scripts/release.cjs +0 -198
package/src/router.js CHANGED
@@ -12,8 +12,17 @@
12
12
  * - Memory mode (default) or persistent storage
13
13
  *
14
14
  * PERFORMANCE: Uses Bun.listen() and Bun.connect() for 2-3x throughput improvement
15
+ *
16
+ * v2 NOTE: The MultiTenantRouter is the **direct-embed** path — callers that
17
+ * spawn their own PostgresManager and bind a TCP port get a per-pid Unix
18
+ * socket from `pgManager.getSocketPath()` (preserved by PR #24). The new
19
+ * **daemon** path (`src/daemon.js`) binds a singleton control socket at
20
+ * `$XDG_RUNTIME_DIR/pgserve/control.sock` and is the v2 default for the
21
+ * `pgserve daemon` CLI subcommand. Both paths coexist; direct-embed callers
22
+ * are not affected by daemon mode.
15
23
  */
16
24
 
25
+ import fs from 'fs';
17
26
  import { PostgresManager } from './postgres.js';
18
27
  import { SyncManager } from './sync.js';
19
28
  import { RestoreManager } from './restore.js';
@@ -28,6 +37,14 @@ const SSL_REQUEST_CODE = 80877103;
28
37
  const GSSAPI_REQUEST_CODE = 80877104;
29
38
  const CANCEL_REQUEST_CODE = 80877102;
30
39
 
40
+ // Maximum size for the pre-handshake startup buffer. A legitimate PG
41
+ // startup message is at most a few hundred bytes; anything approaching
42
+ // 1 MiB is a runaway client or an attempted buffer-growth DoS. Bound
43
+ // this to stop the proxy from accumulating gigabytes of orphaned data
44
+ // when a client sends garbage and the handshake never completes.
45
+ // (Issue #18 root cause #2 — unbounded growth at state.buffer.)
46
+ const MAX_STARTUP_BUFFER_SIZE = 1024 * 1024; // 1 MiB
47
+
31
48
  /**
32
49
  * Attempt to write a pending buffer to a target socket.
33
50
  * Returns remaining unwritten bytes, or null if fully flushed.
@@ -231,6 +248,12 @@ export class MultiTenantRouter extends EventEmitter {
231
248
  pgSocket: null,
232
249
  dbName: null,
233
250
  handshakeComplete: false,
251
+ // startupInProgress serializes processStartupMessage() against async
252
+ // reentrancy — without it, every data event fired while the previous
253
+ // processStartupMessage() is still awaiting createDatabase() would
254
+ // launch another async task on the same state, racing to overwrite
255
+ // state.pgSocket and leaking the losers (issue #18 root cause #1).
256
+ startupInProgress: false,
234
257
  pendingToPg: null,
235
258
  pendingToClient: null
236
259
  });
@@ -249,9 +272,13 @@ export class MultiTenantRouter extends EventEmitter {
249
272
 
250
273
  // If handshake complete, forward to PostgreSQL
251
274
  if (state.handshakeComplete && state.pgSocket) {
252
- // If there's already pending data, append to it
275
+ // If there's already pending data, append and re-pause.
276
+ // (Re-pause is defensive: client should already be paused from the
277
+ // earlier partial-write, but kernel-buffered data can still arrive
278
+ // before the pause takes effect — issue #18 root cause #3.)
253
279
  if (state.pendingToPg) {
254
280
  state.pendingToPg = Buffer.concat([state.pendingToPg, data]);
281
+ socket.pause();
255
282
  return;
256
283
  }
257
284
  const written = state.pgSocket.write(data);
@@ -263,7 +290,20 @@ export class MultiTenantRouter extends EventEmitter {
263
290
  return;
264
291
  }
265
292
 
266
- // Buffer data for startup message parsing
293
+ // Buffer data for startup message parsing.
294
+ // Bound the pre-handshake buffer so a client that never completes its
295
+ // startup (or sends garbage) cannot grow state.buffer without limit —
296
+ // the 74 GiB VmSize in the production deadlock report traces to this
297
+ // path (issue #18 root cause #2).
298
+ const incomingSize = state.buffer ? state.buffer.length + data.byteLength : data.byteLength;
299
+ if (incomingSize > MAX_STARTUP_BUFFER_SIZE) {
300
+ this.logger.warn(
301
+ { incomingSize, limit: MAX_STARTUP_BUFFER_SIZE },
302
+ 'Pre-handshake buffer exceeded limit — closing connection'
303
+ );
304
+ socket.end();
305
+ return;
306
+ }
267
307
  if (state.buffer) {
268
308
  state.buffer = Buffer.concat([state.buffer, data]);
269
309
  } else {
@@ -275,9 +315,17 @@ export class MultiTenantRouter extends EventEmitter {
275
315
  }
276
316
 
277
317
  /**
278
- * Process PostgreSQL startup message and establish proxy connection
318
+ * Process PostgreSQL startup message and establish proxy connection.
319
+ *
320
+ * Guarded against async reentrancy: multiple data events arriving while
321
+ * the first processStartupMessage() is still awaiting createDatabase()
322
+ * or Bun.connect() must not launch concurrent tasks on the same state —
323
+ * they would race to assign state.pgSocket, leaking the losing sockets
324
+ * and double-writing the startup message (issue #18 root cause #1).
279
325
  */
280
326
  async processStartupMessage(socket, state) {
327
+ if (state.startupInProgress) return;
328
+
281
329
  const buffer = state.buffer;
282
330
  if (!buffer || buffer.length < 8) return; // Need at least length + protocol
283
331
 
@@ -315,6 +363,11 @@ export class MultiTenantRouter extends EventEmitter {
315
363
  const dbName = extractDatabaseName(startupMessage);
316
364
  state.dbName = dbName;
317
365
 
366
+ // Claim the reentrancy guard BEFORE the first await so subsequent data
367
+ // events (buffered into state.buffer by handleSocketData) cannot launch
368
+ // a second async task on the same state.
369
+ state.startupInProgress = true;
370
+
318
371
  try {
319
372
  // Auto-provision database if needed
320
373
  if (this.autoProvision) {
@@ -328,9 +381,13 @@ export class MultiTenantRouter extends EventEmitter {
328
381
  // Shared handler for pgSocket (used by both unix and TCP paths)
329
382
  const pgHandler = {
330
383
  data(_pgSocket, pgData) {
331
- // Forward PostgreSQL response to client with backpressure
384
+ // Forward PostgreSQL response to client with backpressure.
385
+ // Re-pause defensively when pendingToClient already exists —
386
+ // kernel-buffered PG data can arrive before the earlier pause()
387
+ // takes effect (issue #18 root cause #3).
332
388
  if (state.pendingToClient) {
333
389
  state.pendingToClient = Buffer.concat([state.pendingToClient, pgData]);
390
+ _pgSocket.pause();
334
391
  return;
335
392
  }
336
393
  const written = socket.write(pgData);
@@ -362,9 +419,18 @@ export class MultiTenantRouter extends EventEmitter {
362
419
  }
363
420
  };
364
421
 
365
- if (socketPath) {
422
+ // Safety net for issue #24: if socketPath points to a directory that was
423
+ // cleaned up (e.g. pgManager was stopped+started, or the PG subprocess
424
+ // exited unexpectedly and socketDir was reset to null but a stale cached
425
+ // path is still hanging around), fall back to TCP instead of Bun.connect
426
+ // hanging on a missing unix socket.
427
+ const useUnix = socketPath && fs.existsSync(socketPath);
428
+ if (useUnix) {
366
429
  state.pgSocket = await Bun.connect({ unix: socketPath, socket: pgHandler });
367
430
  } else {
431
+ if (socketPath && !useUnix) {
432
+ this.logger.warn({ socketPath, dbName }, 'Unix socket path stale — falling back to TCP');
433
+ }
368
434
  state.pgSocket = await Bun.connect({ hostname: '127.0.0.1', port: this.pgPort, socket: pgHandler });
369
435
  }
370
436
 
@@ -373,6 +439,13 @@ export class MultiTenantRouter extends EventEmitter {
373
439
  this.logger.error({ dbName, err: error }, 'Connection error');
374
440
  socket.end();
375
441
  this.emit('connection-error', { error, dbName });
442
+ } finally {
443
+ // Release the reentrancy guard whether handshake succeeded or not.
444
+ // If it succeeded, handshakeComplete is now true and further data
445
+ // events will bypass processStartupMessage anyway (handleSocketData
446
+ // takes the handshakeComplete path). If it failed, socket.end()
447
+ // has been called and the connection is tearing down.
448
+ state.startupInProgress = false;
376
449
  }
377
450
  }
378
451
 
package/src/tenancy.js ADDED
@@ -0,0 +1,75 @@
1
+ /**
2
+ * pgserve tenancy — fingerprint-to-database name resolution + kill-switch.
3
+ *
4
+ * Group 4 wires the kernel-rooted fingerprint (Group 3) to the per-tenant
5
+ * Postgres database. Each `(fingerprint, name)` pair maps deterministically
6
+ * to a database called `app_<sanitized-name>_<12hex>` (≤63 chars, the PG
7
+ * identifier limit).
8
+ *
9
+ * Sanitization rules (per WISH §Group 4):
10
+ * - non-[a-z0-9] runs collapse to a single `_`
11
+ * - lowercased
12
+ * - truncated to 30 chars (so `app_<30>_<12>` ≤ 47 chars, well under 63)
13
+ *
14
+ * The kill switch (`PGSERVE_DISABLE_FINGERPRINT_ENFORCEMENT=1`) is read
15
+ * once per process via `isFingerprintEnforcementDisabled()`. The daemon
16
+ * logs a deprecation warning at boot when the env var is observed; the
17
+ * audit event `enforcement_kill_switch_used` fires on every bypassed
18
+ * cross-fingerprint connection.
19
+ */
20
+
21
+ export const KILL_SWITCH_ENV = 'PGSERVE_DISABLE_FINGERPRINT_ENFORCEMENT';
22
+
23
+ const NAME_TRUNCATE = 30;
24
+ const MAX_DB_IDENT = 63;
25
+
26
+ /**
27
+ * Collapse non-alphanumeric runs to a single `_`, lowercase, truncate.
28
+ *
29
+ * Empty or null names fall back to `'anon'` so we always emit a usable
30
+ * database identifier — a peer with no resolvable package name still
31
+ * deserves a tenant DB, just one that visibly says "anonymous".
32
+ *
33
+ * @param {string|null|undefined} name
34
+ * @returns {string}
35
+ */
36
+ export function sanitizeName(name) {
37
+ const raw = (typeof name === 'string' ? name : '').toLowerCase();
38
+ const collapsed = raw.replace(/[^a-z0-9]+/g, '_');
39
+ if (!collapsed || collapsed === '_') return 'anon';
40
+ return collapsed.slice(0, NAME_TRUNCATE);
41
+ }
42
+
43
+ /**
44
+ * Build the canonical per-tenant database name `app_<sanitized>_<fingerprint>`.
45
+ *
46
+ * Throws if fingerprint is not the documented 12 lowercase-hex blob —
47
+ * any caller that managed to slip a malformed fingerprint through deserves
48
+ * a loud failure rather than a silent identifier mismatch later.
49
+ *
50
+ * @param {{name: string|null|undefined, fingerprint: string}} args
51
+ * @returns {string}
52
+ */
53
+ export function resolveTenantDatabaseName({ name, fingerprint }) {
54
+ if (!/^[0-9a-f]{12}$/.test(fingerprint || '')) {
55
+ throw new Error(`resolveTenantDatabaseName: fingerprint must be 12 hex chars, got "${fingerprint}"`);
56
+ }
57
+ const sanitized = sanitizeName(name);
58
+ const ident = `app_${sanitized}_${fingerprint}`;
59
+ if (ident.length > MAX_DB_IDENT) {
60
+ // Truncation already bounds sanitized to 30; the fingerprint adds 12;
61
+ // the prefix `app_` adds 4 + two underscores = 48. We are safe by
62
+ // construction, but assert anyway: a future change to NAME_TRUNCATE
63
+ // must not silently produce >63-char identifiers.
64
+ throw new Error(`resolveTenantDatabaseName: identifier "${ident}" exceeds ${MAX_DB_IDENT} chars`);
65
+ }
66
+ return ident;
67
+ }
68
+
69
+ /**
70
+ * @param {NodeJS.ProcessEnv} [env]
71
+ * @returns {boolean}
72
+ */
73
+ export function isFingerprintEnforcementDisabled(env = process.env) {
74
+ return env[KILL_SWITCH_ENV] === '1';
75
+ }
package/src/tokens.js ADDED
@@ -0,0 +1,102 @@
1
+ /**
2
+ * pgserve TCP bearer-token helpers (Group 6).
3
+ *
4
+ * Tokens are random 256-bit secrets shown to the operator exactly once
5
+ * (the output of `pgserve daemon issue-token`). Only their sha256 hash
6
+ * is persisted in `pgserve_meta.allowed_tokens`. Verification therefore
7
+ * compares hashes, never cleartext.
8
+ *
9
+ * Token id: short hex prefix used for revocation by humans
10
+ * (`pgserve daemon revoke-token <id>`). It is also persisted alongside
11
+ * the hash so `tcp_token_used` audit events can name which credential
12
+ * authorised the connection without leaking the secret.
13
+ *
14
+ * Wire format on the TCP path: peers pass an `application_name` shaped
15
+ * `?fingerprint=<12hex>&token=<bearer>` (a leading `?` is tolerated so
16
+ * libpq URL-style strings round-trip cleanly). Both keys are required;
17
+ * any missing or extra-long value is treated as auth-fail by the
18
+ * daemon's accept hook, never bubbling further.
19
+ */
20
+
21
+ import crypto from 'crypto';
22
+
23
+ const TOKEN_BYTES = 32; // 256 bits — plenty of entropy
24
+ const TOKEN_ID_BYTES = 6; // 12 hex chars — collision-bound at ~10^14
25
+ const MAX_TOKEN_LEN = 256; // sanity guard for parse path
26
+ const FP_RE = /^[0-9a-f]{12}$/;
27
+
28
+ /**
29
+ * Mint a fresh `(id, cleartext, hash)` triple. The cleartext is meant to
30
+ * leave this process exactly once (printed to stdout by `issue-token`);
31
+ * only the hash gets stored.
32
+ *
33
+ * @returns {{id: string, cleartext: string, hash: string}}
34
+ */
35
+ export function mintToken() {
36
+ const id = crypto.randomBytes(TOKEN_ID_BYTES).toString('hex');
37
+ const cleartext = crypto.randomBytes(TOKEN_BYTES).toString('hex');
38
+ const hash = hashToken(cleartext);
39
+ return { id, cleartext, hash };
40
+ }
41
+
42
+ /**
43
+ * Sha256 of the bearer token in lowercase hex. Centralised so daemon
44
+ * accept code, issue-token CLI, and tests cannot drift.
45
+ *
46
+ * @param {string} cleartext
47
+ * @returns {string}
48
+ */
49
+ export function hashToken(cleartext) {
50
+ if (typeof cleartext !== 'string' || cleartext.length === 0) {
51
+ throw new Error('hashToken: non-empty string required');
52
+ }
53
+ return crypto.createHash('sha256').update(cleartext).digest('hex');
54
+ }
55
+
56
+ /**
57
+ * Parse `?fingerprint=<12hex>&token=<bearer>` — or its prefix-less form —
58
+ * out of an `application_name` startup parameter.
59
+ *
60
+ * Returns `null` for any malformed input. Caller never inspects details
61
+ * beyond presence: the daemon emits a single `tcp_token_denied` audit
62
+ * event regardless of which validation step failed, to deny the peer
63
+ * any oracle that distinguishes "unknown fingerprint" from "wrong token".
64
+ *
65
+ * @param {string|undefined|null} applicationName
66
+ * @returns {{fingerprint: string, token: string} | null}
67
+ */
68
+ export function parseTcpAuth(applicationName) {
69
+ if (typeof applicationName !== 'string' || applicationName.length === 0) return null;
70
+ if (applicationName.length > MAX_TOKEN_LEN + 64) return null;
71
+ const stripped = applicationName.startsWith('?') ? applicationName.slice(1) : applicationName;
72
+ const params = new Map();
73
+ for (const segment of stripped.split('&')) {
74
+ const eq = segment.indexOf('=');
75
+ if (eq <= 0) continue;
76
+ const key = segment.slice(0, eq);
77
+ const val = segment.slice(eq + 1);
78
+ if (key && val) params.set(key, val);
79
+ }
80
+ const fingerprint = params.get('fingerprint');
81
+ const token = params.get('token');
82
+ if (!fingerprint || !token) return null;
83
+ if (!FP_RE.test(fingerprint)) return null;
84
+ if (token.length === 0 || token.length > MAX_TOKEN_LEN) return null;
85
+ return { fingerprint, token };
86
+ }
87
+
88
+ /**
89
+ * Constant-time string compare. Bearer-token verification path uses this
90
+ * after sha256 to avoid leaking length-mismatch via timing.
91
+ *
92
+ * @param {string} a
93
+ * @param {string} b
94
+ * @returns {boolean}
95
+ */
96
+ export function timingSafeEqual(a, b) {
97
+ if (typeof a !== 'string' || typeof b !== 'string') return false;
98
+ if (a.length !== b.length) return false;
99
+ const bufA = Buffer.from(a);
100
+ const bufB = Buffer.from(b);
101
+ return crypto.timingSafeEqual(bufA, bufB);
102
+ }
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Tests for src/audit.js — JSONL writer with rotation + syslog target.
3
+ *
4
+ * Tests use temp dirs under /tmp; nothing touches the user's real
5
+ * `~/.pgserve/audit.log`. The syslog test stubs `logger` via PATH so we
6
+ * don't depend on (or pollute) the host's syslog daemon.
7
+ */
8
+
9
+ import { test, expect, beforeEach, afterEach } from 'bun:test';
10
+ import fs from 'fs';
11
+ import os from 'os';
12
+ import path from 'path';
13
+ import {
14
+ audit,
15
+ configureAudit,
16
+ readAuditTarget,
17
+ AUDIT_EVENTS,
18
+ _internals,
19
+ } from '../src/audit.js';
20
+
21
+ let scratchDir;
22
+
23
+ beforeEach(() => {
24
+ scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pgserve-audit-test-'));
25
+ configureAudit({
26
+ logFile: path.join(scratchDir, 'audit.log'),
27
+ target: 'file',
28
+ });
29
+ });
30
+
31
+ afterEach(() => {
32
+ try {
33
+ fs.rmSync(scratchDir, { recursive: true, force: true });
34
+ } catch { /* noop */ }
35
+ });
36
+
37
+ test('audit() appends a JSON line per event', () => {
38
+ audit(AUDIT_EVENTS.DB_CREATED, { fingerprint: 'abc123def456', db: 'app_demo_abc123def456' });
39
+ audit(AUDIT_EVENTS.CONNECTION_ROUTED, { fingerprint: 'abc123def456', peer_pid: 1234 });
40
+
41
+ const logFile = path.join(scratchDir, 'audit.log');
42
+ const lines = fs.readFileSync(logFile, 'utf8').trim().split('\n');
43
+ expect(lines.length).toBe(2);
44
+ const r1 = JSON.parse(lines[0]);
45
+ expect(r1.event).toBe('db_created');
46
+ expect(r1.fingerprint).toBe('abc123def456');
47
+ expect(typeof r1.ts).toBe('string');
48
+ expect(new Date(r1.ts).toString()).not.toBe('Invalid Date');
49
+
50
+ const r2 = JSON.parse(lines[1]);
51
+ expect(r2.event).toBe('connection_routed');
52
+ expect(r2.peer_pid).toBe(1234);
53
+ });
54
+
55
+ test('audit() refuses unknown events', () => {
56
+ expect(() => audit('definitely_not_a_real_event', {})).toThrow(/unknown event/);
57
+ });
58
+
59
+ test('audit() creates the parent directory if missing', () => {
60
+ const nested = path.join(scratchDir, 'nested', 'sub', 'audit.log');
61
+ audit(AUDIT_EVENTS.DB_CREATED, { fingerprint: 'a'.repeat(12) }, { logFile: nested });
62
+ expect(fs.existsSync(nested)).toBe(true);
63
+ });
64
+
65
+ test('all v2.0 event names are exported (incl. Group 6 tcp_*)', () => {
66
+ expect(Object.values(AUDIT_EVENTS).sort()).toEqual([
67
+ 'connection_denied_fingerprint_mismatch',
68
+ 'connection_routed',
69
+ 'db_created',
70
+ 'db_persist_honored',
71
+ 'db_reaped_liveness',
72
+ 'db_reaped_ttl',
73
+ 'enforcement_kill_switch_used',
74
+ 'tcp_token_denied',
75
+ 'tcp_token_issued',
76
+ 'tcp_token_used',
77
+ ]);
78
+ });
79
+
80
+ test('rotation kicks in once existing file crosses 50 MB', () => {
81
+ const logFile = path.join(scratchDir, 'audit.log');
82
+ // Use a sparse file to simulate a 50 MB log without writing 50 MB.
83
+ const fd = fs.openSync(logFile, 'w');
84
+ fs.ftruncateSync(fd, _internals.ROTATE_THRESHOLD_BYTES);
85
+ fs.closeSync(fd);
86
+
87
+ audit(AUDIT_EVENTS.DB_CREATED, { fingerprint: 'r'.repeat(12) });
88
+
89
+ // Original file rotated to .1, fresh file holds the new line.
90
+ expect(fs.existsSync(`${logFile}.1`)).toBe(true);
91
+ const fresh = fs.readFileSync(logFile, 'utf8');
92
+ expect(fresh.trim().split('\n').length).toBe(1);
93
+ expect(JSON.parse(fresh.trim()).event).toBe('db_created');
94
+
95
+ // The rotated file is the original 50 MB sparse file.
96
+ expect(fs.statSync(`${logFile}.1`).size).toBe(_internals.ROTATE_THRESHOLD_BYTES);
97
+ });
98
+
99
+ test('rotation cascades up to KEEP files and drops the eldest', () => {
100
+ const logFile = path.join(scratchDir, 'audit.log');
101
+ // Pre-populate audit.log.1 ... audit.log.5 with distinct markers.
102
+ for (let i = 1; i <= _internals.ROTATE_KEEP; i++) {
103
+ fs.writeFileSync(`${logFile}.${i}`, `slot-${i}\n`);
104
+ }
105
+ // And the live audit.log just under threshold.
106
+ const fd = fs.openSync(logFile, 'w');
107
+ fs.ftruncateSync(fd, _internals.ROTATE_THRESHOLD_BYTES);
108
+ fs.closeSync(fd);
109
+
110
+ audit(AUDIT_EVENTS.DB_CREATED, { fingerprint: 'q'.repeat(12) });
111
+
112
+ // .5 (was "slot-5") dropped; .4 → .5; .3 → .4; .2 → .3; .1 → .2; live → .1.
113
+ expect(fs.readFileSync(`${logFile}.5`, 'utf8').trim()).toBe('slot-4');
114
+ expect(fs.readFileSync(`${logFile}.4`, 'utf8').trim()).toBe('slot-3');
115
+ expect(fs.readFileSync(`${logFile}.3`, 'utf8').trim()).toBe('slot-2');
116
+ expect(fs.readFileSync(`${logFile}.2`, 'utf8').trim()).toBe('slot-1');
117
+ expect(fs.statSync(`${logFile}.1`).size).toBe(_internals.ROTATE_THRESHOLD_BYTES);
118
+ });
119
+
120
+ test('audit({target:"syslog"}) spawns logger -t pgserve-audit', async () => {
121
+ // Stub `logger` by prepending a temp shim to PATH.
122
+ const shimDir = path.join(scratchDir, 'shim');
123
+ fs.mkdirSync(shimDir, { recursive: true });
124
+ const marker = path.join(scratchDir, 'logger-calls.txt');
125
+ const shimPath = path.join(shimDir, 'logger');
126
+ fs.writeFileSync(
127
+ shimPath,
128
+ `#!/usr/bin/env bash
129
+ # Capture argv to a marker file so the test can verify the spawn.
130
+ printf '%s\\n' "$*" >> "${marker}"
131
+ `,
132
+ { mode: 0o755 },
133
+ );
134
+
135
+ const oldPath = process.env.PATH;
136
+ process.env.PATH = `${shimDir}:${oldPath}`;
137
+ try {
138
+ audit(
139
+ AUDIT_EVENTS.CONNECTION_ROUTED,
140
+ { fingerprint: 's'.repeat(12) },
141
+ { target: 'syslog' },
142
+ );
143
+ // logger is spawned async; poll briefly for the marker.
144
+ const deadline = Date.now() + 2000;
145
+ while (!fs.existsSync(marker) && Date.now() < deadline) {
146
+ await new Promise(r => setTimeout(r, 25));
147
+ }
148
+ expect(fs.existsSync(marker)).toBe(true);
149
+ const contents = fs.readFileSync(marker, 'utf8');
150
+ expect(contents).toContain('-t pgserve-audit');
151
+ expect(contents).toContain('"event":"connection_routed"');
152
+ } finally {
153
+ process.env.PATH = oldPath;
154
+ }
155
+ });
156
+
157
+ test('audit({target:"syslog"}) swallows missing logger binary', () => {
158
+ // Point PATH at an empty dir → `logger` cannot be found → no throw.
159
+ const empty = path.join(scratchDir, 'empty');
160
+ fs.mkdirSync(empty);
161
+ const oldPath = process.env.PATH;
162
+ process.env.PATH = empty;
163
+ try {
164
+ expect(() =>
165
+ audit(
166
+ AUDIT_EVENTS.CONNECTION_ROUTED,
167
+ { fingerprint: 'z'.repeat(12) },
168
+ { target: 'syslog' },
169
+ ),
170
+ ).not.toThrow();
171
+ } finally {
172
+ process.env.PATH = oldPath;
173
+ }
174
+ });
175
+
176
+ test('readAuditTarget reads pgserve.audit.target from package.json', () => {
177
+ const pkgFile = path.join(scratchDir, 'package.json');
178
+ fs.writeFileSync(
179
+ pkgFile,
180
+ JSON.stringify({ name: 'demo', pgserve: { audit: { target: 'syslog' } } }),
181
+ );
182
+ expect(readAuditTarget(pkgFile)).toBe('syslog');
183
+
184
+ fs.writeFileSync(pkgFile, JSON.stringify({ name: 'demo' }));
185
+ expect(readAuditTarget(pkgFile)).toBe('file');
186
+
187
+ // Missing file → file (default).
188
+ expect(readAuditTarget(path.join(scratchDir, 'missing.json'))).toBe('file');
189
+ });