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.
- package/README.md +5 -8
- package/bin/pgserve-wrapper.cjs +23 -0
- package/bin/postgres-server.js +28 -0
- package/package.json +2 -1
- package/scripts/aggregate-manifest.sh +184 -0
- package/scripts/assemble-tarball.sh +191 -0
- package/scripts/audit-redaction-lint.js +349 -0
- package/scripts/build-binary.sh +213 -0
- package/scripts/fetch-postgres-bins.sh +234 -0
- package/scripts/postinstall.cjs +102 -18
- package/scripts/verify-published-artifacts.sh +211 -0
- package/src/audit/audit.js +134 -0
- package/src/cli-install.cjs +258 -26
- package/src/commands/doctor.js +465 -0
- package/src/commands/gc.js +276 -0
- package/src/commands/provision.js +396 -0
- package/src/commands/trust.js +187 -0
- package/src/commands/verify.js +360 -0
- package/src/cosign/cache-token.js +328 -0
- package/src/cosign/schema.js +97 -0
- package/src/cosign/trust-list.js +81 -0
- package/src/cosign/trust-store.js +250 -0
- package/src/cosign/verify-binary.js +277 -0
- package/src/gc/audit-log.js +150 -0
- package/src/gc/orphan-detection.js +190 -0
- package/src/gc/queries.js +193 -0
- package/src/lib/pg-query.js +145 -0
- package/src/lib/runtime-json.js +181 -0
- package/src/provision/advisory-lock.js +91 -0
- package/src/provision/db-naming.js +130 -0
- package/src/provision/fingerprint.js +144 -0
- package/src/schema/pgserve-meta.js +120 -0
- package/src/security/blocked-versions.js +103 -0
- package/src/upgrade/index.js +5 -0
- package/src/upgrade/steps/binary-cache-flush.js +2 -2
- 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
|
+
}
|