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,181 @@
1
+ /**
2
+ * `<socketDir>/runtime.json` — runtime discovery file owned by the
3
+ * `autopg serve` postmaster wrapper (cutover wish G19).
4
+ *
5
+ * Schema:
6
+ * {
7
+ * socketDir: "<absolute path>",
8
+ * port: <integer>, // postgres TCP port
9
+ * pid: <integer>, // postmaster pid
10
+ * autopgPid: <integer>, // `autopg serve` wrapper pid
11
+ * schemaVersion: 1
12
+ * }
13
+ *
14
+ * Cohort contract — there is **no `supervisor` field**. The supervisor
15
+ * (pm2 / systemd-user / launchd / external) is recorded once at install
16
+ * time in `~/.autopg/admin.json`. Mixing the two creates a synchronization
17
+ * problem (which file is authoritative when the postmaster restarts under
18
+ * a new pid?). `writeRuntimeJson()` rejects records carrying a `supervisor`
19
+ * key so the contract can't drift via a future copy-paste.
20
+ *
21
+ * Lifecycle:
22
+ * - `writeRuntimeJson()` after the postmaster greets healthy.
23
+ * - `clearRuntimeJson()` on graceful shutdown (SIGTERM / SIGINT).
24
+ * - On crash the file is left in place. Consumers detect a stale record
25
+ * via `process.kill(record.autopgPid, 0)` (no-signal probe).
26
+ *
27
+ * Atomic semantics: write to `<file>.tmp.<pid>`, then `fs.renameSync()`.
28
+ * Mode 0644 so unprivileged peers can `cat <socketDir>/runtime.json`
29
+ * without sudo — the file carries no secrets, only public discovery info.
30
+ */
31
+
32
+ import fs from 'fs';
33
+ import path from 'path';
34
+
35
+ export const RUNTIME_FILE_NAME = 'runtime.json';
36
+ export const RUNTIME_FILE_MODE = 0o644;
37
+ export const RUNTIME_SCHEMA_VERSION = 1;
38
+
39
+ export function getRuntimeFilePath(socketDir) {
40
+ if (typeof socketDir !== 'string' || socketDir.length === 0) {
41
+ throw new TypeError('runtime-json: socketDir must be a non-empty string');
42
+ }
43
+ return path.join(socketDir, RUNTIME_FILE_NAME);
44
+ }
45
+
46
+ function isPlainObject(v) {
47
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
48
+ }
49
+
50
+ function validateRecord(record) {
51
+ if (!isPlainObject(record)) {
52
+ throw new TypeError('runtime-json: record must be an object');
53
+ }
54
+ if (typeof record.socketDir !== 'string' || record.socketDir.length === 0) {
55
+ throw new TypeError('runtime-json: socketDir must be a non-empty string');
56
+ }
57
+ if (!Number.isInteger(record.port) || record.port < 1 || record.port > 65535) {
58
+ throw new TypeError(`runtime-json: port must be an integer in [1, 65535]; got ${record.port}`);
59
+ }
60
+ if (!Number.isInteger(record.pid) || record.pid < 1) {
61
+ throw new TypeError(`runtime-json: pid must be a positive integer; got ${record.pid}`);
62
+ }
63
+ if (!Number.isInteger(record.autopgPid) || record.autopgPid < 1) {
64
+ throw new TypeError(`runtime-json: autopgPid must be a positive integer; got ${record.autopgPid}`);
65
+ }
66
+ if (Object.prototype.hasOwnProperty.call(record, 'supervisor')) {
67
+ throw new TypeError(
68
+ 'runtime-json: refusing to write `supervisor` into runtime.json — that field '
69
+ + 'lives only in `~/.autopg/admin.json` (cohort contract).',
70
+ );
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Read `<socketDir>/runtime.json`. Returns the parsed object on success,
76
+ * `null` when the file is missing or unreadable. Never throws — callers
77
+ * treat "missing" and "broken" identically and fall back to admin.json.
78
+ */
79
+ export function readRuntimeJson(socketDir) {
80
+ let file;
81
+ try {
82
+ file = getRuntimeFilePath(socketDir);
83
+ } catch {
84
+ return null;
85
+ }
86
+ let raw;
87
+ try {
88
+ raw = fs.readFileSync(file, 'utf8');
89
+ } catch {
90
+ return null;
91
+ }
92
+ try {
93
+ const parsed = JSON.parse(raw);
94
+ return isPlainObject(parsed) ? parsed : null;
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Atomic write of the runtime discovery record. Validates shape, refuses
102
+ * a `supervisor` key (the cohort contract — that field belongs in
103
+ * admin.json), ensures the parent directory exists, and stamps
104
+ * `schemaVersion: 1` if the caller didn't.
105
+ */
106
+ export function writeRuntimeJson(input = {}) {
107
+ if (!isPlainObject(input)) {
108
+ throw new TypeError('runtime-json: writeRuntimeJson expects an object argument');
109
+ }
110
+ // Reject `supervisor` from the input directly — destructuring would
111
+ // silently drop it and that's a contract failure we want to surface.
112
+ if (Object.prototype.hasOwnProperty.call(input, 'supervisor')) {
113
+ throw new TypeError(
114
+ 'runtime-json: refusing to write `supervisor` into runtime.json — that field '
115
+ + 'lives only in `~/.autopg/admin.json` (cohort contract).',
116
+ );
117
+ }
118
+ const { socketDir, port, pid, autopgPid, schemaVersion = RUNTIME_SCHEMA_VERSION } = input;
119
+ const record = { socketDir, port, pid, autopgPid, schemaVersion };
120
+ validateRecord(record);
121
+
122
+ if (!fs.existsSync(socketDir)) {
123
+ fs.mkdirSync(socketDir, { recursive: true, mode: 0o700 });
124
+ }
125
+
126
+ const file = getRuntimeFilePath(socketDir);
127
+ const tmp = `${file}.tmp.${process.pid}`;
128
+ const json = `${JSON.stringify(record, null, 2)}\n`;
129
+ fs.writeFileSync(tmp, json, { mode: RUNTIME_FILE_MODE });
130
+ fs.renameSync(tmp, file);
131
+ fs.chmodSync(file, RUNTIME_FILE_MODE);
132
+ return record;
133
+ }
134
+
135
+ /**
136
+ * Best-effort delete of `<socketDir>/runtime.json`. Used during graceful
137
+ * shutdown so consumers immediately observe "no live postmaster" instead
138
+ * of seeing a stale-pid record they have to probe with `process.kill()`.
139
+ *
140
+ * Returns `true` when the file was removed, `false` when it was already
141
+ * gone or removal failed. Never throws — graceful shutdown must not
142
+ * regress because of a permission glitch on the runtime file.
143
+ */
144
+ export function clearRuntimeJson(socketDir) {
145
+ let file;
146
+ try {
147
+ file = getRuntimeFilePath(socketDir);
148
+ } catch {
149
+ return false;
150
+ }
151
+ try {
152
+ fs.unlinkSync(file);
153
+ return true;
154
+ } catch {
155
+ return false;
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Returns true when the runtime record points at a process that's alive
161
+ * on this host. `process.kill(pid, 0)` is a no-signal probe — it raises
162
+ * ESRCH when the pid is gone and EPERM when we can't signal a foreign
163
+ * uid (still alive, just not ours). Treat EPERM as "alive" so cross-uid
164
+ * supervisors (e.g. an operator probing a system-installed pgserve)
165
+ * don't false-negative.
166
+ */
167
+ export function isLiveRuntime(record) {
168
+ if (!isPlainObject(record)) return false;
169
+ // process.kill(pid, 0) accepts a process group sentinel for pid <= 0
170
+ // (pid 0 = caller's group, pid -1 = every signalable process). Neither
171
+ // is a meaningful "live postmaster" answer, so reject anything below 1
172
+ // before we touch the syscall.
173
+ const pid = record.autopgPid;
174
+ if (!Number.isInteger(pid) || pid < 1) return false;
175
+ try {
176
+ process.kill(pid, 0);
177
+ return true;
178
+ } catch (err) {
179
+ return err && err.code === 'EPERM';
180
+ }
181
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * `pg_advisory_lock` key derivation for `pgserve provision`.
3
+ *
4
+ * pgserve singleton (v2.4) — `pgserve-singleton-no-proxy` wish, Group 3.
5
+ *
6
+ * The wish locks the concurrency story: 10 simultaneous `pgserve
7
+ * provision <fingerprint>` calls must produce exactly 1 database. The
8
+ * server-side coordination is `pg_advisory_lock(key)`; this module
9
+ * derives the integer key from a fingerprint string deterministically.
10
+ *
11
+ * Why two forms:
12
+ * - `pg_advisory_lock(bigint)` — single 64-bit key
13
+ * - `pg_advisory_lock(int4, int4)` — two 32-bit keys (older
14
+ * postgres major versions on hosts the cohort still supports)
15
+ *
16
+ * Both are derived from the same sha256 of the fingerprint, so two
17
+ * provision processes against the same fingerprint will always pick
18
+ * the same lock regardless of which advisory-lock variant they call.
19
+ *
20
+ * Key derivation:
21
+ * - sha256(fingerprint) → 32 bytes
22
+ * - bigint key: reinterpret the first 8 bytes as a signed 64-bit
23
+ * integer, big-endian. The bigint form is what we
24
+ * recommend; the int4 form is provided for parity with
25
+ * callers that need it.
26
+ * - int4 pair: high 32 bits → key1, low 32 bits → key2 (both
27
+ * signed). This matches postgres's two-arg overload.
28
+ *
29
+ * Pure function: no postgres I/O, no network, no globals.
30
+ */
31
+
32
+ import crypto from 'node:crypto';
33
+
34
+ const PGSERVE_NAMESPACE_TAG = 'pgserve-provision-v1:';
35
+
36
+ function sha256Bytes(input) {
37
+ return crypto.createHash('sha256').update(input, 'utf8').digest();
38
+ }
39
+
40
+ /**
41
+ * Derive the bigint advisory-lock key from a fingerprint string.
42
+ * Returned as a JS BigInt; postgres bigint accepts any signed 64-bit
43
+ * integer (range: -2^63 .. 2^63 - 1).
44
+ * @returns {BigInt}
45
+ */
46
+ export function deriveBigintKey(fingerprint) {
47
+ if (typeof fingerprint !== 'string' || fingerprint.length === 0) {
48
+ throw new TypeError('deriveBigintKey: fingerprint must be a non-empty string');
49
+ }
50
+ const bytes = sha256Bytes(PGSERVE_NAMESPACE_TAG + fingerprint);
51
+ // Read first 8 bytes big-endian, signed.
52
+ const key = bytes.readBigInt64BE(0);
53
+ return key;
54
+ }
55
+
56
+ /**
57
+ * Derive the (int4, int4) advisory-lock key pair. Returns plain Numbers
58
+ * in the signed 32-bit range so the caller can pass them straight to
59
+ * pg-cstring/pg-promise without BigInt coercion plumbing.
60
+ * @returns {{ key1: number, key2: number }}
61
+ */
62
+ export function deriveInt4Pair(fingerprint) {
63
+ if (typeof fingerprint !== 'string' || fingerprint.length === 0) {
64
+ throw new TypeError('deriveInt4Pair: fingerprint must be a non-empty string');
65
+ }
66
+ const bytes = sha256Bytes(PGSERVE_NAMESPACE_TAG + fingerprint);
67
+ // High 32 bits → key1; low 32 bits → key2; both signed.
68
+ const key1 = bytes.readInt32BE(0);
69
+ const key2 = bytes.readInt32BE(4);
70
+ return { key1, key2 };
71
+ }
72
+
73
+ /**
74
+ * Convenience: returns the SQL fragment that acquires the lock with
75
+ * pg_advisory_xact_lock. pgserve provision wraps a transaction around
76
+ * its CREATE DATABASE / CREATE ROLE / INSERT, and xact-scoped locks
77
+ * release automatically when that transaction commits or rolls back —
78
+ * no risk of a dangling session-scoped lock surviving a crashed
79
+ * provision.
80
+ *
81
+ * Returns: `{ sql: '...', params: [BigInt] }`
82
+ */
83
+ export function buildAdvisoryLockSql(fingerprint) {
84
+ const key = deriveBigintKey(fingerprint);
85
+ return {
86
+ sql: 'SELECT pg_advisory_xact_lock($1::bigint)',
87
+ params: [key],
88
+ };
89
+ }
90
+
91
+ export const __testInternals = Object.freeze({ sha256Bytes, PGSERVE_NAMESPACE_TAG });
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Database + role naming for `pgserve provision`.
3
+ *
4
+ * pgserve singleton (v2.4) — `pgserve-singleton-no-proxy` wish, Group 3.
5
+ *
6
+ * Postgres identifiers max out at NAMEDATALEN-1 = 63 chars (default
7
+ * build). Our naming has to:
8
+ * 1. Be deterministic: same fingerprint → same name forever, so a
9
+ * re-run of `pgserve provision` is idempotent and `pgserve gc`
10
+ * can correlate `pg_database` rows back to `pgserve_meta` rows.
11
+ * 2. Be readable: include enough of the publisher slug that an
12
+ * operator looking at `\l` in psql can figure out which consumer
13
+ * a database belongs to.
14
+ * 3. Stay under 63 chars even when the publisher is long.
15
+ * 4. Avoid postgres reserved keywords and quoting requirements
16
+ * (lowercase + [a-z0-9_] only).
17
+ *
18
+ * Layout (≤63 chars):
19
+ *
20
+ * pgserve_<publisher-slug>_<fingerprint-hex-12>
21
+ *
22
+ * - prefix: "pgserve_" (8 chars) — fixed, used by gc to
23
+ * find candidate orphans without scanning every
24
+ * postgres DB.
25
+ * - publisher-slug: sanitized + truncated package.json name /
26
+ * pgserve.publisher. Effective max ≈ 37 chars
27
+ * (computed from min(dbSlugBudget, roleSlugBudget)
28
+ * so DB and role always share the same slug for
29
+ * operator UX in `\l` / `\du`).
30
+ * - fingerprint hex: first 12 hex chars of the fingerprint. Plenty
31
+ * of entropy to avoid collisions across consumers
32
+ * on the same host.
33
+ *
34
+ * Role name uses the same identifier with `_role` suffix. Because the
35
+ * publisher slug is already truncated to fit the database name, we
36
+ * recompute the role-side budget so the role also stays ≤63 chars.
37
+ *
38
+ * Pure function: no fs / network / pg.
39
+ */
40
+
41
+ const POSTGRES_MAX_IDENTIFIER = 63;
42
+ const PREFIX = 'pgserve_';
43
+ const FINGERPRINT_HEX_LEN = 12;
44
+ const ROLE_SUFFIX = '_role';
45
+
46
+ /**
47
+ * Lowercase + replace any char that's not [a-z0-9] with '_'. Collapses
48
+ * runs of '_' to a single '_' and trims leading / trailing '_'.
49
+ */
50
+ export function sanitizeSlug(input) {
51
+ if (typeof input !== 'string') return '';
52
+ return input
53
+ .toLowerCase()
54
+ .replace(/[^a-z0-9]+/g, '_')
55
+ .replace(/_+/g, '_')
56
+ .replace(/^_|_$/g, '');
57
+ }
58
+
59
+ /**
60
+ * @typedef {Object} ProvisionedNames
61
+ * @property {string} databaseName ≤63 chars, [a-z0-9_]+
62
+ * @property {string} roleName ≤63 chars, [a-z0-9_]+
63
+ * @property {string} slug the sanitized publisher slug used
64
+ * @property {string} fingerprintHex first 12 chars of the fingerprint
65
+ */
66
+
67
+ /**
68
+ * Derive the database + role name pair from a fingerprint + publisher.
69
+ *
70
+ * @param {object} args
71
+ * @param {string} args.fingerprint sha256-hex from resolveFingerprint
72
+ * @param {string} args.publisher e.g. '@automagik/genie' (may be '')
73
+ * @returns {ProvisionedNames}
74
+ */
75
+ export function deriveProvisionedNames({ fingerprint, publisher } = {}) {
76
+ if (typeof fingerprint !== 'string' || fingerprint.length === 0) {
77
+ throw new TypeError('deriveProvisionedNames: fingerprint must be a non-empty string');
78
+ }
79
+ // Hex encoding is the typical case (sha256-hex). For pinned-string
80
+ // fingerprints (operator escape hatch) we still accept any chars, but
81
+ // we strip them through the same sanitizer so the database identifier
82
+ // stays valid.
83
+ const hexLike = /^[0-9a-f]+$/.test(fingerprint);
84
+ const fingerprintHex = hexLike
85
+ ? fingerprint.slice(0, FINGERPRINT_HEX_LEN)
86
+ : sanitizeSlug(fingerprint).slice(0, FINGERPRINT_HEX_LEN);
87
+ if (fingerprintHex.length === 0) {
88
+ throw new Error('deriveProvisionedNames: fingerprint produced an empty hex segment');
89
+ }
90
+
91
+ // Database identifier:
92
+ // PREFIX + slug + '_' + fingerprintHex ≤ 63
93
+ // → slug budget = 63 - len(PREFIX) - 1 - len(fingerprintHex)
94
+ const dbSlugBudget = POSTGRES_MAX_IDENTIFIER - PREFIX.length - 1 - fingerprintHex.length;
95
+
96
+ // Role identifier (separate budget — has to also fit ROLE_SUFFIX):
97
+ // PREFIX + slug + '_' + fingerprintHex + ROLE_SUFFIX ≤ 63
98
+ // → slug budget = 63 - len(PREFIX) - 1 - len(fingerprintHex) - len(ROLE_SUFFIX)
99
+ const roleSlugBudget = dbSlugBudget - ROLE_SUFFIX.length;
100
+
101
+ // We use the smaller of the two budgets so the same slug appears in
102
+ // both names — operators reading `\l` and `\du` in psql see matched
103
+ // pairs without surprise truncation.
104
+ const slugBudget = Math.max(0, Math.min(dbSlugBudget, roleSlugBudget));
105
+
106
+ const fullSlug = sanitizeSlug(publisher);
107
+ const slug = fullSlug.slice(0, slugBudget);
108
+
109
+ const databaseName = slug.length > 0
110
+ ? `${PREFIX}${slug}_${fingerprintHex}`
111
+ : `${PREFIX}${fingerprintHex}`;
112
+
113
+ const roleName = slug.length > 0
114
+ ? `${PREFIX}${slug}_${fingerprintHex}${ROLE_SUFFIX}`
115
+ : `${PREFIX}${fingerprintHex}${ROLE_SUFFIX}`;
116
+
117
+ return {
118
+ databaseName,
119
+ roleName,
120
+ slug,
121
+ fingerprintHex,
122
+ };
123
+ }
124
+
125
+ export const __testInternals = Object.freeze({
126
+ POSTGRES_MAX_IDENTIFIER,
127
+ PREFIX,
128
+ FINGERPRINT_HEX_LEN,
129
+ ROLE_SUFFIX,
130
+ });
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Fingerprint resolver for `pgserve provision`.
3
+ *
4
+ * pgserve singleton (v2.4) — `pgserve-singleton-no-proxy` wish, Group 3.
5
+ *
6
+ * Goal: deterministically map a consumer location (cwd / explicit path /
7
+ * package.json contents) to a stable fingerprint string + the metadata
8
+ * `pgserve provision` needs to write a row into `pgserve_meta`.
9
+ *
10
+ * Fingerprint precedence (matches Decision in WISH.md §2.4):
11
+ * 1. If a package.json is found and declares `pgserve.fingerprint`,
12
+ * use that string verbatim. (Operator escape hatch — pinned across
13
+ * moves.)
14
+ * 2. Else if a package.json declares `name` + `version`, fingerprint =
15
+ * sha256(`<name>@<version>`).
16
+ * 3. Else if a package.json declares `name` only, fingerprint =
17
+ * sha256(`<name>`).
18
+ * 4. Else (no package.json or empty), fingerprint = sha256(absolute
19
+ * cwd path). This is the "fallback fingerprint" the wish describes.
20
+ *
21
+ * `publisher` resolution:
22
+ * - prefers `package.json#pgserve.publisher`
23
+ * - falls back to `package.json#name`
24
+ * - empty string when no package.json was found
25
+ *
26
+ * `sourcePath` is always the absolute filesystem path (cwd or supplied)
27
+ * — used by gc to detect "directory removed" orphans.
28
+ *
29
+ * Side effects: synchronous filesystem read of `<cwd>/package.json`
30
+ * (one open + one read; no recursion). No postgres I/O, no network, no
31
+ * globals. Hot-loop callers should cache the result themselves.
32
+ */
33
+
34
+ import fs from 'node:fs';
35
+ import path from 'node:path';
36
+ import crypto from 'node:crypto';
37
+
38
+ /**
39
+ * @typedef {Object} ResolvedFingerprint
40
+ * @property {string} fingerprint sha256-hex (64 chars) OR a literal
41
+ * string if pgserve.fingerprint was set.
42
+ * @property {string} sourcePath absolute path that was inspected.
43
+ * @property {string} publisher resolved publisher; '' when unknown.
44
+ * @property {string} kind 'pinned' | 'name+version' | 'name' | 'cwd'
45
+ * @property {object} packageJson the parsed object; null when absent.
46
+ */
47
+
48
+ function sha256Hex(input) {
49
+ return crypto.createHash('sha256').update(input, 'utf8').digest('hex');
50
+ }
51
+
52
+ function readPackageJson(absDir) {
53
+ const candidate = path.join(absDir, 'package.json');
54
+ let raw;
55
+ try {
56
+ raw = fs.readFileSync(candidate, 'utf8');
57
+ } catch (err) {
58
+ if (err.code === 'ENOENT') return null;
59
+ throw err;
60
+ }
61
+ try {
62
+ const parsed = JSON.parse(raw);
63
+ if (!parsed || typeof parsed !== 'object') return null;
64
+ return parsed;
65
+ } catch (err) {
66
+ const e = new Error(`pgserve provision: package.json at ${candidate} is not valid JSON: ${err.message}`);
67
+ e.code = 'EFINGERPRINTPKG';
68
+ throw e;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Resolve a fingerprint for the given directory (defaults to cwd).
74
+ * @param {object} opts
75
+ * @param {string} [opts.cwd] absolute or relative path to inspect
76
+ * @param {string} [opts.explicit] caller-supplied fingerprint; bypasses package.json
77
+ * @returns {ResolvedFingerprint}
78
+ */
79
+ export function resolveFingerprint(opts = {}) {
80
+ const cwd = path.resolve(opts.cwd || process.cwd());
81
+ if (typeof opts.explicit === 'string' && opts.explicit.length > 0) {
82
+ // Caller passed an explicit fingerprint (CLI flag / config file).
83
+ // We still load package.json so callers get the full publisher
84
+ // metadata, but the fingerprint itself comes from the operator.
85
+ const pkg = readPackageJson(cwd);
86
+ return {
87
+ fingerprint: opts.explicit,
88
+ sourcePath: cwd,
89
+ publisher: derivePublisher(pkg),
90
+ kind: 'pinned',
91
+ packageJson: pkg,
92
+ };
93
+ }
94
+ const pkg = readPackageJson(cwd);
95
+ if (pkg && typeof pkg.pgserve === 'object' && pkg.pgserve !== null
96
+ && typeof pkg.pgserve.fingerprint === 'string'
97
+ && pkg.pgserve.fingerprint.length > 0) {
98
+ return {
99
+ fingerprint: pkg.pgserve.fingerprint,
100
+ sourcePath: cwd,
101
+ publisher: derivePublisher(pkg),
102
+ kind: 'pinned',
103
+ packageJson: pkg,
104
+ };
105
+ }
106
+ if (pkg && typeof pkg.name === 'string' && pkg.name.length > 0) {
107
+ if (typeof pkg.version === 'string' && pkg.version.length > 0) {
108
+ return {
109
+ fingerprint: sha256Hex(`${pkg.name}@${pkg.version}`),
110
+ sourcePath: cwd,
111
+ publisher: derivePublisher(pkg),
112
+ kind: 'name+version',
113
+ packageJson: pkg,
114
+ };
115
+ }
116
+ return {
117
+ fingerprint: sha256Hex(pkg.name),
118
+ sourcePath: cwd,
119
+ publisher: derivePublisher(pkg),
120
+ kind: 'name',
121
+ packageJson: pkg,
122
+ };
123
+ }
124
+ return {
125
+ fingerprint: sha256Hex(cwd),
126
+ sourcePath: cwd,
127
+ publisher: '',
128
+ kind: 'cwd',
129
+ packageJson: null,
130
+ };
131
+ }
132
+
133
+ function derivePublisher(pkg) {
134
+ if (!pkg || typeof pkg !== 'object') return '';
135
+ if (typeof pkg.pgserve === 'object' && pkg.pgserve !== null
136
+ && typeof pkg.pgserve.publisher === 'string'
137
+ && pkg.pgserve.publisher.length > 0) {
138
+ return pkg.pgserve.publisher;
139
+ }
140
+ if (typeof pkg.name === 'string' && pkg.name.length > 0) return pkg.name;
141
+ return '';
142
+ }
143
+
144
+ export const __testInternals = Object.freeze({ sha256Hex, derivePublisher, readPackageJson });
@@ -0,0 +1,120 @@
1
+ /**
2
+ * `pgserve_meta` table bootstrap.
3
+ *
4
+ * pgserve singleton (v2.4) — `pgserve-singleton-no-proxy` wish, Group 3
5
+ * (foundation for `pgserve provision` and `pgserve gc`).
6
+ *
7
+ * Schema rationale (Decision P4 — additive across the cohort):
8
+ *
9
+ * `pgserve_meta` is the single source of truth for "which postgres
10
+ * databases on this host belong to a known pgserve consumer."
11
+ * `pgserve provision` writes a row when it idempotently CREATEs a DB +
12
+ * role for a fingerprint. `pgserve gc` scans the table and DROPs DBs
13
+ * whose `last_used_at` is older than the configured stale threshold or
14
+ * whose source_path no longer exists. The cosign verify columns
15
+ * (`verified_at`, `verified_identity`, `verified_tier`) are layered on
16
+ * top via `src/cosign/schema.js#applyVerifiedColumns`; they ALTER this
17
+ * table after CREATE.
18
+ *
19
+ * Why a separate module from cosign/schema.js: cosign owns the
20
+ * verification *delta*; this module owns the *base* table. Splitting
21
+ * them keeps the cosign migration genuinely additive (it's a no-op on
22
+ * fresh DBs that haven't bootstrapped, exactly as documented in
23
+ * cosign-meta-migration.js).
24
+ *
25
+ * Idempotency: every statement uses `IF NOT EXISTS` (table, columns,
26
+ * indexes). Re-running on an already-bootstrapped database is a no-op.
27
+ */
28
+
29
+ export const PGSERVE_META_TABLE = 'pgserve_meta';
30
+
31
+ /**
32
+ * Base columns owned by this module. Cosign columns are owned by
33
+ * src/cosign/schema.js and ALTERed in afterwards.
34
+ */
35
+ export const PGSERVE_META_COLUMNS = Object.freeze([
36
+ 'fingerprint',
37
+ 'database_name',
38
+ 'role_name',
39
+ 'publisher',
40
+ 'source_path',
41
+ 'created_at',
42
+ 'last_used_at',
43
+ ]);
44
+
45
+ /**
46
+ * Idempotent statements that CREATE the table + supporting indexes.
47
+ * Returned as an array so callers can run each one individually for
48
+ * clear error reporting (mirrors cosign/schema.js#getMigrationStatements).
49
+ *
50
+ * Constraints:
51
+ * - fingerprint: PRIMARY KEY — the package.json sha256 fingerprint
52
+ * - database_name: UNIQUE NOT NULL — guards against accidental dupes
53
+ * - role_name: NOT NULL — every provisioned DB has a paired role
54
+ * - publisher: nullable — older path-tier installs may have none
55
+ * - source_path: nullable — fallback fingerprint may not have one
56
+ * - created_at: NOT NULL DEFAULT now() — set on insert
57
+ * - last_used_at: NOT NULL DEFAULT now() — touched by provision; gc
58
+ * uses it as the staleness signal
59
+ */
60
+ // Schema-qualified name. The upgrade pipeline probes
61
+ // `to_regclass('public.pgserve_meta')` (cosign-meta-migration.js); if a
62
+ // non-default search_path is configured on the active role, an
63
+ // unqualified CREATE TABLE could land the bootstrap in a non-public
64
+ // schema while later migrations + gc continue to look in `public.`. The
65
+ // only safe option is to qualify both halves consistently.
66
+ const QUALIFIED = `public.${PGSERVE_META_TABLE}`;
67
+
68
+ export function getBootstrapStatements() {
69
+ return [
70
+ [
71
+ `CREATE TABLE IF NOT EXISTS ${QUALIFIED} (`,
72
+ ' fingerprint TEXT PRIMARY KEY,',
73
+ ' database_name TEXT NOT NULL UNIQUE,',
74
+ ' role_name TEXT NOT NULL,',
75
+ ' publisher TEXT,',
76
+ ' source_path TEXT,',
77
+ ' created_at TIMESTAMPTZ NOT NULL DEFAULT now(),',
78
+ ' last_used_at TIMESTAMPTZ NOT NULL DEFAULT now()',
79
+ ')',
80
+ ].join('\n'),
81
+ `CREATE INDEX IF NOT EXISTS ${PGSERVE_META_TABLE}_last_used_at_idx ON ${QUALIFIED} (last_used_at)`,
82
+ `CREATE INDEX IF NOT EXISTS ${PGSERVE_META_TABLE}_publisher_idx ON ${QUALIFIED} (publisher)`,
83
+ ];
84
+ }
85
+
86
+ /**
87
+ * Single SQL string variant — convenient for embedding the bootstrap in
88
+ * a transaction or pg-init script.
89
+ */
90
+ export function getBootstrapSQL() {
91
+ return `${getBootstrapStatements().join(';\n\n')};\n`;
92
+ }
93
+
94
+ /**
95
+ * Apply the bootstrap via a node-postgres-compatible client. The client
96
+ * must expose an async `query(sql)` method (matches both `pg.Client` and
97
+ * `pg.PoolClient`). Returns the list of statements executed.
98
+ *
99
+ * Statements run sequentially so a failure on the index half doesn't
100
+ * masquerade as success after the table half ran.
101
+ */
102
+ export async function bootstrapPgserveMeta(client) {
103
+ if (!client || typeof client.query !== 'function') {
104
+ throw new TypeError('bootstrapPgserveMeta: client must expose an async query() method');
105
+ }
106
+ const statements = getBootstrapStatements();
107
+ for (const sql of statements) {
108
+ await client.query(sql);
109
+ }
110
+ return statements;
111
+ }
112
+
113
+ /**
114
+ * Predicate the upgrade pipeline calls before deciding whether to run
115
+ * the cosign-verify ALTER chain. We avoid loading pg here — callers pass
116
+ * the result of `SELECT to_regclass('public.pgserve_meta') IS NOT NULL`.
117
+ */
118
+ export function tableExistsFromRegclass(toRegclassResult) {
119
+ return toRegclassResult === true;
120
+ }