pgserve 2.2.2 → 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.
package/CHANGELOG.md CHANGED
@@ -14,6 +14,62 @@ All notable changes to `pgserve` are documented here. The format follows
14
14
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres
15
15
  to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
16
16
 
17
+ ## [2.2.3] - 2026-05-03
18
+
19
+ ### Changed
20
+
21
+ - **`autopg install` now auto-supervises the console UI under pm2** as a
22
+ separate process named `autopg-ui`. The bundled SPA from v2.2.2 is now
23
+ always available at `http://127.0.0.1:8433` after a fresh install — no
24
+ more "operator runs install, doesn't know the UI exists" gap.
25
+ - **The console now requires a password** (Basic Auth). On first install
26
+ `autopg install` generates a 24-char admin password, prints it ONCE to
27
+ stdout, and stores the scrypt hash in `~/.autopg/admin.json` (mode
28
+ 0600). Browsers prompt natively for the password on first visit and
29
+ cache it for the session.
30
+ - **`autopg uninstall` removes both processes** (`autopg-ui` + `pgserve`)
31
+ cleanly.
32
+
33
+ ### Added
34
+
35
+ - **`autopg auth rotate-admin-password`** — generates a new admin
36
+ password, prints once, updates `admin.json`. Existing browser sessions
37
+ re-prompt on their next request.
38
+ - **`autopg auth show-admin-path`** — prints the path to `admin.json`.
39
+ - **`--with-ui` flag on `autopg install`** — UI-only path. Refreshes
40
+ (or registers) just the `autopg-ui` pm2 process without touching the
41
+ daemon. Useful for changing UI host/port post-install or for
42
+ retrofitting the UI onto a v2.2.2 host without restarting postgres.
43
+ - **`--redeploy` flag on `autopg install`** — full redeploy: tears down
44
+ both pm2 processes and reinstalls fresh. Equivalent to
45
+ `autopg uninstall && autopg install` in one command.
46
+ - **`--no-ui` flag on `autopg install`** — opt out of the UI process for
47
+ CI / headless / server hosts that don't need a permanent localhost web
48
+ server.
49
+ - **`--ui-port N` flag on `autopg install`** — override the default UI
50
+ port (8433).
51
+ - **`--ui-host H` flag on `autopg install`** — override the default UI
52
+ bind host (127.0.0.1). Non-loopback values trigger a loud warning at
53
+ the UI server because the console has no TLS.
54
+ - **`AUTOPG_DISABLE_AUTH=1` env var** — escape hatch for CI / smoke tests.
55
+ Only honored when the request comes from `127.0.0.1` / `::1`; cannot
56
+ accidentally expose an unauthenticated UI on a LAN.
57
+
58
+ ### Notes
59
+
60
+ - **Re-run `autopg install` on existing v2.2.2 hosts** to pick up the UI
61
+ auto-supervise + admin password. Idempotent — the daemon is left
62
+ untouched. The first re-run prints the new admin password.
63
+ - **UI process memory cap is 256MB**. Restart budget + exp-backoff are
64
+ shared with the daemon's hardened defaults.
65
+ - **Single-user dev tool boundary, with auth at the door.** Loopback
66
+ binding + Basic Auth + scrypt-hashed password covers the
67
+ "random-local-process-curl'ing-settings" case. Multi-user hosts where
68
+ intra-UID isolation matters should use `--no-ui`.
69
+ - **Hash scheme**: scrypt (RFC 7914, Node built-in since v10.5),
70
+ N=16384, r=8, p=1, 32-byte derived key, 32-byte salt. No npm dep
71
+ added.
72
+
17
73
  ## [2.2.2] - 2026-05-03
18
74
 
19
75
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pgserve",
3
- "version": "2.2.2",
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",
@@ -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
  }
@@ -462,12 +466,66 @@ function serveFile(res, filePath) {
462
466
  * `bin/pgserve-wrapper.cjs` (used for shell-outs). `ctx.consoleRoot`
463
467
  * defaults to the repo's `console/` directory.
464
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
+
465
521
  function createHandler(ctx = {}) {
466
522
  const consoleRoot = ctx.consoleRoot || resolveConsoleRoot();
467
523
  return function handler(req, res) {
468
524
  const url = req.url || '/';
469
525
  const method = req.method || 'GET';
470
526
 
527
+ if (!requireAuth(req, res)) return;
528
+
471
529
  if (url.startsWith('/api/')) {
472
530
  if (url === '/api/settings' && method === 'GET') return handleGetSettings(req, res);
473
531
  if (url === '/api/settings' && method === 'PUT') return handlePutSettings(req, res);