pgserve 2.6.1 → 2.6.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pgserve",
3
- "version": "2.6.1",
3
+ "version": "2.6.5",
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",
@@ -29,7 +29,7 @@ DIST_DIR="${AUTOPG_DIST_DIR:-${REPO_ROOT}/dist}"
29
29
 
30
30
  usage() {
31
31
  cat <<EOF
32
- Usage: $0 --version <v> [--base-url <url>] [--channel <c>] [--cosign-pub-url <url>]
32
+ Usage: $0 --version <v> [--base-url <url>] [--channel <c>] [--trust-regex <re>]
33
33
 
34
34
  --version autopg version, e.g. 2.260503.1 (or read from package.json)
35
35
  --base-url absolute base URL prefix for tarball URLs
@@ -37,14 +37,18 @@ Usage: $0 --version <v> [--base-url <url>] [--channel <c>] [--cosign-pub-url <ur
37
37
  directory the manifest sits in).
38
38
  --channel channel hint embedded in the manifest (stable|beta|canary).
39
39
  default: stable
40
- --cosign-pub-url absolute URL to the published cosign public key.
41
- default: <base-url>/../../keys/cosign.pub for production
42
- (cdn.automagik.dev layout) or "keys/cosign.pub" relative.
40
+ --trust-regex cosign keyless identity regex consumers verify against.
41
+ default: pgserve's own sign-attest.yml@refs/tags/v.*
42
+ anchor (mirrors src/cosign/trust-list.js
43
+ automagik-pgserve-release entry).
44
+ --oidc-issuer Sigstore OIDC issuer URL.
45
+ default: https://token.actions.githubusercontent.com
43
46
 
44
47
  Reads:
45
48
  dist/autopg-<version>-<platform>.tar.gz
46
49
  dist/autopg-<version>-<platform>.tar.gz.sha256
47
50
  dist/autopg-<version>-<platform>.tar.gz.sig (optional)
51
+ dist/autopg-<version>-<platform>.tar.gz.cert (optional, Wave A keyless)
48
52
  dist/autopg-<version>-<platform>.tar.gz.intoto.jsonl (optional)
49
53
 
50
54
  Writes:
@@ -56,13 +60,17 @@ parse_args() {
56
60
  VERSION="${AUTOPG_VERSION:-}"
57
61
  BASE_URL=""
58
62
  CHANNEL="stable"
59
- COSIGN_PUB_URL=""
63
+ TRUST_REGEX_DEFAULT='^https://github.com/namastexlabs/pgserve/.github/workflows/sign-attest.yml@refs/tags/v.*$'
64
+ TRUST_REGEX=""
65
+ OIDC_ISSUER_DEFAULT="https://token.actions.githubusercontent.com"
66
+ OIDC_ISSUER=""
60
67
  while [[ $# -gt 0 ]]; do
61
68
  case "$1" in
62
- --version) VERSION="$2"; shift 2 ;;
63
- --base-url) BASE_URL="$2"; shift 2 ;;
64
- --channel) CHANNEL="$2"; shift 2 ;;
65
- --cosign-pub-url) COSIGN_PUB_URL="$2"; shift 2 ;;
69
+ --version) VERSION="$2"; shift 2 ;;
70
+ --base-url) BASE_URL="$2"; shift 2 ;;
71
+ --channel) CHANNEL="$2"; shift 2 ;;
72
+ --trust-regex) TRUST_REGEX="$2"; shift 2 ;;
73
+ --oidc-issuer) OIDC_ISSUER="$2"; shift 2 ;;
66
74
  -h|--help) usage; exit 0 ;;
67
75
  *) echo "unknown arg: $1" >&2; usage; exit 2 ;;
68
76
  esac
@@ -73,6 +81,8 @@ parse_args() {
73
81
  if [[ -z "$VERSION" ]]; then
74
82
  echo "error: --version required (or set in package.json)" >&2; exit 2
75
83
  fi
84
+ [[ -n "$TRUST_REGEX" ]] || TRUST_REGEX="$TRUST_REGEX_DEFAULT"
85
+ [[ -n "$OIDC_ISSUER" ]] || OIDC_ISSUER="$OIDC_ISSUER_DEFAULT"
76
86
  }
77
87
 
78
88
  # Portable SHA256 — sha256sum on linux, shasum -a 256 on macOS.
@@ -109,10 +119,13 @@ emit_entry() {
109
119
  sha=$(awk '{print $1}' "${tarball}.sha256")
110
120
  sz=$(stat -c %s "$tarball" 2>/dev/null || stat -f %z "$tarball")
111
121
 
112
- local sig_url="" prov_url=""
122
+ local sig_url="" cert_url="" prov_url=""
113
123
  if [[ -f "${tarball}.sig" ]]; then
114
124
  sig_url=$(prefix_url "${base}.sig")
115
125
  fi
126
+ if [[ -f "${tarball}.cert" ]]; then
127
+ cert_url=$(prefix_url "${base}.cert")
128
+ fi
116
129
  if [[ -f "${tarball}.intoto.jsonl" ]]; then
117
130
  prov_url=$(prefix_url "${base}.intoto.jsonl")
118
131
  fi
@@ -124,8 +137,9 @@ emit_entry() {
124
137
  printf ' "url": "%s",\n' "$(prefix_url "$base")"
125
138
  printf ' "sha256": "%s",\n' "$sha"
126
139
  printf ' "size": %d,\n' "$sz"
127
- printf ' "signature_url": "%s",\n' "$sig_url"
128
- printf ' "provenance_url": "%s"\n' "$prov_url"
140
+ printf ' "signature_url": "%s",\n' "$sig_url"
141
+ printf ' "certificate_url": "%s",\n' "$cert_url"
142
+ printf ' "provenance_url": "%s"\n' "$prov_url"
129
143
  printf ' }'
130
144
  }
131
145
 
@@ -154,20 +168,22 @@ main() {
154
168
  printf ' "channel": "%s",\n' "$CHANNEL"
155
169
  printf ' "schemaVersion": 1,\n'
156
170
  printf ' "generated_at": "%s",\n' "$generated_at"
157
- local cpub
158
- if [[ -n "$COSIGN_PUB_URL" ]]; then
159
- cpub="$COSIGN_PUB_URL"
160
- elif [[ -n "$BASE_URL" ]]; then
161
- # CDN layout: <base>/autopg/<channel>/<version>/manifest.json
162
- # Public key lives at: <base>/autopg/keys/cosign.pub
163
- # If caller passes the full <base>/autopg/<channel>/<version> as
164
- # base-url, walk two levels up to reach the keys/ sibling.
165
- cpub="${BASE_URL%/*}"
166
- cpub="${cpub%/*}/keys/cosign.pub"
167
- else
168
- cpub="keys/cosign.pub"
169
- fi
170
- printf ' "cosign_pub_url": "%s",\n' "$cpub"
171
+ # Wave A: keyless OIDC verification metadata. Consumers feed these
172
+ # into `cosign verify-blob --certificate-identity-regexp <regex>
173
+ # --certificate-oidc-issuer <url> --signature <sig> --certificate
174
+ # <cert> <tarball>`. Replaces the legacy `cosign_pub_url` field
175
+ # (which pointed at a pinned long-lived public key).
176
+ printf ' "cosign_verification": {\n'
177
+ printf ' "method": "keyless",\n'
178
+ # Escape backslashes + double quotes for JSON string safety. The
179
+ # trust regex commonly contains both; the issuer URL is unlikely to
180
+ # but a custom --oidc-issuer could carry them, so apply uniformly.
181
+ local trust_escaped issuer_escaped
182
+ trust_escaped=$(printf '%s' "$TRUST_REGEX" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g')
183
+ issuer_escaped=$(printf '%s' "$OIDC_ISSUER" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g')
184
+ printf ' "trust_identity_regexp": "%s",\n' "$trust_escaped"
185
+ printf ' "oidc_issuer": "%s"\n' "$issuer_escaped"
186
+ printf ' },\n'
171
187
  printf ' "platforms": [\n'
172
188
  local first=1
173
189
  for t in "${tarballs[@]}"; do
@@ -153,9 +153,16 @@ stage_from_url() {
153
153
  url="${url//\{pf\}/$pf}"
154
154
  echo " -> source: $url"
155
155
 
156
- local scratch
157
- scratch=$(mktemp -d)
156
+ # Initialize before installing trap — same fix stage_from_pkg has at
157
+ # line 119. Under `set -u` the RETURN trap fires on any early-return
158
+ # path (including ones where `mktemp` hasn't run yet); referencing an
159
+ # unset `$scratch` from the trap would print
160
+ # `scratch: unbound variable` and leak across function frames,
161
+ # masking the real fetch error (codex P2 review on PR #84 fixed this
162
+ # for stage_from_pkg; stage_from_url was missed at the time).
163
+ local scratch=""
158
164
  trap 'rm -rf "$scratch"' RETURN
165
+ scratch=$(mktemp -d) || return 1
159
166
 
160
167
  curl -fsSL "$url" -o "${scratch}/pg.tar.gz"
161
168
  tar -xzf "${scratch}/pg.tar.gz" -C "$scratch"
@@ -1,10 +1,13 @@
1
1
  #!/usr/bin/env bash
2
2
  #
3
- # verify-published-artifacts.sh — Group 8 of autopg-distribution-cutover.
3
+ # verify-published-artifacts.sh — operator post-release verification.
4
+ # Wave A keyless rewrite (PR-B follow-up D13 companion).
4
5
  #
5
6
  # Validates that every autopg tarball in the given directory is:
6
7
  # 1. accompanied by its outer .sha256 and that the hash matches.
7
- # 2. cosign-signed (verifies against keys/cosign.pub or AUTOPG_COSIGN_PUB).
8
+ # 2. cosign keyless-OIDC-signed cert subject matches the trust regex
9
+ # (override via AUTOPG_TRUST_REGEX) under the Sigstore GH Actions
10
+ # OIDC issuer.
8
11
  # 3. accompanied by a SLSA L3 in-toto provenance attestation that
9
12
  # slsa-verifier can verify against the source repo URI.
10
13
  # 4. listed in the aggregated manifest.json with matching metadata.
@@ -12,26 +15,39 @@
12
15
  # Usage:
13
16
  # bash scripts/verify-published-artifacts.sh dist/
14
17
  # bash scripts/verify-published-artifacts.sh dist/ --skip-slsa
15
- # AUTOPG_COSIGN_PUB=tests/fixtures/cosign/cosign.pub \
18
+ # AUTOPG_TRUST_REGEX='^https://github.com/my-org/.+/.github/workflows/release\.yml@refs/tags/v.*$' \
16
19
  # bash scripts/verify-published-artifacts.sh dist/
17
20
  #
18
21
  # Exit codes:
19
22
  # 0 all artifacts verified
20
23
  # 1 verification failure (any tarball fails any check)
21
24
  # 2 invalid args / missing inputs
22
- #
23
- # Group 8 acceptance criteria:
24
- # - cosign verify-blob succeeds for every platform tarball
25
- # - slsa-verifier verify-artifact succeeds for every platform tarball
26
- # - tampered tarball fails both verifications
27
- # - script exits non-zero if any tarball ships without sig + provenance
28
25
 
29
26
  set -euo pipefail
30
27
 
31
- REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
32
- COSIGN_PUB_DEFAULT="${REPO_ROOT}/keys/cosign.pub"
33
- COSIGN_PUB="${AUTOPG_COSIGN_PUB:-${COSIGN_PUB_DEFAULT}}"
34
- SOURCE_URI_DEFAULT="github.com/automagik-dev/autopg"
28
+ # Wave A: keyless OIDC verification is the default. Override mode is
29
+ # selected by setting AUTOPG_COSIGN_PUB to a public-key path — that
30
+ # triggers KEYED verification with `--key <path>`, used exclusively by
31
+ # tests/integration/sign-attest-smoke.sh which signs offline with a
32
+ # checked-in fixture keypair (cosign keyless requires Sigstore network
33
+ # + OIDC, neither of which are available during the offline smoke).
34
+ #
35
+ # KEYLESS mode (default — production):
36
+ # - requires <tarball>.sig + <tarball>.cert
37
+ # - trust regex from AUTOPG_TRUST_REGEX (default below)
38
+ # - OIDC issuer from AUTOPG_OIDC_ISSUER (default below)
39
+ #
40
+ # KEYED mode (opt-in via AUTOPG_COSIGN_PUB):
41
+ # - requires <tarball>.sig only
42
+ # - public key path: $AUTOPG_COSIGN_PUB
43
+ # - trust regex / oidc issuer ignored
44
+ COSIGN_PUB="${AUTOPG_COSIGN_PUB:-}"
45
+ TRUST_REGEX_DEFAULT='^https://github.com/namastexlabs/pgserve/.github/workflows/sign-attest.yml@refs/tags/v.*$'
46
+ TRUST_REGEX="${AUTOPG_TRUST_REGEX:-${TRUST_REGEX_DEFAULT}}"
47
+ OIDC_ISSUER_DEFAULT="https://token.actions.githubusercontent.com"
48
+ OIDC_ISSUER="${AUTOPG_OIDC_ISSUER:-${OIDC_ISSUER_DEFAULT}}"
49
+
50
+ SOURCE_URI_DEFAULT="github.com/namastexlabs/pgserve"
35
51
  SOURCE_URI="${AUTOPG_SOURCE_URI:-${SOURCE_URI_DEFAULT}}"
36
52
 
37
53
  PASS=0
@@ -47,18 +63,26 @@ usage() {
47
63
  cat <<EOF
48
64
  Usage: $0 <dist-dir> [--skip-slsa] [--skip-manifest]
49
65
 
50
- Verifies every autopg-*.tar.gz in <dist-dir> using:
51
- - keys/cosign.pub (override with AUTOPG_COSIGN_PUB=<path>)
66
+ KEYLESS mode (default production):
67
+ - trust regex ${TRUST_REGEX_DEFAULT}
68
+ (override with AUTOPG_TRUST_REGEX=<regex>)
69
+ - oidc issuer ${OIDC_ISSUER_DEFAULT}
70
+ (override with AUTOPG_OIDC_ISSUER=<url>)
71
+ - required siblings <tarball>.sha256 + .sig + .cert
72
+ + .intoto.jsonl (skip with --skip-slsa)
73
+
74
+ KEYED mode (opt-in for offline fixture smoke — set AUTOPG_COSIGN_PUB):
75
+ - public key path \$AUTOPG_COSIGN_PUB
76
+ - required siblings <tarball>.sha256 + .sig
77
+ + .intoto.jsonl (skip with --skip-slsa)
78
+ - trust regex / oidc issuer ignored
79
+
80
+ Always:
52
81
  - source URI ${SOURCE_URI_DEFAULT}
53
82
  (override with AUTOPG_SOURCE_URI=<uri>)
54
83
 
55
- Required siblings per tarball:
56
- <tarball>.sha256
57
- <tarball>.sig
58
- <tarball>.intoto.jsonl (skip with --skip-slsa)
59
-
60
84
  Optional:
61
- manifest.json (skip with --skip-manifest)
85
+ manifest.json (skip with --skip-manifest)
62
86
  EOF
63
87
  }
64
88
 
@@ -76,8 +100,9 @@ parse_args() {
76
100
  if [[ ! -d "$DIST_DIR" ]]; then
77
101
  echo "error: dist dir not found: $DIST_DIR" >&2; exit 2
78
102
  fi
79
- if [[ ! -f "$COSIGN_PUB" ]]; then
80
- echo "error: cosign public key not found: $COSIGN_PUB" >&2; exit 2
103
+ if [[ -n "$COSIGN_PUB" && ! -f "$COSIGN_PUB" ]]; then
104
+ echo "error: AUTOPG_COSIGN_PUB set but file not found: $COSIGN_PUB" >&2
105
+ exit 2
81
106
  fi
82
107
  }
83
108
 
@@ -127,14 +152,35 @@ verify_cosign() {
127
152
  bad "$(basename "$tarball"): missing .sig sibling"
128
153
  return 1
129
154
  fi
130
- if cosign verify-blob \
131
- --key "$COSIGN_PUB" \
132
- --signature "$sig" \
133
- "$tarball" >/dev/null 2>&1; then
134
- ok "$(basename "$tarball"): cosign signature verifies"
155
+ if [[ -n "$COSIGN_PUB" ]]; then
156
+ # KEYED mode (offline fixture smoke).
157
+ if cosign verify-blob \
158
+ --key "$COSIGN_PUB" \
159
+ --signature "$sig" \
160
+ "$tarball" >/dev/null 2>&1; then
161
+ ok "$(basename "$tarball"): cosign keyed signature verifies"
162
+ else
163
+ bad "$(basename "$tarball"): cosign verify-blob FAILED (key=${COSIGN_PUB})"
164
+ return 1
165
+ fi
135
166
  else
136
- bad "$(basename "$tarball"): cosign verify-blob FAILED"
137
- return 1
167
+ # KEYLESS mode (default production).
168
+ local cert="${tarball}.cert"
169
+ if [[ ! -f "$cert" ]]; then
170
+ bad "$(basename "$tarball"): missing .cert sibling (keyless OIDC requires it)"
171
+ return 1
172
+ fi
173
+ if cosign verify-blob \
174
+ --certificate-identity-regexp "$TRUST_REGEX" \
175
+ --certificate-oidc-issuer "$OIDC_ISSUER" \
176
+ --signature "$sig" \
177
+ --certificate "$cert" \
178
+ "$tarball" >/dev/null 2>&1; then
179
+ ok "$(basename "$tarball"): cosign keyless signature verifies"
180
+ else
181
+ bad "$(basename "$tarball"): cosign verify-blob FAILED (regex=${TRUST_REGEX})"
182
+ return 1
183
+ fi
138
184
  fi
139
185
  }
140
186
 
@@ -172,8 +218,15 @@ main() {
172
218
  require_tools
173
219
 
174
220
  echo "==> verify-published-artifacts: $DIST_DIR"
175
- echo " cosign pub: $COSIGN_PUB"
176
- echo " source uri: $SOURCE_URI"
221
+ if [[ -n "$COSIGN_PUB" ]]; then
222
+ echo " mode: KEYED (AUTOPG_COSIGN_PUB set)"
223
+ echo " cosign pub: $COSIGN_PUB"
224
+ else
225
+ echo " mode: KEYLESS"
226
+ echo " trust regex: $TRUST_REGEX"
227
+ echo " oidc issuer: $OIDC_ISSUER"
228
+ fi
229
+ echo " source uri: $SOURCE_URI"
177
230
 
178
231
  local tarballs=()
179
232
  while IFS= read -r line; do
@@ -0,0 +1,296 @@
1
+ /**
2
+ * Per-consumer admin + manifest bootstrap.
3
+ *
4
+ * pgserve singleton (v2.6) — `autopg-distribution-cutover-finalize`
5
+ * wish, Group 3 (`pgserve create-app` + manifest LOCK 1) deliverable D2.
6
+ *
7
+ * Writes two files to disk for a registered consumer:
8
+ *
9
+ * ~/.autopg/<sanitized-slug>/admin.json (mode 0600)
10
+ * ~/.autopg/<sanitized-slug>/manifest.json (mode 0600)
11
+ *
12
+ * The containing directory is `0700`. Both files are derived caches of
13
+ * the authoritative `autopg_meta` row; cache-recovery (when divergence
14
+ * is detected by future doctor surfaces) is the V1 manual story:
15
+ * operator deletes the per-consumer dir + re-runs `pgserve create-app
16
+ * <slug>` (idempotent; preserves `locked_roots` from the table).
17
+ *
18
+ * Schema:
19
+ *
20
+ * admin.json { slug, manifestPath, lockedRoots: [...],
21
+ * createdAt, lastUpdated }
22
+ *
23
+ * manifest.json { schemaVersion: 1, slug, lockedRoots: [...],
24
+ * createdAt, lastUpdated }
25
+ *
26
+ * The two files share the `slug` + `lockedRoots` fields by design —
27
+ * admin.json is the orchestrator-facing record, manifest.json is the
28
+ * verifier-facing copy with an explicit `schemaVersion` for future
29
+ * migrations.
30
+ *
31
+ * Orthogonality: this per-consumer admin.json sits ONE directory level
32
+ * deeper than the host-level `~/.autopg/admin.json` (owned by
33
+ * `canonical-pgserve-pm2-supervision` G1, src/lib/admin-json.js). They
34
+ * never collide; the host record is `~/.autopg/admin.json`, the
35
+ * per-consumer record is `~/.autopg/<slug>/admin.json`.
36
+ *
37
+ * Slug sanitization: REUSES `sanitizeSlug` from
38
+ * src/provision/db-naming.js — the same canonical helper used to build
39
+ * provision database/role names. This guarantees the per-consumer dir
40
+ * for `@demo/app` resolves to `~/.autopg/demo_app/`, matching whatever
41
+ * provision/gc derived from the same input.
42
+ *
43
+ * TOCTOU defense: after mkdir, the resolved path is verified to still
44
+ * land inside the canonical autopg root via `fs.realpathSync` — refuses
45
+ * to write through a symlink that points outside `~/.autopg/`. The
46
+ * file writes themselves use the standard tmp + rename + chmod pattern
47
+ * already in src/lib/admin-json.js.
48
+ */
49
+
50
+ import fs from 'fs';
51
+ import path from 'path';
52
+
53
+ import { sanitizeSlug } from '../provision/db-naming.js';
54
+ import { getDefaultConfigDir } from '../lib/admin-json.js';
55
+
56
+ export const PER_CONSUMER_ADMIN_FILE = 'admin.json';
57
+ export const PER_CONSUMER_MANIFEST_FILE = 'manifest.json';
58
+ export const PER_CONSUMER_FILE_MODE = 0o600;
59
+ export const PER_CONSUMER_DIR_MODE = 0o700;
60
+ export const MANIFEST_SCHEMA_VERSION = 1;
61
+
62
+ /**
63
+ * Resolve the per-consumer config directory for a given slug.
64
+ *
65
+ * `<configDir>/<sanitized-slug>` — defaults `<configDir>` to whatever
66
+ * `getDefaultConfigDir()` returns (`AUTOPG_CONFIG_DIR` >
67
+ * `PGSERVE_CONFIG_DIR` > `$HOME/.autopg`).
68
+ *
69
+ * Throws on empty/whitespace slug input — never silently coerce a bad
70
+ * slug to a flat path that would clobber the host-level admin.json or
71
+ * an unrelated consumer.
72
+ */
73
+ export function getConsumerDir(slug, { configDir = getDefaultConfigDir() } = {}) {
74
+ if (typeof slug !== 'string' || slug.trim().length === 0) {
75
+ throw new TypeError('admin-bootstrap: slug must be a non-empty string');
76
+ }
77
+ const sanitized = sanitizeSlug(slug);
78
+ if (sanitized.length === 0) {
79
+ throw new TypeError(
80
+ `admin-bootstrap: slug "${slug}" sanitizes to empty; pick a slug `
81
+ + 'with at least one alphanumeric character',
82
+ );
83
+ }
84
+ return {
85
+ sanitized,
86
+ consumerDir: path.join(configDir, sanitized),
87
+ configDir,
88
+ };
89
+ }
90
+
91
+ export function getConsumerAdminPath(slug, opts) {
92
+ const { consumerDir } = getConsumerDir(slug, opts);
93
+ return path.join(consumerDir, PER_CONSUMER_ADMIN_FILE);
94
+ }
95
+
96
+ export function getConsumerManifestPath(slug, opts) {
97
+ const { consumerDir } = getConsumerDir(slug, opts);
98
+ return path.join(consumerDir, PER_CONSUMER_MANIFEST_FILE);
99
+ }
100
+
101
+ function ensureConfigRoot(configDir) {
102
+ if (!fs.existsSync(configDir)) {
103
+ fs.mkdirSync(configDir, { recursive: true, mode: PER_CONSUMER_DIR_MODE });
104
+ }
105
+ }
106
+
107
+ function ensureConsumerDir(configDir, consumerDir) {
108
+ ensureConfigRoot(configDir);
109
+ if (!fs.existsSync(consumerDir)) {
110
+ fs.mkdirSync(consumerDir, { recursive: true, mode: PER_CONSUMER_DIR_MODE });
111
+ }
112
+ // TOCTOU defense: refuse to write through a symlink that escapes the
113
+ // canonical config root. Resolve both the root and the consumer dir
114
+ // and assert containment.
115
+ let realRoot;
116
+ let realConsumer;
117
+ try {
118
+ realRoot = fs.realpathSync(configDir);
119
+ realConsumer = fs.realpathSync(consumerDir);
120
+ } catch (err) {
121
+ throw new Error(
122
+ `admin-bootstrap: failed to resolve real path for "${consumerDir}": ${err.message}`,
123
+ );
124
+ }
125
+ const rootWithSep = realRoot.endsWith(path.sep) ? realRoot : realRoot + path.sep;
126
+ if (realConsumer !== realRoot && !realConsumer.startsWith(rootWithSep)) {
127
+ const err = new Error(
128
+ `admin-bootstrap: refusing to write — "${consumerDir}" resolves to `
129
+ + `"${realConsumer}", which is outside config root "${realRoot}". `
130
+ + 'Possible symlink attack; remove the symlink and retry.',
131
+ );
132
+ err.code = 'EAUTOPGCONSUMERESCAPE';
133
+ err.consumerDir = consumerDir;
134
+ err.realConsumer = realConsumer;
135
+ err.configRoot = realRoot;
136
+ throw err;
137
+ }
138
+ fs.chmodSync(consumerDir, PER_CONSUMER_DIR_MODE);
139
+ return realConsumer;
140
+ }
141
+
142
+ function atomicWrite(filePath, body) {
143
+ const tmp = `${filePath}.tmp.${process.pid}`;
144
+ fs.writeFileSync(tmp, body, { mode: PER_CONSUMER_FILE_MODE });
145
+ fs.renameSync(tmp, filePath);
146
+ fs.chmodSync(filePath, PER_CONSUMER_FILE_MODE);
147
+ }
148
+
149
+ function jsonBody(payload) {
150
+ return `${JSON.stringify(payload, null, 2)}\n`;
151
+ }
152
+
153
+ function deepCloneRoots(lockedRoots) {
154
+ if (!Array.isArray(lockedRoots)) {
155
+ throw new TypeError('admin-bootstrap: lockedRoots must be an array');
156
+ }
157
+ // Use JSON round-trip to drop any Object.freeze wrappers AND defensively
158
+ // copy nested fields. TRUSTED_IDENTITIES entries are plain objects with
159
+ // string values — no Date / Buffer / function / undefined that would
160
+ // round-trip badly.
161
+ return JSON.parse(JSON.stringify(lockedRoots));
162
+ }
163
+
164
+ /**
165
+ * Compose the on-disk records for a consumer. Pure function — no fs.
166
+ *
167
+ * Used by `bootstrapConsumerAdmin` (which writes them) and by the
168
+ * doctor surface (which compares against on-disk + table state).
169
+ */
170
+ export function buildConsumerRecords({ slug, lockedRoots, createdAt, lastUpdated, configDir }) {
171
+ const { sanitized, consumerDir } = getConsumerDir(slug, { configDir });
172
+ const manifestPath = path.join(consumerDir, PER_CONSUMER_MANIFEST_FILE);
173
+ const cloned = deepCloneRoots(lockedRoots);
174
+
175
+ const adminRecord = {
176
+ slug: sanitized,
177
+ manifestPath,
178
+ lockedRoots: cloned,
179
+ createdAt,
180
+ lastUpdated,
181
+ };
182
+
183
+ const manifestRecord = {
184
+ schemaVersion: MANIFEST_SCHEMA_VERSION,
185
+ slug: sanitized,
186
+ lockedRoots: cloned,
187
+ createdAt,
188
+ lastUpdated,
189
+ };
190
+
191
+ return {
192
+ consumerDir,
193
+ sanitized,
194
+ manifestPath,
195
+ adminRecord,
196
+ manifestRecord,
197
+ };
198
+ }
199
+
200
+ /**
201
+ * Write the per-consumer admin.json + manifest.json pair.
202
+ *
203
+ * Inputs:
204
+ * - slug the input slug (will be sanitized via sanitizeSlug)
205
+ * - lockedRoots array of TRUSTED_IDENTITIES-shaped entries (frozen
206
+ * at create-app time); deep-cloned before write
207
+ * - createdAt ISO 8601 string; on first create-app
208
+ * - lastUpdated ISO 8601 string; touched every create-app re-run
209
+ *
210
+ * Idempotent: callers that re-run `pgserve create-app <slug>` should
211
+ * leave `createdAt` untouched (read it back from the autopg_meta row,
212
+ * not regenerated) and only refresh `lastUpdated`. The verb in D3
213
+ * threads this through.
214
+ *
215
+ * Returns the written records + paths so the caller can echo them in
216
+ * logs or feed them to a downstream reporter.
217
+ */
218
+ export function bootstrapConsumerAdmin({
219
+ slug,
220
+ lockedRoots,
221
+ createdAt,
222
+ lastUpdated,
223
+ configDir = getDefaultConfigDir(),
224
+ } = {}) {
225
+ if (typeof createdAt !== 'string' || createdAt.length === 0) {
226
+ throw new TypeError('admin-bootstrap: createdAt must be a non-empty ISO 8601 string');
227
+ }
228
+ if (typeof lastUpdated !== 'string' || lastUpdated.length === 0) {
229
+ throw new TypeError('admin-bootstrap: lastUpdated must be a non-empty ISO 8601 string');
230
+ }
231
+
232
+ const { sanitized, consumerDir, adminRecord, manifestRecord } =
233
+ buildConsumerRecords({ slug, lockedRoots, createdAt, lastUpdated, configDir });
234
+
235
+ const realConsumerDir = ensureConsumerDir(configDir, consumerDir);
236
+ // Recompute the manifest path to live under the resolved real dir so
237
+ // the manifestPath stored in admin.json matches what we actually
238
+ // wrote, even if the consumerDir we computed above was a symlink
239
+ // alias of the canonical realConsumerDir.
240
+ const adminFilePath = path.join(realConsumerDir, PER_CONSUMER_ADMIN_FILE);
241
+ const manifestFilePath = path.join(realConsumerDir, PER_CONSUMER_MANIFEST_FILE);
242
+ adminRecord.manifestPath = manifestFilePath;
243
+
244
+ atomicWrite(manifestFilePath, jsonBody(manifestRecord));
245
+ atomicWrite(adminFilePath, jsonBody(adminRecord));
246
+
247
+ return {
248
+ sanitized,
249
+ consumerDir: realConsumerDir,
250
+ adminPath: adminFilePath,
251
+ manifestPath: manifestFilePath,
252
+ adminRecord,
253
+ manifestRecord,
254
+ };
255
+ }
256
+
257
+ /**
258
+ * Read the consumer admin.json. Returns the parsed object on success,
259
+ * `null` when the file is missing or unreadable. Mirrors the read
260
+ * semantics of `readAdminJson` in src/lib/admin-json.js — never throws
261
+ * on missing/broken file.
262
+ */
263
+ export function readConsumerAdmin(slug, { configDir = getDefaultConfigDir() } = {}) {
264
+ const file = getConsumerAdminPath(slug, { configDir });
265
+ let raw;
266
+ try {
267
+ raw = fs.readFileSync(file, 'utf8');
268
+ } catch {
269
+ return null;
270
+ }
271
+ try {
272
+ const parsed = JSON.parse(raw);
273
+ return (parsed && typeof parsed === 'object') ? parsed : null;
274
+ } catch {
275
+ return null;
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Read the consumer manifest.json. Same semantics as readConsumerAdmin.
281
+ */
282
+ export function readConsumerManifest(slug, { configDir = getDefaultConfigDir() } = {}) {
283
+ const file = getConsumerManifestPath(slug, { configDir });
284
+ let raw;
285
+ try {
286
+ raw = fs.readFileSync(file, 'utf8');
287
+ } catch {
288
+ return null;
289
+ }
290
+ try {
291
+ const parsed = JSON.parse(raw);
292
+ return (parsed && typeof parsed === 'object') ? parsed : null;
293
+ } catch {
294
+ return null;
295
+ }
296
+ }
@@ -256,12 +256,49 @@ function cmdEdit() {
256
256
  return result.status ?? EXIT_OK;
257
257
  }
258
258
 
259
+ const CONFIG_USAGE = `autopg config <subcommand> [args]
260
+
261
+ Subcommands:
262
+ list - print every leaf as key|value|source (default with no subverb)
263
+ get <key> - print the resolved value (machine-friendly)
264
+ set <key> <value> - validate + atomic write
265
+ path - print the absolute path to settings.json
266
+ init [--force] - write schema defaults; refuses to clobber unless --force
267
+ edit - open $EDITOR on settings.json
268
+
269
+ Exit codes:
270
+ 0 success
271
+ 1 unknown subcommand / IO error / EDITOR not set / settings file unreadable
272
+ 2 validation error (stable shape: \`error: <field> — <CODE>: <detail>\`)
273
+
274
+ Reachable as either \`autopg config\` or \`pgserve config\` (single dispatch
275
+ per Decision #7).`;
276
+
277
+ function printConfigUsage() {
278
+ process.stdout.write(`${CONFIG_USAGE}\n`);
279
+ }
280
+
259
281
  /**
260
282
  * Subcommand dispatch. Returns the exit code; the parent dispatcher
261
283
  * uses the return value as `process.exit(code)` directly.
262
284
  */
263
285
  function dispatch(subcommand, args = []) {
264
286
  switch (subcommand) {
287
+ // B6 (v2.6.3): honor --help / -h as a verb-level help request, NOT
288
+ // as an unknown subcommand. Pre-fix, the dispatcher hit the default
289
+ // branch which emitted `error: --help — INVALID_KEY: unknown config
290
+ // subcommand "--help"` AND printed the usage block AND returned
291
+ // EXIT_UNKNOWN. The wrapper at bin/pgserve-wrapper.cjs translated
292
+ // EXIT_UNKNOWN to a non-zero exit BUT the parent shell saw exit 0
293
+ // because of a stale process.exit path; either way the message
294
+ // shape "error:" + exit-code-status disagreed (the exact bug B6
295
+ // documented). Honoring --help removes the contradiction. Branch
296
+ // A in the QA recipe; truly unknown subverbs (T4) still hit the
297
+ // default branch + return EXIT_UNKNOWN.
298
+ case '--help':
299
+ case '-h':
300
+ printConfigUsage();
301
+ return EXIT_OK;
265
302
  case 'list':
266
303
  return cmdList();
267
304
  case 'get':