pgserve 2.2.1 → 2.2.3

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,13 @@
1
+ <!doctype html>
2
+ <html lang="en" data-theme="mdr" data-screen-label="autopg console">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <title>autopg · console</title>
6
+ <link rel="stylesheet" href="colors_and_type.css" />
7
+ <link rel="stylesheet" href="console.css?v=settings1" />
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script type="module" src="./app.js"></script>
12
+ </body>
13
+ </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pgserve",
3
- "version": "2.2.1",
3
+ "version": "2.2.3",
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",
@@ -11,7 +11,7 @@
11
11
  "files": [
12
12
  "bin/",
13
13
  "src/",
14
- "console/",
14
+ "console/dist/",
15
15
  "README.md",
16
16
  "CHANGELOG.md",
17
17
  "LICENSE",
@@ -26,12 +26,14 @@
26
26
  "start": "bun bin/postgres-server.js",
27
27
  "build": "bun build --compile bin/postgres-server.js --outfile dist/pgserve",
28
28
  "build:all": "make build-all",
29
+ "console:build": "bun build console/src/main.jsx --target browser --minify --define 'process.env.NODE_ENV=\"production\"' --outfile console/dist/app.js && cp console/src/index.html console/dist/index.html && cp console/src/*.css console/dist/",
30
+ "console:dev": "bun build console/src/main.jsx --target browser --define 'process.env.NODE_ENV=\"development\"' --watch --outfile console/dist/app.js",
29
31
  "lint": "eslint src/ bin/",
30
32
  "lint:fix": "eslint src/ bin/ --fix",
31
33
  "deadcode": "knip",
32
34
  "test:npx": "scripts/test-npx.sh",
33
35
  "test:bun-self-heal": "scripts/test-bun-self-heal.sh",
34
- "prepublishOnly": "npm run lint && npm run deadcode && npm run test:npx && npm run test:bun-self-heal",
36
+ "prepublishOnly": "npm run console:build && npm run lint && npm run deadcode && npm run test:npx && npm run test:bun-self-heal",
35
37
  "prepare": "husky",
36
38
  "postinstall": "node scripts/postinstall.cjs"
37
39
  },
@@ -76,6 +78,8 @@
76
78
  ]
77
79
  },
78
80
  "dependencies": {
79
- "bun": "^1.3.4"
81
+ "bun": "^1.3.4",
82
+ "react": "^18.3.1",
83
+ "react-dom": "^18.3.1"
80
84
  }
81
85
  }
@@ -22,6 +22,7 @@
22
22
  'use strict';
23
23
 
24
24
  const { spawnSync, execFileSync } = require('node:child_process');
25
+ const crypto = require('node:crypto');
25
26
  const fs = require('node:fs');
26
27
  const os = require('node:os');
27
28
  const path = require('node:path');
@@ -29,6 +30,24 @@ const path = require('node:path');
29
30
  const PM2_PROCESS_NAME = 'pgserve';
30
31
  const DEFAULT_PORT = 8432;
31
32
 
33
+ // Console UI is auto-supervised under pm2 alongside the daemon since v2.2.3.
34
+ // The bundled SPA (console/dist/) is served on this port; operator-facing
35
+ // only, 127.0.0.1, no auth, no TLS — matches autopg's "single-user dev tool"
36
+ // posture. Opt-out with `autopg install --no-ui` for headless/CI hosts.
37
+ const UI_PM2_PROCESS_NAME = 'autopg-ui';
38
+ const DEFAULT_UI_PORT = 8433;
39
+ const DEFAULT_UI_HOST = '127.0.0.1';
40
+ const UI_MAX_MEMORY = '256M';
41
+
42
+ // Admin password file shape (~/.autopg/admin.json, mode 0600):
43
+ // { scheme: 'scrypt', salt: <b64>, hash: <b64>, createdAt, rotatedAt }
44
+ // Generated on first `autopg install`; rotated via `autopg auth
45
+ // rotate-admin-password`. cli-ui.cjs reads this via getAdminFilePath()
46
+ // and gates Basic Auth against it.
47
+ const ADMIN_FILE_NAME = 'admin.json';
48
+ const SCRYPT_PARAMS = { N: 16384, r: 8, p: 1, dkLen: 32 };
49
+ const ADMIN_PASSWORD_BYTES = 12; // 96 bits → ~24 hex chars; plenty for a localhost dev tool
50
+
32
51
  /**
33
52
  * Hardening defaults — tuned for production-grade elasticity, NOT
34
53
  * the toy-machine values an initial draft of the wish carried.
@@ -268,13 +287,248 @@ function ok(message) {
268
287
  }
269
288
 
270
289
  /**
271
- * `pgserve install [--port N] [--data PATH]`
290
+ * Resolve the autopg wrapper used to launch the UI under pm2. The wrapper
291
+ * lives next to `postgres-server.js` (same `bin/` dir).
292
+ */
293
+ function getUiBinPath(scriptPath) {
294
+ return path.join(path.dirname(scriptPath), 'autopg-wrapper.cjs');
295
+ }
296
+
297
+ /**
298
+ * pm2 start args for the UI process. Smaller memory cap than the daemon
299
+ * (idle node http server, no postgres backend), shares the same restart
300
+ * budget + exp-backoff as pgserve.
301
+ */
302
+ function buildUiPm2StartArgs({ uiBinPath, uiPort, uiHost }) {
303
+ const logs = {
304
+ out: path.join(getLogsDir(), `${UI_PM2_PROCESS_NAME}-out.log`),
305
+ error: path.join(getLogsDir(), `${UI_PM2_PROCESS_NAME}-error.log`),
306
+ };
307
+ const supervision = getEffectiveSupervision();
308
+ return [
309
+ 'start',
310
+ uiBinPath,
311
+ '--name',
312
+ UI_PM2_PROCESS_NAME,
313
+ '--interpreter',
314
+ 'none',
315
+ '--max-restarts',
316
+ String(supervision.maxRestarts),
317
+ '--restart-delay',
318
+ String(supervision.restartDelayMs),
319
+ '--exp-backoff-restart-delay',
320
+ String(supervision.expBackoffRestartDelayMs),
321
+ '--max-memory-restart',
322
+ UI_MAX_MEMORY,
323
+ '--kill-timeout',
324
+ String(supervision.killTimeoutMs),
325
+ '--log-date-format',
326
+ supervision.logDateFormat,
327
+ '--output',
328
+ logs.out,
329
+ '--error',
330
+ logs.error,
331
+ '--',
332
+ 'ui',
333
+ '--no-open',
334
+ '--port',
335
+ String(uiPort),
336
+ '--host',
337
+ uiHost,
338
+ ];
339
+ }
340
+
341
+ /**
342
+ * Register `autopg-ui` under pm2. Soft-fails: if pm2 is missing, the bin
343
+ * is missing, or the spawn fails — log a note and return 0 anyway. The
344
+ * daemon is the load-bearing process; the UI is convenience.
345
+ */
346
+ function cmdInstallUi(ctx, options = {}) {
347
+ if (!pm2IsAvailable()) {
348
+ note('pm2 not found; skipping UI install (run `autopg ui` on demand)');
349
+ return 0;
350
+ }
351
+
352
+ const uiBinPath = getUiBinPath(ctx.scriptPath);
353
+ if (!fs.existsSync(uiBinPath)) {
354
+ note(`UI bin not found at ${uiBinPath}; skipping UI install`);
355
+ return 0;
356
+ }
357
+
358
+ const uiPort = options.uiPort ?? DEFAULT_UI_PORT;
359
+ const uiHost = options.uiHost ?? DEFAULT_UI_HOST;
360
+ const refresh = options.refresh === true;
361
+
362
+ // If a UI process already exists: refresh-mode replaces it (pick up new
363
+ // host/port/etc); idempotent-mode keeps it. Default behavior is
364
+ // idempotent — operators who want to apply new flags pass --with-ui
365
+ // (which sets refresh=true) so the change takes effect without a
366
+ // separate uninstall step.
367
+ const existing = pm2GetProcess(UI_PM2_PROCESS_NAME);
368
+ if (existing && !refresh) {
369
+ ok(`UI already installed (pm2 process "${UI_PM2_PROCESS_NAME}", status=${existing.pm2_env?.status ?? 'unknown'})`);
370
+ return 0;
371
+ }
372
+ if (existing && refresh) {
373
+ spawnSync('pm2', ['delete', UI_PM2_PROCESS_NAME], { stdio: ['ignore', 'pipe', 'pipe'] });
374
+ }
375
+
376
+ ensureLogsDir();
377
+ const pm2Args = buildUiPm2StartArgs({ uiBinPath, uiPort, uiHost });
378
+ const result = spawnSync('pm2', pm2Args, { stdio: 'inherit' });
379
+ if (result.status !== 0) {
380
+ note(`UI install failed (exit ${result.status}); daemon is unaffected. Run \`autopg ui\` manually.`);
381
+ return 0;
382
+ }
383
+ ok(`UI ${refresh && existing ? 'refreshed' : 'installed'}: pm2 process "${UI_PM2_PROCESS_NAME}" on http://${uiHost}:${uiPort}`);
384
+ return 0;
385
+ }
386
+
387
+ function cmdUninstallUi() {
388
+ if (!pm2IsAvailable()) return 0;
389
+ // Always attempt the delete — `pm2 delete <name>` is idempotent and
390
+ // exits non-zero on a missing process, which is fine to swallow. This
391
+ // is more robust than a pre-existence check against `pm2 jlist`, which
392
+ // can lag the actual process state (or, in test stubs, return a
393
+ // partial registry).
394
+ const result = spawnSync('pm2', ['delete', UI_PM2_PROCESS_NAME], {
395
+ stdio: ['ignore', 'pipe', 'pipe'],
396
+ });
397
+ if (result.status === 0) {
398
+ ok(`UI uninstalled (pm2 process "${UI_PM2_PROCESS_NAME}" removed)`);
399
+ }
400
+ return 0;
401
+ }
402
+
403
+ // ─── Admin password (Basic Auth for `autopg ui`) ─────────────────────────
404
+
405
+ function getAdminFilePath() {
406
+ return path.join(getConfigDir(), ADMIN_FILE_NAME);
407
+ }
408
+
409
+ function generateAdminPassword() {
410
+ // 12 bytes → 24 hex chars, grouped in 4-char chunks for human transcription:
411
+ // "7f3a-92c1-8ed4-1b6c-..." (no ambiguous chars beyond hex; matches the
412
+ // "really simple, don't reinvent" bar — operators copy-paste from a single
413
+ // stdout line into a browser dialog).
414
+ const raw = crypto.randomBytes(ADMIN_PASSWORD_BYTES).toString('hex');
415
+ return raw.match(/.{1,4}/g).join('-');
416
+ }
417
+
418
+ function hashAdminPassword(password, salt) {
419
+ // scrypt is RFC 7914 + built into Node since 10.5. No npm dep.
420
+ return crypto.scryptSync(password, salt, SCRYPT_PARAMS.dkLen, {
421
+ N: SCRYPT_PARAMS.N,
422
+ r: SCRYPT_PARAMS.r,
423
+ p: SCRYPT_PARAMS.p,
424
+ });
425
+ }
426
+
427
+ function writeAdminFile({ password, rotated = false }) {
428
+ const salt = crypto.randomBytes(32);
429
+ const hash = hashAdminPassword(password, salt);
430
+ const file = getAdminFilePath();
431
+ const now = new Date().toISOString();
432
+ const payload = {
433
+ scheme: 'scrypt',
434
+ params: SCRYPT_PARAMS,
435
+ salt: salt.toString('base64'),
436
+ hash: hash.toString('base64'),
437
+ createdAt: rotated ? readAdminFile()?.createdAt ?? now : now,
438
+ rotatedAt: rotated ? now : null,
439
+ };
440
+ ensureConfigDir();
441
+ // Atomic write: tmp + rename. mode 0600 enforced via fchmod after write.
442
+ const tmp = `${file}.tmp`;
443
+ fs.writeFileSync(tmp, JSON.stringify(payload, null, 2), { mode: 0o600 });
444
+ fs.renameSync(tmp, file);
445
+ fs.chmodSync(file, 0o600);
446
+ return payload;
447
+ }
448
+
449
+ function readAdminFile() {
450
+ try {
451
+ const raw = fs.readFileSync(getAdminFilePath(), 'utf8');
452
+ return JSON.parse(raw);
453
+ } catch {
454
+ return null;
455
+ }
456
+ }
457
+
458
+ function ensureConfigDir() {
459
+ const dir = getConfigDir();
460
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
461
+ }
462
+
463
+ /**
464
+ * Verify a candidate password against the stored hash. Returns true on match.
465
+ * Used by cli-ui.cjs at every Basic Auth check. Constant-time comparison
466
+ * via crypto.timingSafeEqual.
467
+ */
468
+ function verifyAdminPassword(candidate) {
469
+ const stored = readAdminFile();
470
+ if (!stored || stored.scheme !== 'scrypt') return false;
471
+ const salt = Buffer.from(stored.salt, 'base64');
472
+ const expected = Buffer.from(stored.hash, 'base64');
473
+ const params = stored.params || SCRYPT_PARAMS;
474
+ let actual;
475
+ try {
476
+ actual = crypto.scryptSync(candidate, salt, params.dkLen, {
477
+ N: params.N,
478
+ r: params.r,
479
+ p: params.p,
480
+ });
481
+ } catch {
482
+ return false;
483
+ }
484
+ if (actual.length !== expected.length) return false;
485
+ return crypto.timingSafeEqual(actual, expected);
486
+ }
487
+
488
+ function ensureAdminPassword({ rotate = false } = {}) {
489
+ const existing = readAdminFile();
490
+ if (existing && !rotate) return null;
491
+ const password = generateAdminPassword();
492
+ writeAdminFile({ password, rotated: !!existing });
493
+ return password;
494
+ }
495
+
496
+ function cmdAuthRotate() {
497
+ const password = ensureAdminPassword({ rotate: true });
498
+ if (!password) {
499
+ fail('admin password rotation produced no new password (unexpected)');
500
+ }
501
+ process.stdout.write(`pgserve: admin password rotated. New password (printed ONCE):\n\n ${password}\n\n`);
502
+ process.stdout.write(`Saved hash to ${getAdminFilePath()} (mode 0600).\n`);
503
+ process.stdout.write(`Existing browser sessions will be re-prompted on their next request.\n`);
504
+ return 0;
505
+ }
506
+
507
+ function cmdAuthDispatch(args) {
508
+ const sub = args[0];
509
+ switch (sub) {
510
+ case 'rotate-admin-password':
511
+ return cmdAuthRotate();
512
+ case 'show-admin-path':
513
+ process.stdout.write(`${getAdminFilePath()}\n`);
514
+ return 0;
515
+ default:
516
+ fail(`pgserve auth: unknown subcommand "${sub ?? ''}". Try: rotate-admin-password | show-admin-path`);
517
+ }
518
+ }
519
+
520
+ /**
521
+ * `pgserve install [--port N] [--data PATH] [--no-ui] [--ui-port N]`
272
522
  *
273
523
  * Idempotent. When the process is already registered, prints a reuse line
274
524
  * and exits 0 without touching anything. Otherwise: writes `~/.pgserve/
275
525
  * config.json` (creating the dir if needed), then registers the process
276
526
  * under pm2 with the hardened defaults.
277
527
  *
528
+ * Since v2.2.3: also auto-supervises the autopg console UI under pm2 as
529
+ * `autopg-ui` (default port 8433). Opt out via `--no-ui`. The UI is a thin
530
+ * http server bound to 127.0.0.1 — single-user dev tool, no auth, no TLS.
531
+ *
278
532
  * `scriptPath` is the path to `bin/postgres-server.js` resolved by the
279
533
  * wrapper before this module is required (avoids re-resolving here).
280
534
  */
@@ -286,14 +540,48 @@ function cmdInstall(args, ctx) {
286
540
  const port = parsePort(args) ?? readConfig()?.port ?? DEFAULT_PORT;
287
541
  const dataDir = parseDataDir(args) ?? readConfig()?.dataDir ?? getDataDir();
288
542
 
289
- // Idempotent: already-registered = no-op success.
290
- const existing = pm2GetProcess(PM2_PROCESS_NAME);
543
+ const noUi = args.includes('--no-ui');
544
+ const withUi = args.includes('--with-ui');
545
+ const redeploy = args.includes('--redeploy');
546
+ const uiPort = parseUiPort(args) ?? DEFAULT_UI_PORT;
547
+ const uiHost = parseUiHost(args) ?? DEFAULT_UI_HOST;
548
+
549
+ if (noUi && withUi) {
550
+ fail('--no-ui and --with-ui are mutually exclusive');
551
+ }
552
+
553
+ // --with-ui: UI-only path. Don't touch the daemon — register or refresh
554
+ // autopg-ui only. Useful for v2.2.2 → v2.2.3 upgrades where the daemon
555
+ // is fine and only the UI is missing, AND for changing UI host/port
556
+ // post-install without restarting postgres.
557
+ if (withUi) {
558
+ cmdInstallUi(ctx, { uiPort, uiHost, refresh: true });
559
+ return 0;
560
+ }
561
+
562
+ // --redeploy: full reset. Tear down both processes, then proceed with
563
+ // a fresh install. Equivalent to `autopg uninstall && autopg install`
564
+ // but in one verb.
565
+ if (redeploy) {
566
+ const had = pm2GetProcess(PM2_PROCESS_NAME);
567
+ if (had) {
568
+ spawnSync('pm2', ['delete', PM2_PROCESS_NAME], { stdio: ['ignore', 'pipe', 'pipe'] });
569
+ }
570
+ spawnSync('pm2', ['delete', UI_PM2_PROCESS_NAME], { stdio: ['ignore', 'pipe', 'pipe'] });
571
+ note('--redeploy: removed any existing pm2 processes; reinstalling fresh');
572
+ }
573
+
574
+ // Idempotent: already-registered = no-op success. Still reconcile the UI
575
+ // process so re-running `autopg install` after an upgrade picks up the UI
576
+ // even on hosts where the daemon was registered pre-v2.2.3.
577
+ const existing = redeploy ? null : pm2GetProcess(PM2_PROCESS_NAME);
291
578
  if (existing) {
292
579
  ok(`already installed (pm2 process "${PM2_PROCESS_NAME}", status=${existing.pm2_env?.status ?? 'unknown'})`);
293
580
  // Refresh config in case install was re-run with new flags — but
294
581
  // don't tear down the live process. Operators wanting a port change
295
- // should `uninstall` then `install`.
582
+ // should `uninstall` then `install` (or pass --redeploy).
296
583
  writeConfig({ port, dataDir, registeredAt: readConfig()?.registeredAt ?? new Date().toISOString() });
584
+ if (!noUi) cmdInstallUi(ctx, { uiPort, uiHost });
297
585
  return 0;
298
586
  }
299
587
 
@@ -309,6 +597,25 @@ function cmdInstall(args, ctx) {
309
597
  writeConfig({ port, dataDir, registeredAt: new Date().toISOString() });
310
598
  ok(`installed: pm2 process "${PM2_PROCESS_NAME}" on port ${port} (data: ${dataDir})`);
311
599
  ok(`url: postgres://localhost:${port}/postgres`);
600
+
601
+ if (noUi) {
602
+ note('--no-ui set; skipping console install. Run `autopg ui` on demand.');
603
+ } else {
604
+ // Generate admin password BEFORE starting the UI process, so the UI
605
+ // server reads admin.json on first request without a race. Print
606
+ // ONCE — operator must copy now or run `autopg auth rotate-admin-
607
+ // password` to get a new one.
608
+ const newPassword = ensureAdminPassword();
609
+ if (newPassword) {
610
+ process.stdout.write('\n');
611
+ process.stdout.write(` 🔑 ADMIN PASSWORD (printed ONCE — saved hash at ${getAdminFilePath()}):\n`);
612
+ process.stdout.write(` ${newPassword}\n`);
613
+ process.stdout.write('\n');
614
+ process.stdout.write(' Browser will prompt on first access. Rotate via:\n');
615
+ process.stdout.write(' autopg auth rotate-admin-password\n\n');
616
+ }
617
+ cmdInstallUi(ctx, { uiPort, uiHost, refresh: redeploy });
618
+ }
312
619
  return 0;
313
620
  }
314
621
 
@@ -320,9 +627,13 @@ function cmdInstall(args, ctx) {
320
627
  * downstream service still depends on the data.
321
628
  */
322
629
  function cmdUninstall() {
630
+ // Delete the daemon first — it's the load-bearing process and the order
631
+ // of "what existed at uninstall start" is determined by the daemon's
632
+ // pm2 registry, not the UI's. UI cleanup follows; soft-fails if absent.
323
633
  const existing = pm2GetProcess(PM2_PROCESS_NAME);
324
634
  if (!existing) {
325
635
  ok(`not registered under pm2 (nothing to uninstall)`);
636
+ cmdUninstallUi();
326
637
  return 0;
327
638
  }
328
639
  const result = spawnSync('pm2', ['delete', PM2_PROCESS_NAME], { stdio: 'inherit' });
@@ -330,6 +641,7 @@ function cmdUninstall() {
330
641
  fail(`pm2 delete failed (exit ${result.status})`);
331
642
  }
332
643
  ok(`uninstalled (pm2 process removed; data dir preserved at ${getDataDir()})`);
644
+ cmdUninstallUi();
333
645
  return 0;
334
646
  }
335
647
 
@@ -435,6 +747,25 @@ function parseDataDir(args) {
435
747
  return path.resolve(v);
436
748
  }
437
749
 
750
+ function parseUiPort(args) {
751
+ const i = args.indexOf('--ui-port');
752
+ if (i < 0) return null;
753
+ const v = args[i + 1];
754
+ if (!v) fail('--ui-port requires a value');
755
+ const n = Number.parseInt(v, 10);
756
+ if (!Number.isInteger(n) || n < 1 || n > 65535) fail(`invalid --ui-port "${v}"`);
757
+ return n;
758
+ }
759
+
760
+ function parseUiHost(args) {
761
+ const i = args.indexOf('--ui-host');
762
+ if (i < 0) return null;
763
+ const v = args[i + 1];
764
+ if (!v) fail('--ui-host requires a value');
765
+ // Pass through verbatim. cli-ui.cjs warns on non-loopback at bind time.
766
+ return v;
767
+ }
768
+
438
769
  /**
439
770
  * One-shot migration check from `~/.pgserve/` → `~/.autopg/`. Runs once
440
771
  * per process at the top of dispatch() so every CLI entry point gets
@@ -510,6 +841,8 @@ function dispatch(subcommand, args, ctx) {
510
841
  const ui = require('./cli-ui.cjs');
511
842
  return ui.dispatch(args, { scriptPath: ctx.wrapperPath });
512
843
  }
844
+ case 'auth':
845
+ return cmdAuthDispatch(args);
513
846
  default:
514
847
  throw new Error(`pgserve: dispatch called with unknown subcommand "${subcommand}"`);
515
848
  }
@@ -518,6 +851,10 @@ function dispatch(subcommand, args, ctx) {
518
851
  module.exports = {
519
852
  // Public API for the wrapper.
520
853
  dispatch,
854
+ // Auth surface used by cli-ui.cjs.
855
+ verifyAdminPassword,
856
+ getAdminFilePath,
857
+ readAdminFile,
521
858
  // Test surface.
522
859
  _internals: {
523
860
  HARDENED_DEFAULTS,
@@ -533,5 +870,9 @@ module.exports = {
533
870
  getEffectiveSupervision,
534
871
  parsePort,
535
872
  parseDataDir,
873
+ generateAdminPassword,
874
+ hashAdminPassword,
875
+ writeAdminFile,
876
+ ensureAdminPassword,
536
877
  },
537
878
  };
package/src/cli-ui.cjs CHANGED
@@ -80,11 +80,15 @@ function parseArgs(args) {
80
80
  } else if (a === '--no-open') {
81
81
  out.noOpen = true;
82
82
  } else if (a === '--host') {
83
- // Defense: still bind 127.0.0.1 unless explicitly opted out via env.
84
- // We accept --host for parity but ignore non-loopback values.
85
83
  const v = args[++i];
86
- if (v === '127.0.0.1' || v === 'localhost') {
87
- out.host = v;
84
+ out.host = v;
85
+ // Loud warning on non-loopback. The console has no auth, no TLS —
86
+ // exposing it on the LAN means any host on the network reads your
87
+ // settings file. Accepted (operator opted in) but flagged.
88
+ if (v !== '127.0.0.1' && v !== 'localhost') {
89
+ process.stderr.write(
90
+ `autopg ui: WARNING binding to ${v} (not loopback) — console has no auth, anyone reaching this address can read settings\n`,
91
+ );
88
92
  }
89
93
  }
90
94
  }
@@ -142,8 +146,25 @@ function listenWithFallback(server, host, preferredPort) {
142
146
  * via npm the `files` allowlist preserves the layout.
143
147
  */
144
148
  function resolveConsoleRoot() {
145
- // src/ repo root console/
146
- return path.resolve(__dirname, '..', 'console');
149
+ // After autopg-console-dist (v2.2.2): the SPA ships pre-bundled in
150
+ // console/dist/ instead of as flat .jsx files at console/. Prefer dist/;
151
+ // fall back to console/src/ for repo-checkout dev mode (where dist/ is
152
+ // gitignored and only built on demand).
153
+ const consoleParent = path.resolve(__dirname, '..', 'console');
154
+ const distRoot = path.join(consoleParent, 'dist');
155
+ if (fs.existsSync(distRoot)) return distRoot;
156
+
157
+ const srcRoot = path.join(consoleParent, 'src');
158
+ if (fs.existsSync(srcRoot)) {
159
+ process.stderr.write(
160
+ 'autopg ui: running unbuilt sources from console/src/ — run `bun run console:build` for production behavior\n',
161
+ );
162
+ return srcRoot;
163
+ }
164
+
165
+ throw new Error(
166
+ 'console assets not found: expected console/dist/ (run `bun run console:build`) or console/src/ (repo checkout)',
167
+ );
147
168
  }
148
169
 
149
170
  /**
@@ -445,12 +466,66 @@ function serveFile(res, filePath) {
445
466
  * `bin/pgserve-wrapper.cjs` (used for shell-outs). `ctx.consoleRoot`
446
467
  * defaults to the repo's `console/` directory.
447
468
  */
469
+ // Lazy-load the auth verifier to avoid a require cycle with cli-install.
470
+ function getAuthVerifier() {
471
+ try {
472
+ return require('./cli-install.cjs').verifyAdminPassword;
473
+ } catch {
474
+ return () => false;
475
+ }
476
+ }
477
+
478
+ /**
479
+ * Basic Auth gate. Returns true if the request is authorized (or auth is
480
+ * disabled via env), false if the response has been sent (401). The handler
481
+ * MUST stop processing when this returns false.
482
+ */
483
+ function requireAuth(req, res) {
484
+ // Escape hatch: AUTOPG_DISABLE_AUTH=1 only honored when bound loopback.
485
+ // The startServer loop binds to opts.host (default 127.0.0.1); this check
486
+ // refuses the bypass when the request's interface isn't loopback to keep
487
+ // it useful for CI/tests but useless for accidentally-exposed UIs.
488
+ if (process.env.AUTOPG_DISABLE_AUTH === '1') {
489
+ const remote = req.socket?.remoteAddress || '';
490
+ if (remote === '127.0.0.1' || remote === '::1' || remote === '::ffff:127.0.0.1') {
491
+ return true;
492
+ }
493
+ }
494
+
495
+ const verify = getAuthVerifier();
496
+ const header = req.headers['authorization'] || '';
497
+ const m = /^Basic\s+([A-Za-z0-9+/=]+)\s*$/.exec(header);
498
+ if (m) {
499
+ const decoded = Buffer.from(m[1], 'base64').toString('utf8');
500
+ const colonIdx = decoded.indexOf(':');
501
+ if (colonIdx >= 0) {
502
+ // Username is ignored — single-tenant tool. Only the password matters.
503
+ const pw = decoded.slice(colonIdx + 1);
504
+ if (verify(pw)) return true;
505
+ }
506
+ }
507
+
508
+ res.writeHead(401, {
509
+ 'www-authenticate': 'Basic realm="autopg console"',
510
+ 'content-type': 'text/plain; charset=utf-8',
511
+ 'cache-control': 'no-store',
512
+ });
513
+ res.end(
514
+ 'autopg console requires authentication.\n' +
515
+ 'Username: any (e.g. "admin")\n' +
516
+ 'Password: see the value printed by `autopg install` or run `autopg auth rotate-admin-password`.\n',
517
+ );
518
+ return false;
519
+ }
520
+
448
521
  function createHandler(ctx = {}) {
449
522
  const consoleRoot = ctx.consoleRoot || resolveConsoleRoot();
450
523
  return function handler(req, res) {
451
524
  const url = req.url || '/';
452
525
  const method = req.method || 'GET';
453
526
 
527
+ if (!requireAuth(req, res)) return;
528
+
454
529
  if (url.startsWith('/api/')) {
455
530
  if (url === '/api/settings' && method === 'GET') return handleGetSettings(req, res);
456
531
  if (url === '/api/settings' && method === 'PUT') return handlePutSettings(req, res);