pgserve 2.6.1 → 2.6.4
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 +181 -0
- package/README.md +20 -0
- package/bin/pgserve-wrapper.cjs +27 -2
- package/console/dist/app.js +7 -7
- package/package.json +1 -1
- package/scripts/aggregate-manifest.sh +42 -26
- package/scripts/verify-published-artifacts.sh +85 -32
- package/src/admin/admin-bootstrap.js +296 -0
- package/src/cli-config.cjs +37 -0
- package/src/cli-install.cjs +24 -13
- package/src/commands/create-app.js +387 -0
- package/src/commands/doctor.js +65 -0
- package/src/commands/gc.js +16 -1
- package/src/commands/verify.js +94 -4
- package/src/cosign/locked-roots.js +141 -0
- package/src/cosign/trust-list.js +29 -6
- package/src/cosign/verify-binary.js +162 -12
- package/src/gc/audit-log.js +92 -0
- package/src/postgres.js +16 -1
- package/src/schema/autopg-meta.js +120 -0
package/package.json
CHANGED
|
@@ -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>] [--
|
|
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
|
|
41
|
-
default:
|
|
42
|
-
(
|
|
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
|
-
|
|
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";
|
|
63
|
-
--base-url) BASE_URL="$2";
|
|
64
|
-
--channel) CHANNEL="$2";
|
|
65
|
-
--
|
|
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'
|
|
128
|
-
printf ' "
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
printf '
|
|
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
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
#
|
|
3
|
-
# verify-published-artifacts.sh —
|
|
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
|
|
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
|
-
#
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
51
|
-
-
|
|
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
|
|
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:
|
|
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
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
137
|
-
|
|
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
|
-
|
|
176
|
-
|
|
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
|
+
}
|
package/src/cli-config.cjs
CHANGED
|
@@ -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':
|