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,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HMAC-signed verification cache tokens.
|
|
3
|
+
*
|
|
4
|
+
* pgserve singleton (v2.4) — `pgserve-singleton-no-proxy` wish, Group 4.
|
|
5
|
+
*
|
|
6
|
+
* After `pgserve verify <binary>` succeeds we stash a token at
|
|
7
|
+
* `$XDG_STATE_HOME/pgserve/verified/<fingerprint>.token`
|
|
8
|
+
* with mode 0600. Subsequent invocations short-circuit cosign when:
|
|
9
|
+
* - the token's HMAC matches (tamper-evident, keyed on cache.hmac),
|
|
10
|
+
* - the binary's mtime/size still match the cached values (re-verify
|
|
11
|
+
* when the binary changes on disk per SHARED-DESIGN.md §2.4),
|
|
12
|
+
* - the sliding-expiry window has not lapsed:
|
|
13
|
+
* idle ≤ 1 hour since lastUsedAt
|
|
14
|
+
* total ≤ 7 days since createdAt.
|
|
15
|
+
*
|
|
16
|
+
* The HMAC key lives at `$XDG_STATE_HOME/pgserve/cache.hmac` (32 random
|
|
17
|
+
* bytes, 0600). Auto-generated on first write; re-generated if the file
|
|
18
|
+
* is missing or sized wrong (treated as cache-poisoning recovery).
|
|
19
|
+
*
|
|
20
|
+
* Tokens are stored as canonical JSON wrapped in an envelope:
|
|
21
|
+
* { v: 1, payload: <stable-stringify(token)>, mac: <hex hmac256> }
|
|
22
|
+
* `payload` is a string (not a nested object) so we HMAC the exact bytes
|
|
23
|
+
* we serialized. Nested objects would invite key-ordering ambiguity.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import crypto from 'node:crypto';
|
|
27
|
+
import fs from 'node:fs';
|
|
28
|
+
import os from 'node:os';
|
|
29
|
+
import path from 'node:path';
|
|
30
|
+
|
|
31
|
+
export const TOKEN_VERSION = 1;
|
|
32
|
+
export const TOKEN_MODE = 0o600;
|
|
33
|
+
export const HMAC_KEY_BYTES = 32;
|
|
34
|
+
export const HMAC_KEY_MODE = 0o600;
|
|
35
|
+
export const TOKEN_DIR_MODE = 0o700;
|
|
36
|
+
export const SLIDING_IDLE_MS = 60 * 60 * 1000; // 1 hour
|
|
37
|
+
export const SLIDING_MAX_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Resolve `$XDG_STATE_HOME/pgserve` (preferred) or
|
|
41
|
+
* `$HOME/.local/state/pgserve` (XDG default fallback). Mirrors the spec
|
|
42
|
+
* referenced in the wish (`$XDG_STATE_HOME/pgserve/verified/...`).
|
|
43
|
+
*/
|
|
44
|
+
export function getStateDir() {
|
|
45
|
+
const xdg = process.env.XDG_STATE_HOME;
|
|
46
|
+
if (xdg && xdg.length > 0) return path.join(xdg, 'pgserve');
|
|
47
|
+
return path.join(os.homedir(), '.local', 'state', 'pgserve');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function getVerifiedDir(stateDir = getStateDir()) {
|
|
51
|
+
return path.join(stateDir, 'verified');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function getHmacKeyPath(stateDir = getStateDir()) {
|
|
55
|
+
return path.join(stateDir, 'cache.hmac');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function getTokenPath(fingerprint, stateDir = getStateDir()) {
|
|
59
|
+
if (typeof fingerprint !== 'string' || fingerprint.length === 0) {
|
|
60
|
+
throw new TypeError('cache-token: fingerprint must be a non-empty string');
|
|
61
|
+
}
|
|
62
|
+
if (!/^[A-Za-z0-9._-]+$/.test(fingerprint)) {
|
|
63
|
+
throw new TypeError(
|
|
64
|
+
`cache-token: fingerprint contains unsafe characters: ${JSON.stringify(fingerprint)}`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
return path.join(getVerifiedDir(stateDir), `${fingerprint}.token`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function ensureDir(dir, mode) {
|
|
71
|
+
fs.mkdirSync(dir, { recursive: true, mode });
|
|
72
|
+
fs.chmodSync(dir, mode);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Read or create the HMAC key. The key is 32 random bytes stored at
|
|
77
|
+
* mode 0600. Missing / wrong-sized files are recreated — treat poisoning
|
|
78
|
+
* as a cache miss rather than a hard failure.
|
|
79
|
+
*/
|
|
80
|
+
export function ensureHmacKey({ stateDir = getStateDir() } = {}) {
|
|
81
|
+
ensureDir(stateDir, TOKEN_DIR_MODE);
|
|
82
|
+
const file = getHmacKeyPath(stateDir);
|
|
83
|
+
if (fs.existsSync(file)) {
|
|
84
|
+
const buf = fs.readFileSync(file);
|
|
85
|
+
if (buf.length === HMAC_KEY_BYTES) return buf;
|
|
86
|
+
}
|
|
87
|
+
const buf = crypto.randomBytes(HMAC_KEY_BYTES);
|
|
88
|
+
const tmp = `${file}.tmp.${process.pid}`;
|
|
89
|
+
fs.writeFileSync(tmp, buf, { mode: HMAC_KEY_MODE });
|
|
90
|
+
fs.renameSync(tmp, file);
|
|
91
|
+
fs.chmodSync(file, HMAC_KEY_MODE);
|
|
92
|
+
return buf;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function stableStringify(obj) {
|
|
96
|
+
// Deterministic JSON: sort keys at every level so the same logical
|
|
97
|
+
// payload always produces the same bytes (and HMAC).
|
|
98
|
+
if (obj === null || typeof obj !== 'object') return JSON.stringify(obj);
|
|
99
|
+
if (Array.isArray(obj)) return `[${obj.map(stableStringify).join(',')}]`;
|
|
100
|
+
const keys = Object.keys(obj).sort();
|
|
101
|
+
const body = keys.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(',');
|
|
102
|
+
return `{${body}}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function hmacHex(key, payloadString) {
|
|
106
|
+
return crypto.createHmac('sha256', key).update(payloadString).digest('hex');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function timingSafeEqHex(a, b) {
|
|
110
|
+
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
|
111
|
+
if (a.length !== b.length) return false;
|
|
112
|
+
try {
|
|
113
|
+
return crypto.timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex'));
|
|
114
|
+
} catch {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Compute the binary attestation used as part of the cache key. Re-using
|
|
121
|
+
* mtime + size catches the simple "operator updated the binary" case
|
|
122
|
+
* cheaply; sha256 is intentionally NOT recomputed here on the cached path
|
|
123
|
+
* (callers can recompute on demand if they want defense in depth).
|
|
124
|
+
*/
|
|
125
|
+
export function computeBinaryAttestation(binaryPath) {
|
|
126
|
+
const stat = fs.statSync(binaryPath);
|
|
127
|
+
if (!stat.isFile()) {
|
|
128
|
+
throw new Error(`cache-token: binary path is not a file: ${binaryPath}`);
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
realpath: fs.realpathSync(binaryPath),
|
|
132
|
+
size: stat.size,
|
|
133
|
+
mtimeMs: Math.floor(stat.mtimeMs),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Build the canonical token payload. Caller supplies verification result;
|
|
139
|
+
* we layer in createdAt / lastUsedAt timestamps and the binary attestation.
|
|
140
|
+
*/
|
|
141
|
+
export function buildTokenPayload({
|
|
142
|
+
fingerprint,
|
|
143
|
+
binary,
|
|
144
|
+
identity,
|
|
145
|
+
tier,
|
|
146
|
+
sha256,
|
|
147
|
+
now = Date.now(),
|
|
148
|
+
}) {
|
|
149
|
+
if (typeof fingerprint !== 'string' || fingerprint.length === 0) {
|
|
150
|
+
throw new TypeError('cache-token: fingerprint must be a non-empty string');
|
|
151
|
+
}
|
|
152
|
+
if (!binary || typeof binary !== 'object') {
|
|
153
|
+
throw new TypeError('cache-token: binary attestation required');
|
|
154
|
+
}
|
|
155
|
+
if (typeof identity !== 'string' || identity.length === 0) {
|
|
156
|
+
throw new TypeError('cache-token: identity must be a non-empty string');
|
|
157
|
+
}
|
|
158
|
+
if (typeof tier !== 'string' || tier.length === 0) {
|
|
159
|
+
throw new TypeError('cache-token: tier must be a non-empty string');
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
v: TOKEN_VERSION,
|
|
163
|
+
fingerprint,
|
|
164
|
+
binary: {
|
|
165
|
+
realpath: binary.realpath,
|
|
166
|
+
size: binary.size,
|
|
167
|
+
mtimeMs: binary.mtimeMs,
|
|
168
|
+
},
|
|
169
|
+
identity,
|
|
170
|
+
tier,
|
|
171
|
+
sha256: sha256 || null,
|
|
172
|
+
createdAt: now,
|
|
173
|
+
lastUsedAt: now,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Persist a verification token. Computes HMAC over the canonical payload
|
|
179
|
+
* with the key from `ensureHmacKey()`. Atomic write via tmp+rename. mode
|
|
180
|
+
* 0600.
|
|
181
|
+
*/
|
|
182
|
+
export function writeCacheToken(payload, { stateDir = getStateDir() } = {}) {
|
|
183
|
+
if (!payload || typeof payload !== 'object') {
|
|
184
|
+
throw new TypeError('cache-token: payload must be an object');
|
|
185
|
+
}
|
|
186
|
+
if (payload.v !== TOKEN_VERSION) {
|
|
187
|
+
throw new TypeError(`cache-token: unsupported token version ${payload.v}`);
|
|
188
|
+
}
|
|
189
|
+
const key = ensureHmacKey({ stateDir });
|
|
190
|
+
ensureDir(getVerifiedDir(stateDir), TOKEN_DIR_MODE);
|
|
191
|
+
|
|
192
|
+
const file = getTokenPath(payload.fingerprint, stateDir);
|
|
193
|
+
const payloadString = stableStringify(payload);
|
|
194
|
+
const envelope = {
|
|
195
|
+
v: TOKEN_VERSION,
|
|
196
|
+
payload: payloadString,
|
|
197
|
+
mac: hmacHex(key, payloadString),
|
|
198
|
+
};
|
|
199
|
+
const tmp = `${file}.tmp.${process.pid}`;
|
|
200
|
+
fs.writeFileSync(tmp, `${JSON.stringify(envelope)}\n`, { mode: TOKEN_MODE });
|
|
201
|
+
fs.renameSync(tmp, file);
|
|
202
|
+
fs.chmodSync(file, TOKEN_MODE);
|
|
203
|
+
return file;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function classifyError(reason, detail) {
|
|
207
|
+
return { ok: false, reason, ...(detail ? { detail } : {}) };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Read + validate a cached token. Returns `{ ok: true, payload, file }`
|
|
212
|
+
* on hit, `{ ok: false, reason }` on miss. Never throws on malformed
|
|
213
|
+
* tokens — bad input is treated as a cache miss so the caller can fall
|
|
214
|
+
* back to a fresh verify.
|
|
215
|
+
*/
|
|
216
|
+
export function readCacheToken(fingerprint, {
|
|
217
|
+
binaryAttestation,
|
|
218
|
+
stateDir = getStateDir(),
|
|
219
|
+
now = Date.now(),
|
|
220
|
+
} = {}) {
|
|
221
|
+
let file;
|
|
222
|
+
try {
|
|
223
|
+
file = getTokenPath(fingerprint, stateDir);
|
|
224
|
+
} catch (err) {
|
|
225
|
+
return classifyError('invalid-fingerprint', err.message);
|
|
226
|
+
}
|
|
227
|
+
if (!fs.existsSync(file)) {
|
|
228
|
+
return classifyError('missing');
|
|
229
|
+
}
|
|
230
|
+
let raw;
|
|
231
|
+
try {
|
|
232
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
233
|
+
} catch (err) {
|
|
234
|
+
return classifyError('unreadable', err.message);
|
|
235
|
+
}
|
|
236
|
+
let envelope;
|
|
237
|
+
try {
|
|
238
|
+
envelope = JSON.parse(raw);
|
|
239
|
+
} catch (err) {
|
|
240
|
+
return classifyError('malformed-envelope', err.message);
|
|
241
|
+
}
|
|
242
|
+
if (
|
|
243
|
+
!envelope || typeof envelope !== 'object'
|
|
244
|
+
|| envelope.v !== TOKEN_VERSION
|
|
245
|
+
|| typeof envelope.payload !== 'string'
|
|
246
|
+
|| typeof envelope.mac !== 'string'
|
|
247
|
+
) {
|
|
248
|
+
return classifyError('malformed-envelope');
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const key = ensureHmacKey({ stateDir });
|
|
252
|
+
const expected = hmacHex(key, envelope.payload);
|
|
253
|
+
if (!timingSafeEqHex(expected, envelope.mac)) {
|
|
254
|
+
return classifyError('hmac-mismatch');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
let payload;
|
|
258
|
+
try {
|
|
259
|
+
payload = JSON.parse(envelope.payload);
|
|
260
|
+
} catch (err) {
|
|
261
|
+
return classifyError('malformed-payload', err.message);
|
|
262
|
+
}
|
|
263
|
+
if (!payload || payload.v !== TOKEN_VERSION) {
|
|
264
|
+
return classifyError('malformed-payload');
|
|
265
|
+
}
|
|
266
|
+
if (payload.fingerprint !== fingerprint) {
|
|
267
|
+
return classifyError('fingerprint-mismatch');
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const createdAt = Number(payload.createdAt);
|
|
271
|
+
const lastUsedAt = Number(payload.lastUsedAt);
|
|
272
|
+
if (!Number.isFinite(createdAt) || !Number.isFinite(lastUsedAt)) {
|
|
273
|
+
return classifyError('malformed-timestamps');
|
|
274
|
+
}
|
|
275
|
+
if (now - createdAt > SLIDING_MAX_MS) {
|
|
276
|
+
return classifyError('expired-max');
|
|
277
|
+
}
|
|
278
|
+
if (now - lastUsedAt > SLIDING_IDLE_MS) {
|
|
279
|
+
return classifyError('expired-idle');
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (binaryAttestation) {
|
|
283
|
+
const cached = payload.binary || {};
|
|
284
|
+
if (
|
|
285
|
+
cached.realpath !== binaryAttestation.realpath
|
|
286
|
+
|| cached.size !== binaryAttestation.size
|
|
287
|
+
|| cached.mtimeMs !== binaryAttestation.mtimeMs
|
|
288
|
+
) {
|
|
289
|
+
return classifyError('binary-changed');
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return { ok: true, payload, file };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Bump `lastUsedAt` to `now` on a hit. Returns the rewritten payload, or
|
|
298
|
+
* `null` if the touch fails (treat as soft failure — the cached token is
|
|
299
|
+
* still valid for this invocation).
|
|
300
|
+
*/
|
|
301
|
+
export function touchCacheToken(payload, { stateDir = getStateDir(), now = Date.now() } = {}) {
|
|
302
|
+
if (!payload || typeof payload !== 'object' || payload.v !== TOKEN_VERSION) return null;
|
|
303
|
+
const next = { ...payload, lastUsedAt: now };
|
|
304
|
+
try {
|
|
305
|
+
writeCacheToken(next, { stateDir });
|
|
306
|
+
return next;
|
|
307
|
+
} catch {
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Delete a cached token. Idempotent — missing file is a no-op.
|
|
314
|
+
*/
|
|
315
|
+
export function deleteCacheToken(fingerprint, { stateDir = getStateDir() } = {}) {
|
|
316
|
+
let file;
|
|
317
|
+
try {
|
|
318
|
+
file = getTokenPath(fingerprint, stateDir);
|
|
319
|
+
} catch {
|
|
320
|
+
return false;
|
|
321
|
+
}
|
|
322
|
+
try {
|
|
323
|
+
fs.unlinkSync(file);
|
|
324
|
+
return true;
|
|
325
|
+
} catch {
|
|
326
|
+
return false;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `pgserve_meta` schema delta — additive verification columns.
|
|
3
|
+
*
|
|
4
|
+
* pgserve singleton (v2.4) — `pgserve-singleton-no-proxy` wish, Group 4.
|
|
5
|
+
*
|
|
6
|
+
* Decision P4 (locked): the schema delta is purely additive. Pre-cosign
|
|
7
|
+
* `path`-tier rows continue to work — we add `verified_at`,
|
|
8
|
+
* `verified_identity`, `verified_tier` only. Group 3 (`pgserve provision`)
|
|
9
|
+
* writes these columns when its tier resolution lands in the cosign-signed
|
|
10
|
+
* path; older rows leave them NULL and behave exactly as before.
|
|
11
|
+
*
|
|
12
|
+
* Why a separate module: the underlying `pgserve_meta` table is owned by
|
|
13
|
+
* Group 3 (provision) which lands after Group 4 in Wave 2. We ship the
|
|
14
|
+
* column definitions + idempotent ALTER statements here so Group 3 / 7
|
|
15
|
+
* can call us once their CREATE TABLE has run. Idempotency relies on
|
|
16
|
+
* `ADD COLUMN IF NOT EXISTS` and `ADD CONSTRAINT IF NOT EXISTS` so re-
|
|
17
|
+
* running on an already-migrated database is a no-op.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export const VERIFIED_TIER_VALUES = Object.freeze([
|
|
21
|
+
'path',
|
|
22
|
+
'host_signed',
|
|
23
|
+
'self_signed',
|
|
24
|
+
'cosign_signed',
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
export const VERIFIED_TIER_CHECK_NAME = 'pgserve_meta_verified_tier_check';
|
|
28
|
+
|
|
29
|
+
const TIER_LIST_SQL = VERIFIED_TIER_VALUES.map((v) => `'${v}'`).join(', ');
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Idempotent ALTER statements that add the verification columns to an
|
|
33
|
+
* existing `pgserve_meta` table. Returned as an array so callers can run
|
|
34
|
+
* each statement individually for clearer error reporting.
|
|
35
|
+
*
|
|
36
|
+
* The CHECK constraint is added via DO-block guarded `pg_constraint`
|
|
37
|
+
* lookup because `ADD CONSTRAINT IF NOT EXISTS` is not standardized
|
|
38
|
+
* across all postgres major versions we still support.
|
|
39
|
+
*/
|
|
40
|
+
export function getMigrationStatements() {
|
|
41
|
+
return [
|
|
42
|
+
'ALTER TABLE pgserve_meta ADD COLUMN IF NOT EXISTS verified_at TIMESTAMPTZ',
|
|
43
|
+
'ALTER TABLE pgserve_meta ADD COLUMN IF NOT EXISTS verified_identity TEXT',
|
|
44
|
+
'ALTER TABLE pgserve_meta ADD COLUMN IF NOT EXISTS verified_tier TEXT',
|
|
45
|
+
[
|
|
46
|
+
'DO $$',
|
|
47
|
+
'BEGIN',
|
|
48
|
+
' IF NOT EXISTS (',
|
|
49
|
+
' SELECT 1 FROM pg_constraint',
|
|
50
|
+
` WHERE conname = '${VERIFIED_TIER_CHECK_NAME}'`,
|
|
51
|
+
' ) THEN',
|
|
52
|
+
' EXECUTE $check$',
|
|
53
|
+
` ALTER TABLE pgserve_meta ADD CONSTRAINT ${VERIFIED_TIER_CHECK_NAME}`,
|
|
54
|
+
` CHECK (verified_tier IS NULL OR verified_tier IN (${TIER_LIST_SQL}))`,
|
|
55
|
+
' $check$;',
|
|
56
|
+
' END IF;',
|
|
57
|
+
'END$$',
|
|
58
|
+
].join('\n'),
|
|
59
|
+
];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Single SQL string variant — convenient for embedding the migration in
|
|
64
|
+
* pg-init scripts that run statements in a transaction.
|
|
65
|
+
*/
|
|
66
|
+
export function getMigrationSQL() {
|
|
67
|
+
return `${getMigrationStatements().join(';\n\n')};\n`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Apply the migration via a node-postgres-compatible client. The client
|
|
72
|
+
* must expose an async `query(sql)` method (matches both `pg.Client` and
|
|
73
|
+
* `pg.PoolClient`). Returns the list of statements executed for caller
|
|
74
|
+
* diagnostics.
|
|
75
|
+
*
|
|
76
|
+
* Statements run sequentially — DO blocks need their own statement
|
|
77
|
+
* boundary and we don't want a single-line failure to masquerade as
|
|
78
|
+
* success on later statements.
|
|
79
|
+
*/
|
|
80
|
+
export async function applyVerifiedColumns(client) {
|
|
81
|
+
if (!client || typeof client.query !== 'function') {
|
|
82
|
+
throw new TypeError('schema: client must expose an async query() method');
|
|
83
|
+
}
|
|
84
|
+
const statements = getMigrationStatements();
|
|
85
|
+
for (const sql of statements) {
|
|
86
|
+
await client.query(sql);
|
|
87
|
+
}
|
|
88
|
+
return statements;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Convenience predicate — true when the tier value is one the wish
|
|
93
|
+
* accepts. Group 3 (`pgserve provision`) calls this before writing rows.
|
|
94
|
+
*/
|
|
95
|
+
export function isValidTier(tier) {
|
|
96
|
+
return VERIFIED_TIER_VALUES.includes(tier);
|
|
97
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hardcoded cosign trust list — Tier 2 (cosign_signed) identity table.
|
|
3
|
+
*
|
|
4
|
+
* pgserve singleton (v2.4) — `pgserve-singleton-no-proxy` wish, Group 4.
|
|
5
|
+
*
|
|
6
|
+
* Identities listed here are baked into the binary (Decision P6 in the
|
|
7
|
+
* wish). Operators cannot remove or override them; updates flow only
|
|
8
|
+
* through `pgserve update` shipping a new build. User-extensible roots
|
|
9
|
+
* live separately in `~/.pgserve/trust/identities.json` (Group 3 surface),
|
|
10
|
+
* not here.
|
|
11
|
+
*
|
|
12
|
+
* Identity shape mirrors what `cosign verify` consumes via
|
|
13
|
+
* `--certificate-identity` + `--certificate-oidc-issuer` flags. The
|
|
14
|
+
* `identityRegexp` form (Sigstore conventional) accepts `--certificate-
|
|
15
|
+
* identity-regexp`.
|
|
16
|
+
*
|
|
17
|
+
* SHARED-DESIGN.md §2.4 commits to GitHub Actions OIDC for the Namastex
|
|
18
|
+
* automagik release workflows: `release.yml@refs/tags/v*`. We pin both the
|
|
19
|
+
* exact issuer URL and the regexp form so callers can pick whichever
|
|
20
|
+
* cosign CLI flag set is convenient.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export const SIGSTORE_GITHUB_ACTIONS_ISSUER = 'https://token.actions.githubusercontent.com';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Hardcoded trust roots — frozen to prevent runtime mutation.
|
|
27
|
+
*
|
|
28
|
+
* Each entry:
|
|
29
|
+
* id short stable identifier used in diagnostics
|
|
30
|
+
* publisher `package.json` `pgserve.publisher` value the entry attests
|
|
31
|
+
* issuer OIDC issuer URL (Sigstore --certificate-oidc-issuer)
|
|
32
|
+
* identityRegexp Sigstore --certificate-identity-regexp value
|
|
33
|
+
* description human-readable summary for `pgserve trust list`
|
|
34
|
+
*/
|
|
35
|
+
export const TRUSTED_IDENTITIES = Object.freeze([
|
|
36
|
+
Object.freeze({
|
|
37
|
+
id: 'automagik-genie-release',
|
|
38
|
+
publisher: '@automagik/genie',
|
|
39
|
+
issuer: SIGSTORE_GITHUB_ACTIONS_ISSUER,
|
|
40
|
+
identityRegexp: '^https://github.com/automagik-dev/genie/.github/workflows/release.yml@refs/tags/v.*$',
|
|
41
|
+
description: 'Namastex automagik genie release workflow (GitHub Actions OIDC)',
|
|
42
|
+
}),
|
|
43
|
+
Object.freeze({
|
|
44
|
+
id: 'automagik-omni-release',
|
|
45
|
+
publisher: '@automagik/omni',
|
|
46
|
+
issuer: SIGSTORE_GITHUB_ACTIONS_ISSUER,
|
|
47
|
+
identityRegexp: '^https://github.com/automagik-dev/omni/.github/workflows/release.yml@refs/tags/v.*$',
|
|
48
|
+
description: 'Namastex automagik omni release workflow (GitHub Actions OIDC)',
|
|
49
|
+
}),
|
|
50
|
+
Object.freeze({
|
|
51
|
+
id: 'automagik-pgserve-release',
|
|
52
|
+
publisher: 'pgserve',
|
|
53
|
+
issuer: SIGSTORE_GITHUB_ACTIONS_ISSUER,
|
|
54
|
+
identityRegexp: '^https://github.com/namastexlabs/pgserve/.github/workflows/release.yml@refs/tags/v.*$',
|
|
55
|
+
description: 'Namastex automagik pgserve release workflow (GitHub Actions OIDC)',
|
|
56
|
+
}),
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
const TRUSTED_BY_ID = new Map(TRUSTED_IDENTITIES.map((e) => [e.id, e]));
|
|
60
|
+
const TRUSTED_BY_PUBLISHER = new Map(TRUSTED_IDENTITIES.map((e) => [e.publisher, e]));
|
|
61
|
+
|
|
62
|
+
export function getTrustedById(id) {
|
|
63
|
+
return TRUSTED_BY_ID.get(id) || null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function getTrustedByPublisher(publisher) {
|
|
67
|
+
return TRUSTED_BY_PUBLISHER.get(publisher) || null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Return the trust roots in serialized-list form for `pgserve trust list`.
|
|
72
|
+
* Includes the hardcoded marker so the surface can refuse `trust remove`
|
|
73
|
+
* operations against compiled-in entries.
|
|
74
|
+
*/
|
|
75
|
+
export function listHardcodedTrust() {
|
|
76
|
+
return TRUSTED_IDENTITIES.map((entry) => ({
|
|
77
|
+
...entry,
|
|
78
|
+
source: 'hardcoded',
|
|
79
|
+
removable: false,
|
|
80
|
+
}));
|
|
81
|
+
}
|