pgserve 2.5.0 → 2.6.1
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 +48 -0
- package/package.json +1 -1
- package/scripts/aggregate-manifest.sh +184 -0
- package/scripts/assemble-tarball.sh +191 -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/cli-install.cjs +229 -3
- 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/cosign/trust-list.js +3 -3
- package/src/cosign/trust-store.js +250 -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/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/steps/binary-cache-flush.js +2 -2
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hardcoded blocklist — pgserve-singleton-no-proxy wish, Group 5.
|
|
3
|
+
*
|
|
4
|
+
* Compile-time list of pgserve versions that `pgserve install` and
|
|
5
|
+
* `pgserve update` MUST refuse, with a clear diagnostic. The trust root
|
|
6
|
+
* is opaque to operators: they cannot edit this file at runtime, only
|
|
7
|
+
* receive an updated list via `pgserve update`.
|
|
8
|
+
*
|
|
9
|
+
* Per SHARED-DESIGN.md §2.5: this is the ONLY revocation surface for v2.4+.
|
|
10
|
+
* No Rekor consultation, no revoked.json sync, no DNS-served blocklist.
|
|
11
|
+
* The list grows when a known-bad version ships and gets pinned here, then
|
|
12
|
+
* an updated pgserve release rolls out via the normal channel.
|
|
13
|
+
*
|
|
14
|
+
* Format: array of { version, reason, advisoryUrl? } records.
|
|
15
|
+
* - version: exact semver string. Range matchers are intentionally
|
|
16
|
+
* unsupported — every blocked version is named explicitly so an
|
|
17
|
+
* auditor reading this file knows exactly what is rejected.
|
|
18
|
+
* - reason: one-line operator-facing explanation (printed on refusal).
|
|
19
|
+
* - advisoryUrl: optional pointer to a CVE/security advisory for the
|
|
20
|
+
* blocked release.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {Object} BlockedVersion
|
|
25
|
+
* @property {string} version Exact semver string (no ranges).
|
|
26
|
+
* @property {string} reason Operator-facing diagnostic line.
|
|
27
|
+
* @property {string} [advisoryUrl] Pointer to CVE/security advisory.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** @type {readonly BlockedVersion[]} */
|
|
31
|
+
export const BLOCKED_VERSIONS = Object.freeze([
|
|
32
|
+
// Empty by default. Populate as known-bad versions are identified.
|
|
33
|
+
// Example shape (uncomment + edit when a real block is needed):
|
|
34
|
+
// { version: '2.6.0', reason: 'Postmaster crash on Linux ARM64 — see #999', advisoryUrl: 'https://github.com/namastexlabs/pgserve/security/advisories/GHSA-xxxx' },
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Test-only override. The compile-time list is frozen so production code
|
|
39
|
+
* cannot mutate it; tests need a way to inject blocked entries to exercise
|
|
40
|
+
* the throw path. Populated via __addBlockedForTest() and consulted by
|
|
41
|
+
* findBlocked() when non-empty. Cleared via __clearBlockedTestOverridesForTest().
|
|
42
|
+
*
|
|
43
|
+
* @type {BlockedVersion[]}
|
|
44
|
+
*/
|
|
45
|
+
const _testOverrides = [];
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Test-only — register an additional blocked entry for the lifetime of a
|
|
49
|
+
* test. Call __clearBlockedTestOverridesForTest() in afterEach to keep
|
|
50
|
+
* tests isolated.
|
|
51
|
+
*
|
|
52
|
+
* @param {BlockedVersion} entry
|
|
53
|
+
*/
|
|
54
|
+
export function __addBlockedForTest(entry) {
|
|
55
|
+
if (!entry || typeof entry.version !== 'string' || typeof entry.reason !== 'string') {
|
|
56
|
+
throw new Error('__addBlockedForTest: entry needs { version, reason }');
|
|
57
|
+
}
|
|
58
|
+
_testOverrides.push(entry);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Test-only — drop all entries registered via __addBlockedForTest. */
|
|
62
|
+
export function __clearBlockedTestOverridesForTest() {
|
|
63
|
+
_testOverrides.length = 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Find the blocklist entry for an exact version string, if any.
|
|
68
|
+
* Considers both the compile-time BLOCKED_VERSIONS list and any test
|
|
69
|
+
* overrides registered via __addBlockedForTest().
|
|
70
|
+
*
|
|
71
|
+
* @param {string} version
|
|
72
|
+
* @returns {BlockedVersion | undefined}
|
|
73
|
+
*/
|
|
74
|
+
export function findBlocked(version) {
|
|
75
|
+
if (typeof version !== 'string' || version.length === 0) return undefined;
|
|
76
|
+
const fromOverrides = _testOverrides.find((b) => b.version === version);
|
|
77
|
+
if (fromOverrides) return fromOverrides;
|
|
78
|
+
return BLOCKED_VERSIONS.find((b) => b.version === version);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Assert that a version is not blocked. Throws an Error with a stable,
|
|
83
|
+
* grep-able prefix (`EBLOCKEDVERSION`) when the version is blocked, so
|
|
84
|
+
* callers (cli-install / upgrade) can detect it and exit with a known code.
|
|
85
|
+
*
|
|
86
|
+
* @param {string} version
|
|
87
|
+
* @throws {Error} when version is blocked
|
|
88
|
+
*/
|
|
89
|
+
export function assertNotBlocked(version) {
|
|
90
|
+
const hit = findBlocked(version);
|
|
91
|
+
if (!hit) return;
|
|
92
|
+
const lines = [
|
|
93
|
+
`EBLOCKEDVERSION: pgserve@${version} is blocked.`,
|
|
94
|
+
` reason: ${hit.reason}`,
|
|
95
|
+
];
|
|
96
|
+
if (hit.advisoryUrl) lines.push(` advisory: ${hit.advisoryUrl}`);
|
|
97
|
+
lines.push(' remediation: install a different version (run `pgserve update` for the latest).');
|
|
98
|
+
const err = new Error(lines.join('\n'));
|
|
99
|
+
err.code = 'EBLOCKEDVERSION';
|
|
100
|
+
err.version = version;
|
|
101
|
+
err.reason = hit.reason;
|
|
102
|
+
throw err;
|
|
103
|
+
}
|
|
@@ -57,8 +57,8 @@ export async function execute({ log, warn }) {
|
|
|
57
57
|
|
|
58
58
|
if (!downloadFn) {
|
|
59
59
|
warn(`binary needs refresh (pinned=${pinned}, cached=${marker || 'missing'}) but autopg postgres module not exposing download API`);
|
|
60
|
-
warn(`operator action: rerun \`
|
|
61
|
-
return { status: 'FAIL', detail: 'binary refresh needs
|
|
60
|
+
warn(`operator action: rerun \`curl -fsSL https://raw.githubusercontent.com/namastexlabs/pgserve/main/install.sh | bash\` to refresh from GitHub Releases`);
|
|
61
|
+
return { status: 'FAIL', detail: 'binary refresh needs install.sh rerun (no npm dependency)' };
|
|
62
62
|
}
|
|
63
63
|
log(`re-downloading PG ${pinned} into ${cacheDir}`);
|
|
64
64
|
await downloadFn({ version: pinned, targetDir: cacheDir });
|