pgserve 2.3.0 → 2.5.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 (44) hide show
  1. package/bin/pgserve-wrapper.cjs +9 -4
  2. package/bin/postgres-server.js +170 -631
  3. package/config/logrotate.d/pgserve +47 -0
  4. package/config/pgaudit.conf +31 -0
  5. package/package.json +3 -2
  6. package/scripts/audit-redaction-lint.js +349 -0
  7. package/scripts/test-npx.sh +32 -10
  8. package/src/audit/audit.js +134 -0
  9. package/src/cli-install.cjs +340 -100
  10. package/src/commands/uninstall.js +241 -0
  11. package/src/commands/verify.js +360 -0
  12. package/src/cosign/cache-token.js +328 -0
  13. package/src/cosign/schema.js +97 -0
  14. package/src/cosign/trust-list.js +81 -0
  15. package/src/cosign/verify-binary.js +277 -0
  16. package/src/index.js +11 -44
  17. package/src/lib/admin-json.js +202 -0
  18. package/src/lib/pm2-args.js +119 -0
  19. package/src/lib/runtime-json.js +181 -0
  20. package/src/lib/socket-dir.js +69 -0
  21. package/src/postgres.js +64 -5
  22. package/src/upgrade/index.js +5 -0
  23. package/src/upgrade/steps/cosign-meta-migration.js +123 -0
  24. package/src/admin-client.js +0 -223
  25. package/src/audit.js +0 -168
  26. package/src/cluster.js +0 -654
  27. package/src/control-db.js +0 -330
  28. package/src/daemon-control.js +0 -468
  29. package/src/daemon-shared.js +0 -18
  30. package/src/daemon-tcp.js +0 -297
  31. package/src/daemon.js +0 -709
  32. package/src/dashboard.js +0 -217
  33. package/src/fingerprint.js +0 -479
  34. package/src/gc.js +0 -351
  35. package/src/pg-wire.js +0 -869
  36. package/src/protocol.js +0 -389
  37. package/src/restore.js +0 -574
  38. package/src/router.js +0 -546
  39. package/src/sdk.js +0 -137
  40. package/src/stats-collector.js +0 -453
  41. package/src/stats-dashboard.js +0 -401
  42. package/src/sync.js +0 -335
  43. package/src/tenancy.js +0 -75
  44. package/src/tokens.js +0 -102
@@ -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/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: '@automagik/pgserve',
53
+ issuer: SIGSTORE_GITHUB_ACTIONS_ISSUER,
54
+ identityRegexp: '^https://github.com/automagik/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
+ }