pgserve 2.4.0 → 2.6.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 (36) hide show
  1. package/README.md +5 -8
  2. package/bin/pgserve-wrapper.cjs +23 -0
  3. package/bin/postgres-server.js +28 -0
  4. package/package.json +2 -1
  5. package/scripts/aggregate-manifest.sh +184 -0
  6. package/scripts/assemble-tarball.sh +191 -0
  7. package/scripts/audit-redaction-lint.js +349 -0
  8. package/scripts/build-binary.sh +213 -0
  9. package/scripts/fetch-postgres-bins.sh +234 -0
  10. package/scripts/postinstall.cjs +102 -18
  11. package/scripts/verify-published-artifacts.sh +211 -0
  12. package/src/audit/audit.js +134 -0
  13. package/src/cli-install.cjs +258 -26
  14. package/src/commands/doctor.js +465 -0
  15. package/src/commands/gc.js +276 -0
  16. package/src/commands/provision.js +396 -0
  17. package/src/commands/trust.js +187 -0
  18. package/src/commands/verify.js +360 -0
  19. package/src/cosign/cache-token.js +328 -0
  20. package/src/cosign/schema.js +97 -0
  21. package/src/cosign/trust-list.js +81 -0
  22. package/src/cosign/trust-store.js +250 -0
  23. package/src/cosign/verify-binary.js +277 -0
  24. package/src/gc/audit-log.js +150 -0
  25. package/src/gc/orphan-detection.js +190 -0
  26. package/src/gc/queries.js +193 -0
  27. package/src/lib/pg-query.js +145 -0
  28. package/src/lib/runtime-json.js +181 -0
  29. package/src/provision/advisory-lock.js +91 -0
  30. package/src/provision/db-naming.js +130 -0
  31. package/src/provision/fingerprint.js +144 -0
  32. package/src/schema/pgserve-meta.js +120 -0
  33. package/src/security/blocked-versions.js +103 -0
  34. package/src/upgrade/index.js +5 -0
  35. package/src/upgrade/steps/binary-cache-flush.js +2 -2
  36. package/src/upgrade/steps/cosign-meta-migration.js +123 -0
@@ -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
+
@@ -34,10 +34,33 @@ const path = require('node:path');
34
34
  // promise once at module load and await it from async install paths.
35
35
  const _adminJsonModuleP = import('./lib/admin-json.js');
36
36
  const _socketDirModuleP = import('./lib/socket-dir.js');
37
+ const _runtimeJsonModuleP = import('./lib/runtime-json.js');
38
+ const _blockedVersionsModuleP = import('./security/blocked-versions.js');
37
39
 
38
40
  async function loadCohortModules() {
39
- const [adminJson, socketDirMod] = await Promise.all([_adminJsonModuleP, _socketDirModuleP]);
40
- return { adminJson, socketDirMod };
41
+ const [adminJson, socketDirMod, runtimeJson, blockedVersions] = await Promise.all([
42
+ _adminJsonModuleP,
43
+ _socketDirModuleP,
44
+ _runtimeJsonModuleP,
45
+ _blockedVersionsModuleP,
46
+ ]);
47
+ return { adminJson, socketDirMod, runtimeJson, blockedVersions };
48
+ }
49
+
50
+ // pgserve singleton (v2.4) — `pgserve-singleton-no-proxy` wish, Group 5.
51
+ //
52
+ // Resolves the running pgserve version from package.json so `assertNotBlocked`
53
+ // can compare against the compile-time BLOCKED_VERSIONS list before any
54
+ // install/update mutation. We intentionally use the package.json shipped
55
+ // with this binary (`require.resolve` from inside cli-install.cjs) rather
56
+ // than the version on the host filesystem — we want to refuse THIS binary
57
+ // running, not a different binary that might happen to live next door.
58
+ function getCurrentVersion() {
59
+ try {
60
+ return require('../package.json').version;
61
+ } catch (_e) {
62
+ return undefined;
63
+ }
41
64
  }
42
65
 
43
66
  // pgserve singleton (v2.4) — `pgserve-singleton-no-proxy` wish, Group 1.
@@ -165,6 +188,131 @@ function readConfig() {
165
188
  }
166
189
  }
167
190
 
191
+ // cutover G19: discovery layer used by `autopg port / url / status`.
192
+ //
193
+ // Order of precedence (most-authoritative first):
194
+ // 1. `<socketDir>/runtime.json` — written by the live postmaster at greet
195
+ // time, removed on graceful shutdown. Carries the *current* port + pid
196
+ // for an actually-running daemon.
197
+ // 2. `~/.autopg/admin.json` — supervisor record written at install time.
198
+ // Survives postmaster restarts; doesn't reflect runtime state.
199
+ // 3. `~/.autopg/config.json` — legacy pre-G19 install record. Final
200
+ // fallback so older installs that haven't been re-installed under v2.4
201
+ // still discover cleanly.
202
+ //
203
+ // All readers swallow errors — discovery must never throw on a missing or
204
+ // truncated file. Synchronous on purpose: `dispatch()` for status/url/port
205
+ // is sync and the wrapper handles only `Promise OR number` return types.
206
+ function readRuntimeJsonSync(socketDir) {
207
+ if (typeof socketDir !== 'string' || socketDir.length === 0) return null;
208
+ const file = path.join(socketDir, 'runtime.json');
209
+ let raw;
210
+ try {
211
+ raw = fs.readFileSync(file, 'utf8');
212
+ } catch {
213
+ return null;
214
+ }
215
+ try {
216
+ const parsed = JSON.parse(raw);
217
+ return (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : null;
218
+ } catch {
219
+ return null;
220
+ }
221
+ }
222
+
223
+ function readAdminJsonSync() {
224
+ const file = path.join(getConfigDir(), ADMIN_FILE_NAME);
225
+ let raw;
226
+ try {
227
+ raw = fs.readFileSync(file, 'utf8');
228
+ } catch {
229
+ return null;
230
+ }
231
+ try {
232
+ const parsed = JSON.parse(raw);
233
+ return (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : null;
234
+ } catch {
235
+ return null;
236
+ }
237
+ }
238
+
239
+ function resolveCanonicalSocketDir() {
240
+ // Mirror src/lib/socket-dir.js#resolveSocketDir — pure function, no fs
241
+ // touch. Inlined here so the sync discovery layer doesn't need a top-
242
+ // level await on the ESM module.
243
+ const xdg = process.env.XDG_RUNTIME_DIR;
244
+ const base = xdg && xdg.length > 0 ? xdg : '/tmp';
245
+ return path.join(base, 'pgserve');
246
+ }
247
+
248
+ function isLivePid(pid) {
249
+ if (!Number.isInteger(pid) || pid < 1) return false;
250
+ try {
251
+ process.kill(pid, 0);
252
+ return true;
253
+ } catch (err) {
254
+ return err && err.code === 'EPERM';
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Compose a discovery view from runtime.json (preferred), admin.json
260
+ * (fallback), and config.json (legacy fallback). Returns:
261
+ * {
262
+ * runtime: { socketDir, port, pid, autopgPid, schemaVersion } | null,
263
+ * admin: { supervisor, socketDir, port, installedAt, ... } | null,
264
+ * config: { port, dataDir, registeredAt } | null,
265
+ * // composed view — best effort merge for callers that just want
266
+ * // "where do I connect right now?":
267
+ * socketDir: <string|null>,
268
+ * port: <number|null>,
269
+ * liveAutopg: <boolean> // true when runtime.json names a live pid
270
+ * }
271
+ */
272
+ function readDiscovery() {
273
+ const config = readConfig();
274
+ const admin = readAdminJsonSync();
275
+ // Prefer the socket dir the supervisor recorded at install time — that's
276
+ // the path operators configured. Only fall back to the canonical resolver
277
+ // when the install record is missing (fresh-host case).
278
+ const socketDir = (admin && typeof admin.socketDir === 'string' && admin.socketDir.length > 0)
279
+ ? admin.socketDir
280
+ : resolveCanonicalSocketDir();
281
+ const runtime = readRuntimeJsonSync(socketDir);
282
+
283
+ // PR #80 P2 fix: previous logic treated ANY parsed runtime.json as
284
+ // authoritative — a malformed-but-JSON file (no port, no socketDir) would
285
+ // hide later admin.json / config fallbacks because composedPort stayed
286
+ // null while the precedence chain stopped early. Validate that runtime
287
+ // actually carries a usable port + socketDir before treating it as live.
288
+ // Mirrors the admin / config branches' Number.isInteger guard.
289
+ let composedSocketDir = null;
290
+ let composedPort = null;
291
+ const runtimeUsable = runtime
292
+ && Number.isInteger(runtime.port)
293
+ && typeof runtime.socketDir === 'string'
294
+ && runtime.socketDir.length > 0;
295
+ if (runtimeUsable) {
296
+ composedSocketDir = runtime.socketDir;
297
+ composedPort = runtime.port;
298
+ } else if (admin && Number.isInteger(admin.port)) {
299
+ composedSocketDir = admin.socketDir ?? socketDir;
300
+ composedPort = admin.port;
301
+ } else if (config && Number.isInteger(config.port)) {
302
+ composedPort = config.port;
303
+ composedSocketDir = socketDir;
304
+ }
305
+
306
+ return {
307
+ runtime,
308
+ admin,
309
+ config,
310
+ socketDir: composedSocketDir,
311
+ port: composedPort,
312
+ liveAutopg: !!(runtime && isLivePid(runtime.autopgPid)),
313
+ };
314
+ }
315
+
168
316
  function writeConfig(config) {
169
317
  const dir = getConfigDir();
170
318
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o755 });
@@ -545,7 +693,25 @@ function cmdAuthDispatch(args) {
545
693
  * wrapper before this module is required (avoids re-resolving here).
546
694
  */
547
695
  async function cmdInstall(args, ctx) {
548
- const { adminJson, socketDirMod } = await loadCohortModules();
696
+ const { adminJson, socketDirMod, blockedVersions } = await loadCohortModules();
697
+
698
+ // pgserve singleton (v2.4) — `pgserve-singleton-no-proxy` wish, Group 5.
699
+ // Refuse to install if THIS binary's version appears in the compile-time
700
+ // blocklist. Runs first (before any host-touching work) so the operator
701
+ // sees a clear `EBLOCKEDVERSION:` diagnostic with the locked reason +
702
+ // remediation hint, exit code 4 (distinct from generic install failures).
703
+ const currentVersion = getCurrentVersion();
704
+ if (currentVersion) {
705
+ try {
706
+ blockedVersions.assertNotBlocked(currentVersion);
707
+ } catch (err) {
708
+ if (err.code === 'EBLOCKEDVERSION') {
709
+ process.stderr.write(`${err.message}\n`);
710
+ process.exit(4);
711
+ }
712
+ throw err;
713
+ }
714
+ }
549
715
 
550
716
  // pgserve singleton (v2.4): refuse to install if a different supervisor
551
717
  // (Tier B systemd-user / launchd) already owns the host. The cohort
@@ -704,15 +870,20 @@ function writeSupervisorRecord(adminJson, { supervisor, socketDir, port }) {
704
870
  /**
705
871
  * `pgserve status [--json]`
706
872
  *
707
- * Reports both pm2 state and on-disk config. Exits 0 with status info
708
- * regardless of running/stopped operators script around the JSON output.
709
- * Non-zero only when the config is missing entirely (i.e. pgserve was
710
- * never installed).
873
+ * Reports both pm2 state and on-disk discovery (runtime.json admin.json
874
+ * config.json fallback chain). Exits 0 with status info regardless of
875
+ * running/stopped operators script around the JSON output. Non-zero
876
+ * only when nothing was ever installed (no admin.json AND no config.json).
877
+ *
878
+ * Cutover G19: surfaces `runtime` (live socket discovery) and `socketDir`
879
+ * top-level so consumers can pick UDS vs TCP without parsing pm2 jlist.
711
880
  */
712
881
  function cmdStatus(args) {
713
882
  const json = args.includes('--json');
714
- const config = readConfig();
715
- if (!config) {
883
+ const discovery = readDiscovery();
884
+ const { config, admin, runtime } = discovery;
885
+
886
+ if (!config && !admin) {
716
887
  if (json) {
717
888
  process.stdout.write(`${JSON.stringify({ installed: false })}\n`);
718
889
  } else {
@@ -720,24 +891,41 @@ function cmdStatus(args) {
720
891
  }
721
892
  return 1;
722
893
  }
894
+
723
895
  const proc = pm2GetProcess(PM2_PROCESS_NAME);
724
896
  const status = proc?.pm2_env?.status ?? 'stopped';
725
897
  const pid = proc?.pid ?? null;
726
898
  const uptimeMs = proc?.pm2_env?.pm_uptime ? Date.now() - proc.pm2_env.pm_uptime : null;
727
899
  const restarts = proc?.pm2_env?.restart_time ?? 0;
728
900
 
901
+ const port = discovery.port;
902
+ const socketDir = discovery.socketDir;
903
+ const dataDir = config?.dataDir ?? null;
904
+
729
905
  const payload = {
730
906
  installed: true,
731
907
  name: PM2_PROCESS_NAME,
732
908
  status,
733
909
  pid,
734
- port: config.port,
735
- dataDir: config.dataDir,
910
+ port,
911
+ socketDir,
912
+ dataDir,
736
913
  logsDir: getLogsDir(),
737
- url: `postgres://localhost:${config.port}/postgres`,
914
+ url: port ? `postgres://localhost:${port}/postgres` : null,
738
915
  uptimeMs,
739
916
  restarts,
740
- registeredAt: config.registeredAt,
917
+ registeredAt: config?.registeredAt ?? null,
918
+ supervisor: admin?.supervisor ?? null,
919
+ runtime: runtime
920
+ ? {
921
+ socketDir: runtime.socketDir,
922
+ port: runtime.port,
923
+ pid: runtime.pid,
924
+ autopgPid: runtime.autopgPid,
925
+ schemaVersion: runtime.schemaVersion,
926
+ live: discovery.liveAutopg,
927
+ }
928
+ : null,
741
929
  };
742
930
 
743
931
  if (json) {
@@ -746,42 +934,53 @@ function cmdStatus(args) {
746
934
  }
747
935
  process.stdout.write(`name ${payload.name}\n`);
748
936
  process.stdout.write(`status ${payload.status}${payload.pid ? ` (pid ${payload.pid})` : ''}\n`);
749
- process.stdout.write(`port ${payload.port}\n`);
750
- process.stdout.write(`url ${payload.url}\n`);
751
- process.stdout.write(`dataDir ${payload.dataDir}\n`);
937
+ if (payload.supervisor) {
938
+ process.stdout.write(`supervisor ${payload.supervisor}\n`);
939
+ }
940
+ if (payload.port != null) process.stdout.write(`port ${payload.port}\n`);
941
+ if (payload.url) process.stdout.write(`url ${payload.url}\n`);
942
+ if (payload.socketDir) process.stdout.write(`socketDir ${payload.socketDir}\n`);
943
+ if (payload.dataDir) process.stdout.write(`dataDir ${payload.dataDir}\n`);
752
944
  process.stdout.write(`logsDir ${payload.logsDir}\n`);
945
+ if (payload.runtime) {
946
+ process.stdout.write(`runtime pid=${payload.runtime.pid} autopgPid=${payload.runtime.autopgPid} live=${payload.runtime.live}\n`);
947
+ } else {
948
+ process.stdout.write(`runtime (no runtime.json — postmaster down or never started)\n`);
949
+ }
753
950
  if (payload.uptimeMs != null) {
754
951
  const sec = Math.floor(payload.uptimeMs / 1000);
755
952
  process.stdout.write(`uptime ${sec}s\n`);
756
953
  }
757
954
  process.stdout.write(`restarts ${payload.restarts}\n`);
758
- process.stdout.write(`registered ${payload.registeredAt}\n`);
955
+ if (payload.registeredAt) process.stdout.write(`registered ${payload.registeredAt}\n`);
759
956
  return 0;
760
957
  }
761
958
 
762
959
  /**
763
960
  * `pgserve url`
764
961
  *
765
- * Discovery API. Prints the canonical connection string. Downstream
962
+ * Discovery API. Prints the canonical TCP connection string. Downstream
766
963
  * installers (genie install, omni install) call this to learn where to
767
- * connect, instead of hardcoding a port.
964
+ * connect, instead of hardcoding a port. The TCP form is stable across
965
+ * Tier A / Tier B / fingerprint-disabled hosts; UDS callers should
966
+ * resolve `<socketDir>/.s.PGSQL.<port>` from `autopg status --json`.
768
967
  */
769
968
  function cmdUrl() {
770
- const config = readConfig();
771
- if (!config) {
969
+ const discovery = readDiscovery();
970
+ if (discovery.port == null) {
772
971
  fail('not installed (run: pgserve install)');
773
972
  }
774
- process.stdout.write(`postgres://localhost:${config.port}/postgres\n`);
973
+ process.stdout.write(`postgres://localhost:${discovery.port}/postgres\n`);
775
974
  return 0;
776
975
  }
777
976
 
778
- /** `pgserve port` — print the canonical port. */
977
+ /** `pgserve port` — print the canonical port from runtime.json → admin.json → config.json. */
779
978
  function cmdPort() {
780
- const config = readConfig();
781
- if (!config) {
979
+ const discovery = readDiscovery();
980
+ if (discovery.port == null) {
782
981
  fail('not installed (run: pgserve install)');
783
982
  }
784
- process.stdout.write(`${config.port}\n`);
983
+ process.stdout.write(`${discovery.port}\n`);
785
984
  return 0;
786
985
  }
787
986
 
@@ -878,6 +1077,12 @@ function dispatch(subcommand, args, ctx) {
878
1077
  // dispatcher. dispatch() returns a Promise here; the wrapper
879
1078
  // already handles both numeric and Promise returns.
880
1079
  return import('./commands/uninstall.js').then((mod) => mod.runUninstall());
1080
+ case 'doctor':
1081
+ // pgserve-singleton-no-proxy Group 3: read-only V1. Reports the
1082
+ // active supervisor + postmaster reachability + admin.json /
1083
+ // runtime.json health. --fix tiered modes deferred to a follow-up
1084
+ // (SHARED-DESIGN §3.2).
1085
+ return import('./commands/doctor.js').then((mod) => mod.runDoctor(args).then((code) => process.exit(code)));
881
1086
  case 'status':
882
1087
  return cmdStatus(args);
883
1088
  case 'url':
@@ -913,6 +1118,33 @@ function dispatch(subcommand, args, ctx) {
913
1118
  }
914
1119
  case 'auth':
915
1120
  return cmdAuthDispatch(args);
1121
+ case 'verify': {
1122
+ // pgserve singleton (v2.4) — `pgserve-singleton-no-proxy` wish, Group 4.
1123
+ // `pgserve verify` is a pure-node command (cosign shellout + HMAC
1124
+ // cache token write); routes through the same async-import pattern
1125
+ // as `uninstall` so the ESM module isn't eagerly loaded.
1126
+ return import('./commands/verify.js').then((mod) => mod.runVerify(args));
1127
+ }
1128
+ case 'trust':
1129
+ // pgserve singleton (v2.4) — wish Group 3, second read-only verb.
1130
+ // `pgserve trust add/list/remove` manages the user-extensible cosign
1131
+ // trust store at ~/.pgserve/trust/identities.json. Pure node.
1132
+ // The wrapper handles the numeric-exit-code case; matches the
1133
+ // verify dispatch style so the wrapper, not the verb, owns
1134
+ // process.exit.
1135
+ return import('./commands/trust.js').then((mod) => mod.runTrust(args));
1136
+ case 'gc':
1137
+ // pgserve singleton (v2.4) — wish Group 3, verb 3. `pgserve gc`
1138
+ // sweeps orphaned databases. Default mode is dry-run; --apply
1139
+ // performs the actual DROP. Composes the orphan classifier +
1140
+ // audit-log writer + psql shellout primitives.
1141
+ return import('./commands/gc.js').then((mod) => mod.runGc(args));
1142
+ case 'provision':
1143
+ // pgserve singleton (v2.4) — wish Group 3, verb 4. Idempotent
1144
+ // CREATE ROLE / DATABASE / GRANT + UPSERT pgserve_meta. Honest
1145
+ // idempotency-driven serialization (see provision.js header for
1146
+ // why no advisory lock). Pure node + psql shellout.
1147
+ return import('./commands/provision.js').then((mod) => mod.runProvision(args));
916
1148
  default:
917
1149
  throw new Error(`pgserve: dispatch called with unknown subcommand "${subcommand}"`);
918
1150
  }