pgserve 2.4.0 → 2.5.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.
@@ -43,6 +43,10 @@ const __installSubcommands = new Set([
43
43
  'upgrade',
44
44
  'restart',
45
45
  'ui',
46
+ // pgserve singleton (v2.4) — `pgserve-singleton-no-proxy` wish, Group 4.
47
+ // `verify` shells out to cosign + writes an HMAC cache token. Pure node
48
+ // (no bun) so it must skip the bun probe like the install surface above.
49
+ 'verify',
46
50
  ]);
47
51
  if (__subcommand && __installSubcommands.has(__subcommand)) {
48
52
  const cli = require(path.join(__dirname, '..', 'src', 'cli-install.cjs'));
@@ -17,6 +17,7 @@
17
17
 
18
18
  import { PostgresManager } from '../src/postgres.js';
19
19
  import { resolveSocketDir, ensureSocketDir } from '../src/lib/socket-dir.js';
20
+ import { writeRuntimeJson, clearRuntimeJson } from '../src/lib/runtime-json.js';
20
21
  import { createLogger } from '../src/logger.js';
21
22
 
22
23
  // Global error handlers — surface unhandled rejections + uncaught errors
@@ -95,6 +96,27 @@ async function runPostmasterSubcommand(postmasterArgs) {
95
96
  process.exit(1);
96
97
  }
97
98
 
99
+ // cutover G19: drop a runtime discovery file at <socketDir>/runtime.json
100
+ // so consumers' UDS-first probes find the live socket without globbing
101
+ // ephemeral pid-stamped dirs. The file is intentionally separate from
102
+ // ~/.autopg/admin.json (which records supervisor metadata, not live
103
+ // socket info) — that split lets the postmaster restart under a new
104
+ // pid without rewriting the supervisor record. NO `supervisor` key
105
+ // here; the writer rejects it.
106
+ try {
107
+ writeRuntimeJson({
108
+ socketDir,
109
+ port: opts.port,
110
+ pid: manager.process?.pid ?? process.pid,
111
+ autopgPid: process.pid,
112
+ });
113
+ } catch (err) {
114
+ logger.warn(
115
+ { err: err.message },
116
+ 'pgserve postmaster: runtime.json write failed; consumers will fall back to admin.json',
117
+ );
118
+ }
119
+
98
120
  logger.info(
99
121
  { port: opts.port, socketDir, dataDir: opts.dataDir },
100
122
  'pgserve postmaster: ready (Unix socket + TCP)',
@@ -102,6 +124,12 @@ async function runPostmasterSubcommand(postmasterArgs) {
102
124
 
103
125
  const shutdown = async (signal) => {
104
126
  logger.info({ signal }, 'pgserve postmaster: stopping');
127
+ // Clear runtime.json BEFORE stopping the postmaster so the moment
128
+ // a graceful-shutdown signal lands, fresh consumers see "no live
129
+ // socket" instead of racing against a stale-pid record. On crash
130
+ // (uncaughtException, backend died) the file is left behind; the
131
+ // operator-facing detector is `process.kill(record.autopgPid, 0)`.
132
+ clearRuntimeJson(socketDir);
105
133
  try {
106
134
  await manager.stop();
107
135
  } catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pgserve",
3
- "version": "2.4.0",
3
+ "version": "2.5.0",
4
4
  "description": "Embedded PostgreSQL server with true concurrent connections - zero config, auto-provision databases",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -30,6 +30,7 @@
30
30
  "console:dev": "bun build console/src/main.jsx --target browser --define 'process.env.NODE_ENV=\"development\"' --watch --outfile console/dist/app.js",
31
31
  "lint": "eslint src/ bin/",
32
32
  "lint:fix": "eslint src/ bin/ --fix",
33
+ "lint:audit": "bun scripts/audit-redaction-lint.js",
33
34
  "deadcode": "knip",
34
35
  "test:npx": "scripts/test-npx.sh",
35
36
  "test:bun-self-heal": "scripts/test-bun-self-heal.sh",
@@ -0,0 +1,349 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Audit redaction lint — Group 6, autopg-distribution-cutover.
4
+ *
5
+ * Walks every .js / .cjs source under `src/` (excluding `src/audit/audit.js`
6
+ * itself) and locates every `auditEmit(...)` call. For each call site, the
7
+ * lint asserts:
8
+ *
9
+ * 1. No object-literal key matches /password|secret|token|connection_string|database_url/i.
10
+ * 2. No value is `process.env.*PASSWORD*|*SECRET*|*TOKEN*|*DATABASE_URL*|*CONNECTION_STRING*`.
11
+ * 3. No string-literal value looks like a `postgres://user:pass@host/...` URL.
12
+ *
13
+ * Failure = exit 1 with file:line per offending site. Clean tree = exit 0.
14
+ *
15
+ * The walker is a hand-rolled scanner rather than a full parser: it tracks
16
+ * string state (single, double, backtick), template-literal nesting, line
17
+ * comments, block comments, and balanced brace depth. That's enough to
18
+ * isolate the first object-literal argument of every `auditEmit(...)` call
19
+ * without pulling in a parser dependency. New AST-flavored rules can be
20
+ * added by extending `scanRecord()`.
21
+ *
22
+ * Usage:
23
+ * bun run scripts/audit-redaction-lint.js
24
+ * bun run scripts/audit-redaction-lint.js path/to/file.js
25
+ * bun run scripts/audit-redaction-lint.js --fixture tests/audit/fixtures
26
+ */
27
+
28
+ import fs from 'fs';
29
+ import path from 'path';
30
+
31
+ const REPO_ROOT = path.resolve(import.meta.dir, '..');
32
+ const DEFAULT_ROOT = path.join(REPO_ROOT, 'src');
33
+ const SELF_EXCLUDE = path.join('src', 'audit', 'audit.js');
34
+
35
+ const FORBIDDEN_KEY_RE = /^(password|secret|token|connection_string|database_url)$/i;
36
+ const ENV_SECRET_RE =
37
+ /process\.env\.[A-Za-z_][A-Za-z0-9_]*(PASSWORD|SECRET|TOKEN|DATABASE_URL|CONNECTION_STRING)[A-Za-z0-9_]*\b/i;
38
+ const POSTGRES_URL_RE = /\bpostgres(?:ql)?:\/\/[^\s'"]*:[^\s'"@]+@/i;
39
+
40
+ function listSourceFiles(roots) {
41
+ const out = [];
42
+ for (const root of roots) {
43
+ if (!fs.existsSync(root)) continue;
44
+ const stat = fs.statSync(root);
45
+ if (stat.isFile()) {
46
+ if (/\.(js|cjs|mjs)$/.test(root)) out.push(root);
47
+ continue;
48
+ }
49
+ walk(root, out);
50
+ }
51
+ return out.filter((p) => !p.endsWith(SELF_EXCLUDE));
52
+ }
53
+
54
+ function walk(dir, out) {
55
+ for (const name of fs.readdirSync(dir)) {
56
+ if (name === 'node_modules' || name.startsWith('.')) continue;
57
+ const full = path.join(dir, name);
58
+ const st = fs.statSync(full);
59
+ if (st.isDirectory()) {
60
+ walk(full, out);
61
+ } else if (/\.(js|cjs|mjs)$/.test(name)) {
62
+ out.push(full);
63
+ }
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Find every `auditEmit(...)` call in `src` and yield each one's first
69
+ * argument span (the object literal between `{` and matching `}`), plus
70
+ * the file-relative line number of the `auditEmit` identifier.
71
+ */
72
+ function* findAuditEmitCalls(src) {
73
+ const len = src.length;
74
+ let i = 0;
75
+ let line = 1;
76
+
77
+ const isIdentChar = (ch) => /[A-Za-z0-9_$]/.test(ch);
78
+
79
+ while (i < len) {
80
+ const ch = src[i];
81
+
82
+ // Track newlines for accurate line reporting.
83
+ if (ch === '\n') { line++; i++; continue; }
84
+
85
+ // Skip line comments.
86
+ if (ch === '/' && src[i + 1] === '/') {
87
+ while (i < len && src[i] !== '\n') i++;
88
+ continue;
89
+ }
90
+ // Skip block comments.
91
+ if (ch === '/' && src[i + 1] === '*') {
92
+ i += 2;
93
+ while (i < len && !(src[i] === '*' && src[i + 1] === '/')) {
94
+ if (src[i] === '\n') line++;
95
+ i++;
96
+ }
97
+ i += 2;
98
+ continue;
99
+ }
100
+ // Skip strings.
101
+ if (ch === '"' || ch === "'" || ch === '`') {
102
+ i = skipString(src, i, ch, (n) => { line += n; });
103
+ continue;
104
+ }
105
+
106
+ // Match `auditEmit` as a whole identifier (must be word-boundary-prefixed).
107
+ if (ch === 'a' && src.startsWith('auditEmit', i)) {
108
+ const before = src[i - 1];
109
+ const after = src[i + 'auditEmit'.length];
110
+ if ((!before || !isIdentChar(before)) && !isIdentChar(after)) {
111
+ const idLine = line;
112
+ // Advance past identifier, skip whitespace, expect '('.
113
+ let j = i + 'auditEmit'.length;
114
+ while (j < len && /\s/.test(src[j])) {
115
+ if (src[j] === '\n') line++;
116
+ j++;
117
+ }
118
+ if (src[j] === '(') {
119
+ // Now find first `{` (the object-literal arg start) before the
120
+ // matching ')'. Object literals as function args appear directly
121
+ // after `(` (perhaps after whitespace) — but `auditEmit` callers
122
+ // pass an object literal by spec, so this is the path we care
123
+ // about. If we find a `)` first, it's auditEmit() with no
124
+ // object-literal arg → not redaction-relevant; skip.
125
+ let k = j + 1;
126
+ while (k < len) {
127
+ const c = src[k];
128
+ if (c === '\n') { line++; k++; continue; }
129
+ if (/\s/.test(c)) { k++; continue; }
130
+ if (c === '/' && src[k + 1] === '/') {
131
+ while (k < len && src[k] !== '\n') k++;
132
+ continue;
133
+ }
134
+ if (c === '/' && src[k + 1] === '*') {
135
+ k += 2;
136
+ while (k < len && !(src[k] === '*' && src[k + 1] === '/')) {
137
+ if (src[k] === '\n') line++;
138
+ k++;
139
+ }
140
+ k += 2;
141
+ continue;
142
+ }
143
+ break;
144
+ }
145
+ if (src[k] === '{') {
146
+ const literalLine = line;
147
+ const end = findBalanced(src, k, '{', '}', (n) => { line += n; });
148
+ if (end !== -1) {
149
+ const literal = src.slice(k, end + 1);
150
+ yield { call: 'auditEmit', idLine, literalLine, literal };
151
+ i = end + 1;
152
+ continue;
153
+ }
154
+ }
155
+ }
156
+ }
157
+ }
158
+
159
+ i++;
160
+ }
161
+ }
162
+
163
+ function skipString(src, i, quote, onNewline) {
164
+ // Returns the index just past the closing quote.
165
+ const len = src.length;
166
+ i++; // open quote
167
+ while (i < len) {
168
+ const ch = src[i];
169
+ if (ch === '\\') { i += 2; continue; }
170
+ if (ch === '\n') { onNewline(1); i++; continue; }
171
+ if (ch === quote) { return i + 1; }
172
+ if (quote === '`' && ch === '$' && src[i + 1] === '{') {
173
+ // Template literal interpolation — find matching `}`.
174
+ i = findBalanced(src, i + 1, '{', '}', onNewline);
175
+ if (i === -1) return len;
176
+ i++;
177
+ continue;
178
+ }
179
+ i++;
180
+ }
181
+ return len;
182
+ }
183
+
184
+ function findBalanced(src, start, open, close, onNewline) {
185
+ const len = src.length;
186
+ let depth = 0;
187
+ let i = start;
188
+ while (i < len) {
189
+ const ch = src[i];
190
+ if (ch === '\n') { onNewline(1); i++; continue; }
191
+ if (ch === '/' && src[i + 1] === '/') {
192
+ while (i < len && src[i] !== '\n') i++;
193
+ continue;
194
+ }
195
+ if (ch === '/' && src[i + 1] === '*') {
196
+ i += 2;
197
+ while (i < len && !(src[i] === '*' && src[i + 1] === '/')) {
198
+ if (src[i] === '\n') onNewline(1);
199
+ i++;
200
+ }
201
+ i += 2;
202
+ continue;
203
+ }
204
+ if (ch === '"' || ch === "'" || ch === '`') {
205
+ i = skipString(src, i, ch, onNewline);
206
+ continue;
207
+ }
208
+ if (ch === open) depth++;
209
+ else if (ch === close) {
210
+ depth--;
211
+ if (depth === 0) return i;
212
+ }
213
+ i++;
214
+ }
215
+ return -1;
216
+ }
217
+
218
+ /**
219
+ * Walk the captured object-literal source and pull out each top-level key.
220
+ * Skips nested objects/arrays so a nested `meta: { secret: 'x' }` is still
221
+ * caught (see scanForNestedSecrets) — but the simple flat-key shape is the
222
+ * primary check.
223
+ */
224
+ function extractTopLevelKeys(literal) {
225
+ // Drop the outer braces.
226
+ if (literal[0] !== '{' || literal[literal.length - 1] !== '}') return [];
227
+ const inner = literal.slice(1, -1);
228
+ const keys = [];
229
+ let i = 0;
230
+ let line = 0;
231
+ const len = inner.length;
232
+ let depth = 0;
233
+ let atKeyPosition = true;
234
+
235
+ while (i < len) {
236
+ const ch = inner[i];
237
+ if (ch === '\n') { line++; i++; continue; }
238
+ if (ch === '/' && inner[i + 1] === '/') {
239
+ while (i < len && inner[i] !== '\n') i++;
240
+ continue;
241
+ }
242
+ if (ch === '/' && inner[i + 1] === '*') {
243
+ i += 2;
244
+ while (i < len && !(inner[i] === '*' && inner[i + 1] === '/')) {
245
+ if (inner[i] === '\n') line++;
246
+ i++;
247
+ }
248
+ i += 2;
249
+ continue;
250
+ }
251
+ if (ch === '"' || ch === "'" || ch === '`') {
252
+ const next = skipString(inner, i, ch, (n) => { line += n; });
253
+ if (depth === 0 && atKeyPosition) {
254
+ const raw = inner.slice(i + 1, next - 1);
255
+ keys.push({ name: raw, line });
256
+ atKeyPosition = false;
257
+ }
258
+ i = next;
259
+ continue;
260
+ }
261
+ if (ch === '{' || ch === '[' || ch === '(') { depth++; atKeyPosition = false; i++; continue; }
262
+ if (ch === '}' || ch === ']' || ch === ')') { depth--; i++; continue; }
263
+ if (ch === ',' && depth === 0) { atKeyPosition = true; i++; continue; }
264
+ if (ch === ':' && depth === 0) { atKeyPosition = false; i++; continue; }
265
+ if (depth === 0 && atKeyPosition && /[A-Za-z_$]/.test(ch)) {
266
+ const start = i;
267
+ while (i < len && /[A-Za-z0-9_$]/.test(inner[i])) i++;
268
+ const name = inner.slice(start, i);
269
+ keys.push({ name, line });
270
+ atKeyPosition = false;
271
+ continue;
272
+ }
273
+ i++;
274
+ }
275
+ return keys;
276
+ }
277
+
278
+ function lintFile(file) {
279
+ const src = fs.readFileSync(file, 'utf8');
280
+ const errors = [];
281
+ const rel = path.relative(REPO_ROOT, file);
282
+
283
+ for (const call of findAuditEmitCalls(src)) {
284
+ const keys = extractTopLevelKeys(call.literal);
285
+ for (const key of keys) {
286
+ if (FORBIDDEN_KEY_RE.test(key.name)) {
287
+ errors.push({
288
+ file: rel,
289
+ line: call.literalLine + key.line,
290
+ message: `auditEmit field "${key.name}" matches forbidden secret pattern (${FORBIDDEN_KEY_RE})`,
291
+ });
292
+ }
293
+ }
294
+ // Value-side checks run on the whole literal: env-secret references and
295
+ // postgres:// URLs would be caught here even if buried inside nested
296
+ // objects.
297
+ const envMatch = call.literal.match(ENV_SECRET_RE);
298
+ if (envMatch) {
299
+ const offset = envMatch.index || 0;
300
+ const lineOffset = call.literal.slice(0, offset).split('\n').length - 1;
301
+ errors.push({
302
+ file: rel,
303
+ line: call.literalLine + lineOffset,
304
+ message: `auditEmit value sources from secret env var: ${envMatch[0]}`,
305
+ });
306
+ }
307
+ const urlMatch = call.literal.match(POSTGRES_URL_RE);
308
+ if (urlMatch) {
309
+ const offset = urlMatch.index || 0;
310
+ const lineOffset = call.literal.slice(0, offset).split('\n').length - 1;
311
+ errors.push({
312
+ file: rel,
313
+ line: call.literalLine + lineOffset,
314
+ message: `auditEmit value contains postgres URL with embedded password: ${urlMatch[0]}…`,
315
+ });
316
+ }
317
+ }
318
+ return errors;
319
+ }
320
+
321
+ function main(argv) {
322
+ const args = argv.slice(2);
323
+ let roots;
324
+ if (args.length === 0) {
325
+ roots = [DEFAULT_ROOT];
326
+ } else {
327
+ roots = args.map((a) => path.resolve(a));
328
+ }
329
+ const files = listSourceFiles(roots);
330
+ const allErrors = [];
331
+ for (const f of files) {
332
+ allErrors.push(...lintFile(f));
333
+ }
334
+ if (allErrors.length === 0) {
335
+ console.log(`audit-redaction-lint: scanned ${files.length} file(s); 0 issues.`);
336
+ return 0;
337
+ }
338
+ for (const err of allErrors) {
339
+ console.error(`${err.file}:${err.line}: ${err.message}`);
340
+ }
341
+ console.error(`audit-redaction-lint: ${allErrors.length} issue(s) across ${files.length} file(s).`);
342
+ return 1;
343
+ }
344
+
345
+ if (import.meta.main) {
346
+ process.exit(main(process.argv));
347
+ }
348
+
349
+ export { lintFile, findAuditEmitCalls, extractTopLevelKeys, listSourceFiles };
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Structured audit emitter for privilege-changing operations.
3
+ *
4
+ * Group 6 of autopg-distribution-cutover. This is the v1 audit surface
5
+ * consumed by Group 5's `create-app` / `list` / `revoke` / `rotate` and the
6
+ * LOCK 1 manifest verifier. Distinct from the legacy `src/audit.js` event
7
+ * stream (DB lifecycle, connection routing): that stream is `event`-keyed
8
+ * and writes to `~/.autopg/audit.log`; this stream is `op`-keyed and writes
9
+ * to `~/.autopg/logs/audit.log` with `schemaVersion: 1`.
10
+ *
11
+ * Records are JSON Lines. Every emit produces exactly one line. The shape
12
+ * is fixed at v1 to give the redaction lint a stable target — adding a new
13
+ * field is a `schemaVersion: 2` migration, not an in-place addition.
14
+ *
15
+ * Threat model the redaction lint guards:
16
+ * - The audit log will leak. Plan for it.
17
+ * - Therefore: no field name may be a secret category, and no value may
18
+ * be sourced from `process.env.*PASSWORD*` (or matching token/secret
19
+ * patterns). The lint enforces this at every call site.
20
+ */
21
+
22
+ import fs from 'fs';
23
+ import os from 'os';
24
+ import path from 'path';
25
+
26
+ export const AUDIT_SCHEMA_VERSION = 1;
27
+
28
+ export const AUDIT_OPS = Object.freeze({
29
+ CREATE_APP: 'create-app',
30
+ REVOKE: 'revoke',
31
+ ROTATE: 'rotate',
32
+ MANIFEST_VERIFY: 'manifest-verify',
33
+ MANIFEST_VERIFY_BYPASS: 'manifest-verify-bypass',
34
+ ADOPT_EXISTING_DB: 'adopt-existing-db',
35
+ });
36
+
37
+ const VALID_OPS = new Set(Object.values(AUDIT_OPS));
38
+
39
+ const FILE_MODE = 0o600;
40
+ const DIR_MODE = 0o700;
41
+
42
+ function getConfigDir() {
43
+ return (
44
+ process.env.AUTOPG_CONFIG_DIR ||
45
+ process.env.PGSERVE_CONFIG_DIR ||
46
+ path.join(os.homedir(), '.autopg')
47
+ );
48
+ }
49
+
50
+ function defaultLogPath() {
51
+ return path.join(getConfigDir(), 'logs', 'audit.log');
52
+ }
53
+
54
+ let LOG_PATH = defaultLogPath();
55
+
56
+ /**
57
+ * Override the audit log path. Tests use this to redirect into a scratch
58
+ * dir; the daemon may use it if `AUTOPG_CONFIG_DIR` is set after import.
59
+ *
60
+ * Pass no argument to reset to the default (re-resolves env vars).
61
+ *
62
+ * @param {{logFile?: string}} [cfg]
63
+ */
64
+ export function configureAuditEmit(cfg = {}) {
65
+ if (cfg.logFile) {
66
+ LOG_PATH = cfg.logFile;
67
+ return;
68
+ }
69
+ LOG_PATH = defaultLogPath();
70
+ }
71
+
72
+ export function getAuditLogPath() {
73
+ return LOG_PATH;
74
+ }
75
+
76
+ /**
77
+ * Emit a single audit record.
78
+ *
79
+ * Required: `op`, `actor`. Optional: `app`, `role`, `manifestSha256`,
80
+ * `sigVerified`, `incidentId`. Unknown fields are passed through verbatim
81
+ * so call sites stay flexible — but the redaction lint validates that the
82
+ * payload never contains secret-shaped names or env-sourced secret values.
83
+ *
84
+ * Record shape on disk (JSON Lines):
85
+ * {"schemaVersion":1,"ts":"<iso>","op":"create-app",...}
86
+ *
87
+ * Returns the written record (mostly for tests; production callers ignore).
88
+ *
89
+ * @param {object} record
90
+ * @param {string} record.op - one of AUDIT_OPS
91
+ * @param {string} [record.actor] - OS user or admin role performing the op
92
+ * @param {string} [record.app] - target app name
93
+ * @param {string} [record.role] - target postgres role
94
+ * @param {string} [record.manifestSha256] - hex sha256 of the verified manifest
95
+ * @param {boolean} [record.sigVerified] - whether the manifest sig verified
96
+ * @param {string} [record.incidentId] - present only when bypass was used
97
+ * @returns {object}
98
+ */
99
+ export function auditEmit(record) {
100
+ if (!record || typeof record !== 'object') {
101
+ throw new Error('auditEmit: record must be an object');
102
+ }
103
+ if (typeof record.op !== 'string' || !VALID_OPS.has(record.op)) {
104
+ throw new Error(
105
+ `auditEmit: unknown op "${record.op}". Allowed: ${[...VALID_OPS].join(', ')}`
106
+ );
107
+ }
108
+
109
+ const out = {
110
+ schemaVersion: AUDIT_SCHEMA_VERSION,
111
+ ts: new Date().toISOString(),
112
+ ...record,
113
+ };
114
+
115
+ writeJsonLine(out, LOG_PATH);
116
+ return out;
117
+ }
118
+
119
+ function writeJsonLine(record, logFile) {
120
+ const dir = path.dirname(logFile);
121
+ if (!fs.existsSync(dir)) {
122
+ fs.mkdirSync(dir, { recursive: true, mode: DIR_MODE });
123
+ }
124
+ const fd = fs.openSync(logFile, 'a', FILE_MODE);
125
+ try {
126
+ fs.writeSync(fd, JSON.stringify(record) + '\n');
127
+ } finally {
128
+ fs.closeSync(fd);
129
+ }
130
+ try {
131
+ fs.chmodSync(logFile, FILE_MODE);
132
+ } catch { /* best-effort tighten */ }
133
+ }
134
+