pgserve 2.6.0 → 2.6.4

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.
@@ -31,7 +31,7 @@
31
31
 
32
32
  import { readAdminJson } from '../lib/admin-json.js';
33
33
  import { classifyOrphans } from '../gc/orphan-detection.js';
34
- import { writeGcAudit } from '../gc/audit-log.js';
34
+ import { writeGcAudit, rotateGcAuditLogs } from '../gc/audit-log.js';
35
35
  import {
36
36
  selectMetaRows,
37
37
  selectExistingDbs,
@@ -164,6 +164,21 @@ export async function runGc(argv = []) {
164
164
  detail: `mode=${opts.apply ? 'apply' : 'dry-run'} port=${port} staleAfterDays=${opts.staleAfterDays}`,
165
165
  });
166
166
 
167
+ // Rotate audit logs older than 90 days — runs on every gc invocation
168
+ // (dry-run or apply). Boundary guard preserves the current day's log.
169
+ // Errors are surfaced via the audit log itself; never aborts the gc run.
170
+ try {
171
+ const rotation = rotateGcAuditLogs();
172
+ if (rotation.deleted.length > 0 || rotation.errors.length > 0) {
173
+ writeGcAudit({
174
+ action: 'rotate-summary',
175
+ detail: `deleted=${rotation.deleted.length} kept=${rotation.kept.length} errors=${rotation.errors.length}`,
176
+ });
177
+ }
178
+ } catch (err) {
179
+ writeGcAudit({ action: 'error', reason: 'rotate_failed', detail: err.message });
180
+ }
181
+
167
182
  let metaRows;
168
183
  try {
169
184
  metaRows = selectMetaRows({ port });
@@ -45,6 +45,8 @@ import {
45
45
  writeCacheToken,
46
46
  } from '../cosign/cache-token.js';
47
47
  import { sha256File, verifyBinary } from '../cosign/verify-binary.js';
48
+ import { loadLockedRoots as loadLockedRootsImpl } from '../cosign/locked-roots.js';
49
+ import { readAdminJson } from '../lib/admin-json.js';
48
50
 
49
51
  const EXIT_OK = 0;
50
52
  const EXIT_VERIFY_FAILED = 2;
@@ -110,6 +112,8 @@ function parseArgs(args) {
110
112
  cosignBin: null,
111
113
  allowFetch: false,
112
114
  noCache: false,
115
+ slug: null,
116
+ port: null,
113
117
  };
114
118
  for (let i = 0; i < args.length; i++) {
115
119
  const a = args[i];
@@ -119,6 +123,15 @@ function parseArgs(args) {
119
123
  else if (a === '--no-cache') opts.noCache = true;
120
124
  else if (a === '--bundle') opts.bundlePath = args[++i];
121
125
  else if (a === '--cosign-bin') opts.cosignBin = args[++i];
126
+ else if (a === '--slug') opts.slug = args[++i];
127
+ else if (a === '--port' || a === '-p') {
128
+ const v = Number(args[++i]);
129
+ if (!Number.isInteger(v) || v <= 0 || v > 65535) {
130
+ process.stderr.write('pgserve verify: --port requires an integer in [1, 65535]\n');
131
+ return { exit: EXIT_INVOCATION };
132
+ }
133
+ opts.port = v;
134
+ }
122
135
  else if (a === '--help' || a === '-h') {
123
136
  printHelp(process.stdout);
124
137
  return { exit: EXIT_OK };
@@ -136,6 +149,10 @@ function parseArgs(args) {
136
149
  printHelp(process.stderr);
137
150
  return { exit: EXIT_INVOCATION };
138
151
  }
152
+ if (opts.slug !== null && (typeof opts.slug !== 'string' || opts.slug.trim().length === 0)) {
153
+ process.stderr.write('pgserve verify: --slug requires a non-empty value\n');
154
+ return { exit: EXIT_INVOCATION };
155
+ }
139
156
  return { opts };
140
157
  }
141
158
 
@@ -155,12 +172,19 @@ Options:
155
172
  --cosign-bin <path> Override the cosign executable
156
173
  --allow-fetch Allow downloading cosign if missing
157
174
  --no-cache Never read or write the verified-cache token
175
+ --slug <slug> Verify against the locked_roots snapshot for
176
+ the named consumer slug from public.autopg_meta
177
+ (frozen at \`pgserve create-app\` time). When
178
+ omitted, the live TRUSTED_IDENTITIES list is used.
179
+ --port, -p <N> Override the postgres port for --slug lookups
180
+ (default: read ~/.autopg/admin.json or 5432)
158
181
  --help, -h Show this help
159
182
 
160
183
  Exit codes:
161
184
  0 Verified (fresh or cache hit)
162
- 2 Verification failed
163
- 3 Invocation problem (missing binary/bundle/cosign/pretrusted key)
185
+ 2 Verification failed (binary's identity not in trust list / locked roots)
186
+ 3 Invocation problem (missing binary/bundle/cosign/pretrusted key,
187
+ unknown --slug, autopg_meta not bootstrapped)
164
188
  `);
165
189
  }
166
190
 
@@ -183,11 +207,30 @@ function emit({ json }, payload) {
183
207
  }
184
208
  }
185
209
 
210
+ function resolveVerifyPort(opts) {
211
+ if (typeof opts.port === 'number') return opts.port;
212
+ try {
213
+ const admin = readAdminJson();
214
+ if (admin && Number.isInteger(admin.port) && admin.port > 0) return admin.port;
215
+ } catch {
216
+ /* admin.json absent — fall through */
217
+ }
218
+ return 5432;
219
+ }
220
+
186
221
  /**
187
222
  * Run the verify command. `argv` is the bare argument list AFTER the
188
- * `verify` token. Returns an integer exit code.
223
+ * `verify` token.
224
+ *
225
+ * @param {string[]} argv
226
+ * @param {object} [deps] dependency injection seam
227
+ * @param {Function} [deps.loadLockedRoots] test stub for the
228
+ * autopg_meta loader; defaults
229
+ * to the real psql shellout.
230
+ * @returns {number} exit code
189
231
  */
190
- export function runVerify(argv) {
232
+ export function runVerify(argv, deps = {}) {
233
+ const loadLockedRoots = deps.loadLockedRoots || loadLockedRootsImpl;
191
234
  const parsed = parseArgs(argv);
192
235
  if (parsed.exit !== undefined) return parsed.exit;
193
236
  const opts = parsed.opts;
@@ -296,10 +339,55 @@ export function runVerify(argv) {
296
339
  }
297
340
 
298
341
  // ── Cosign path ──────────────────────────────────────────────────────
342
+ // --slug: load the frozen locked_roots from autopg_meta and pass them
343
+ // through as options.trustList so cosign verification runs against the
344
+ // create-app-time snapshot, not the live TRUSTED_IDENTITIES (BRIEF v5
345
+ // deliverable D4b — the manifest LOCK 1 invariant).
346
+ let slugTrustList;
347
+ let slugLockedAt;
348
+ if (opts.slug) {
349
+ try {
350
+ const port = resolveVerifyPort(opts);
351
+ const loaded = loadLockedRoots({ slug: opts.slug, port });
352
+ slugTrustList = loaded.lockedRoots;
353
+ slugLockedAt = loaded.createdAt;
354
+ } catch (err) {
355
+ // Map structured loader errors → invocation exit code (3). These
356
+ // are operator/setup problems, not "this binary is wrong" failures
357
+ // (which are exit code 2 — handled below by verifyBinary's
358
+ // empty-trust-list / no-matching-identity reasons).
359
+ if (
360
+ err.code === 'EAUTOPGMETAMISSING'
361
+ || err.code === 'EAUTOPGSLUGUNKNOWN'
362
+ || err.code === 'EAUTOPGLOCKEDPARSE'
363
+ ) {
364
+ emit(opts, {
365
+ ok: false,
366
+ reason: 'slug-lookup-failed',
367
+ detail: err.message,
368
+ slug: opts.slug,
369
+ loaderCode: err.code,
370
+ });
371
+ return EXIT_INVOCATION;
372
+ }
373
+ // Anything else (postgres unreachable, psql crash, etc.) is also
374
+ // an invocation problem — operator hasn't connected pgserve verify
375
+ // to a reachable postmaster.
376
+ emit(opts, {
377
+ ok: false,
378
+ reason: 'slug-lookup-failed',
379
+ detail: err.message,
380
+ slug: opts.slug,
381
+ });
382
+ return EXIT_INVOCATION;
383
+ }
384
+ }
385
+
299
386
  const result = verifyBinary(binaryPath, {
300
387
  cosignBin: opts.cosignBin || process.env.PGSERVE_COSIGN_BIN || undefined,
301
388
  bundlePath: opts.bundlePath || undefined,
302
389
  allowFetch: opts.allowFetch === true,
390
+ trustList: slugTrustList,
303
391
  });
304
392
 
305
393
  if (!result.ok) {
@@ -348,6 +436,8 @@ export function runVerify(argv) {
348
436
  cacheFile,
349
437
  bundle: result.bundle,
350
438
  cosignBin: result.cosignBin,
439
+ slug: opts.slug || undefined,
440
+ slugLockedAt: slugLockedAt || undefined,
351
441
  });
352
442
  return EXIT_OK;
353
443
  }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Locked-roots loader — reads `autopg_meta.locked_roots` for a slug.
3
+ *
4
+ * pgserve singleton (v2.6) — `autopg-distribution-cutover-finalize`
5
+ * wish, Group 3 (deliverable D4a). Companion to `pgserve verify --slug
6
+ * <slug>` (D4b): the verify verb passes the loaded `lockedRoots` as
7
+ * `options.trustList` to `verifyBinary()` so verification runs against
8
+ * the frozen-at-create-app snapshot, not the live `TRUSTED_IDENTITIES`.
9
+ *
10
+ * Why this is its own module:
11
+ * - keeps create-app + verify decoupled (verify must not import
12
+ * create-app's full verb code);
13
+ * - lets tests stub the loader via dependency injection without
14
+ * spinning up postgres;
15
+ * - mirrors the cohort-canonical pattern of one psql-shellout file
16
+ * per concern (provision/queries.js, gc/queries.js, etc.).
17
+ *
18
+ * Structured errors with stable `code` values let `pgserve verify`'s
19
+ * error-mapping layer pick the correct exit code:
20
+ *
21
+ * EAUTOPGMETAMISSING table doesn't exist (schema not bootstrapped on
22
+ * this postmaster — operator hasn't run any
23
+ * `pgserve create-app` yet) → invocation problem
24
+ * EAUTOPGSLUGUNKNOWN table exists but no row for slug → invocation
25
+ * problem; actionable message includes the
26
+ * remediation `pgserve create-app <slug>`
27
+ * EAUTOPGLOCKEDPARSE row exists but locked_roots JSONB is malformed
28
+ * (file rot / direct-mutation accident) →
29
+ * invocation problem
30
+ */
31
+
32
+ import { pgQuery, quoteLiteral } from '../lib/pg-query.js';
33
+ import { sanitizeSlug } from '../provision/db-naming.js';
34
+
35
+ /**
36
+ * Load the locked-roots snapshot for a slug.
37
+ *
38
+ * @param {object} args
39
+ * @param {string} args.slug raw or sanitized slug; sanitizeSlug runs
40
+ * inside so callers can pass either form.
41
+ * @param {number} [args.port=5432]
42
+ * @returns {{
43
+ * slug: string,
44
+ * lockedRoots: Array<object>,
45
+ * createdAt: string,
46
+ * lastUpdated: string,
47
+ * manifestPath: string,
48
+ * }}
49
+ * @throws Error with `.code` set to one of EAUTOPGMETAMISSING /
50
+ * EAUTOPGSLUGUNKNOWN / EAUTOPGLOCKEDPARSE on the structured
51
+ * failure modes above; the underlying psql error otherwise.
52
+ */
53
+ export function loadLockedRoots({ slug, port = 5432 } = {}) {
54
+ if (typeof slug !== 'string' || slug.trim().length === 0) {
55
+ throw new TypeError('loadLockedRoots: slug must be a non-empty string');
56
+ }
57
+ const sanitized = sanitizeSlug(slug);
58
+ if (sanitized.length === 0) {
59
+ const err = new Error(
60
+ `loadLockedRoots: slug "${slug}" sanitizes to empty; pick a slug `
61
+ + 'with at least one alphanumeric character',
62
+ );
63
+ err.code = 'EAUTOPGSLUGUNKNOWN';
64
+ throw err;
65
+ }
66
+
67
+ // Probe the table existence in the same query so we can disambiguate
68
+ // "table missing" from "row missing". `to_regclass` returns NULL when
69
+ // the relation is absent in the active search_path; we wrap the
70
+ // SELECT in a CTE that conditionally returns 'NO_TABLE' / 'NO_ROW' /
71
+ // the tab-separated payload. Plain text comes back via psql -At -F\t.
72
+ const sentinel = '__autopg_loaded__';
73
+ const sql = [
74
+ "WITH t AS (SELECT to_regclass('public.autopg_meta') AS rel)",
75
+ 'SELECT',
76
+ " CASE WHEN t.rel IS NULL THEN 'NO_TABLE' ELSE",
77
+ ` COALESCE((SELECT '${sentinel}\t' || locked_roots::text || '\t' ||`,
78
+ " to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"') || '\t' ||",
79
+ " to_char(last_updated AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"') || '\t' ||",
80
+ ' manifest_path',
81
+ ' FROM public.autopg_meta',
82
+ ` WHERE slug = ${quoteLiteral(sanitized)} LIMIT 1), 'NO_ROW')`,
83
+ ' END AS payload',
84
+ 'FROM t',
85
+ ].join('\n');
86
+
87
+ const out = pgQuery({ sql, port, captureStdout: true });
88
+
89
+ if (out === 'NO_TABLE') {
90
+ const err = new Error(
91
+ `loadLockedRoots: public.autopg_meta does not exist on this postmaster `
92
+ + '(no apps registered yet — run `pgserve create-app <slug>` to bootstrap)',
93
+ );
94
+ err.code = 'EAUTOPGMETAMISSING';
95
+ throw err;
96
+ }
97
+
98
+ if (out === 'NO_ROW' || !out.startsWith(`${sentinel}\t`)) {
99
+ const err = new Error(
100
+ `loadLockedRoots: no autopg_meta row for slug "${sanitized}" `
101
+ + `— run \`pgserve create-app ${sanitized}\` first`,
102
+ );
103
+ err.code = 'EAUTOPGSLUGUNKNOWN';
104
+ err.slug = sanitized;
105
+ throw err;
106
+ }
107
+
108
+ // Strip the sentinel + parse the four tab-separated fields.
109
+ const payload = out.slice(sentinel.length + 1);
110
+ const [lockedRootsJson, createdAt, lastUpdated, manifestPath] = payload.split('\t');
111
+
112
+ let lockedRoots;
113
+ try {
114
+ lockedRoots = JSON.parse(lockedRootsJson);
115
+ } catch (err) {
116
+ const wrap = new Error(
117
+ `loadLockedRoots: locked_roots JSONB for slug "${sanitized}" is malformed: ${err.message}`,
118
+ );
119
+ wrap.code = 'EAUTOPGLOCKEDPARSE';
120
+ wrap.slug = sanitized;
121
+ wrap.cause = err;
122
+ throw wrap;
123
+ }
124
+ if (!Array.isArray(lockedRoots)) {
125
+ const err = new Error(
126
+ `loadLockedRoots: locked_roots for slug "${sanitized}" is not a JSON array `
127
+ + `(got ${typeof lockedRoots})`,
128
+ );
129
+ err.code = 'EAUTOPGLOCKEDPARSE';
130
+ err.slug = sanitized;
131
+ throw err;
132
+ }
133
+
134
+ return {
135
+ slug: sanitized,
136
+ lockedRoots,
137
+ createdAt,
138
+ lastUpdated,
139
+ manifestPath,
140
+ };
141
+ }
@@ -37,22 +37,45 @@ export const TRUSTED_IDENTITIES = Object.freeze([
37
37
  id: 'automagik-genie-release',
38
38
  publisher: '@automagik/genie',
39
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)',
40
+ // genie's release.yml is an orchestrator that workflow_call's into
41
+ // sign-attest.yml the Fulcio SAN URI therefore binds to
42
+ // sign-attest.yml@<ref>, not release.yml@<ref>. Verified against
43
+ // both v4.260511.1 (released from main) and v4.260511.2 (released
44
+ // from wish/genie-distribution-cutover-g1) bundle certificates on
45
+ // 2026-05-11: both cert subjects are
46
+ // `automagik-dev/genie/.github/workflows/sign-attest.yml@refs/tags/v4.260511.x`.
47
+ // Same shape as pgserve's own entry below.
48
+ identityRegexp: '^https://github.com/automagik-dev/genie/.github/workflows/sign-attest.yml@refs/tags/v.*$',
49
+ description: 'Namastex automagik genie sign-attest workflow (GitHub Actions OIDC)',
42
50
  }),
43
51
  Object.freeze({
44
52
  id: 'automagik-omni-release',
45
53
  publisher: '@automagik/omni',
46
54
  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)',
55
+ // Omni signing pipeline mirrors genie's orchestrator+workflow_call
56
+ // pattern (Felipe directive 2026-05-11, decision 2.α for
57
+ // `v3-prerelease-trust-loop` G2): release.yml orchestrates, the
58
+ // cosign keyless sign-blob runs inside a reusable sign-attest.yml,
59
+ // and the Fulcio SAN URI binds to sign-attest.yml@<ref>. Anchoring
60
+ // here pre-emptively so omni's first signed tag (post-G2 merge)
61
+ // verifies without a follow-up trust-list flip. Re-validate against
62
+ // the first signed omni bundle during G4 smoke.
63
+ identityRegexp: '^https://github.com/automagik-dev/omni/.github/workflows/sign-attest.yml@refs/tags/v.*$',
64
+ description: 'Namastex automagik omni sign-attest workflow (GitHub Actions OIDC)',
49
65
  }),
50
66
  Object.freeze({
51
67
  id: 'automagik-pgserve-release',
52
68
  publisher: 'pgserve',
53
69
  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)',
70
+ // Wave A (v2.6.x): pgserve's signing happens in `sign-attest.yml`,
71
+ // not `release.yml` (which is the unrelated npm-publish workflow
72
+ // with zero cosign content). Renaming sign-attest.yml → release.yml
73
+ // would clobber that workflow, so we anchor the trust regex on
74
+ // sign-attest.yml instead. Mirror image of genie PR #1725 (binding
75
+ // release.yml@refs/tags/v* — same Sigstore identity discipline,
76
+ // different workflow filename).
77
+ identityRegexp: '^https://github.com/namastexlabs/pgserve/.github/workflows/sign-attest.yml@refs/tags/v.*$',
78
+ description: 'Namastex automagik pgserve sign-attest workflow (GitHub Actions OIDC)',
56
79
  }),
57
80
  ]);
58
81
 
@@ -54,13 +54,141 @@ export function sha256File(filePath) {
54
54
  }
55
55
 
56
56
  /**
57
- * Resolve the sidecar bundle path for a given binary path. Convention:
58
- * `<binary>.bundle`. Operators that publish detached `.sig` + `.cert` can
59
- * regenerate a bundle with `cosign sign-blob --bundle <path>.bundle`; we
60
- * intentionally only support the bundle form to keep the surface narrow.
57
+ * Candidate sidecar bundle paths for a given binary path, in priority
58
+ * order. First existing file wins. When none exist, the first candidate
59
+ * (`<binary>.bundle`) is returned so the caller's existing
60
+ * `bundle-missing` error path keeps the same operator-facing message
61
+ * shape.
62
+ *
63
+ * Three conventions are accepted:
64
+ *
65
+ * 1. `<binary>.bundle` — `cosign sign-blob --bundle` default;
66
+ * pgserve's own producer convention.
67
+ * 2. `<binary-stem>.intoto.jsonl` — slsa-github-generator
68
+ * per-artifact provenance (writes `<artifact>.intoto.jsonl` when
69
+ * provenance-name interpolates the artifact stem). The stem is
70
+ * computed by stripping `.tgz` (the only archive ext used in the
71
+ * automagik-cohort cosign trust loop today).
72
+ * 3. `<dirname>/provenance.intoto.jsonl` — slsa-github-generator
73
+ * generic-provenance default (single sibling per release upload).
74
+ * This is what `automagik-dev/genie` ships today (verified via
75
+ * `gh release view` against v4.260508.3+).
76
+ *
77
+ * Why fall-through, not single-shape: CV-VERIFY-BUNDLE-NAMING (qa
78
+ * Wave-B-live finding 2026-05-09). pgserve's prior single-shape
79
+ * resolver returned literal `<binary>.bundle` and failed every
80
+ * `pgserve verify` against producer-side artifacts that ship per the
81
+ * standards-compliant `intoto.jsonl` conventions. Consumer-side
82
+ * fall-through preserves the producer's standards conformance instead
83
+ * of forcing every producer-side wave (A/B/C) into a non-standard
84
+ * `.bundle` rename.
85
+ */
86
+ export function resolveBundleCandidates(binaryPath) {
87
+ const stem = binaryPath.replace(/\.tgz$/, '');
88
+ return [
89
+ `${binaryPath}.bundle`,
90
+ `${stem}.intoto.jsonl`,
91
+ path.join(path.dirname(binaryPath), 'provenance.intoto.jsonl'),
92
+ ];
93
+ }
94
+
95
+ /**
96
+ * Resolve the sidecar bundle path for a given binary path. Returns the
97
+ * first existing candidate; when none exist, returns the first
98
+ * candidate (`<binary>.bundle`) so the existing `bundle-missing` error
99
+ * path retains its operator-facing message shape.
100
+ *
101
+ * Callers that need the full candidate list (e.g. for diagnostics) can
102
+ * use `resolveBundleCandidates` directly.
61
103
  */
62
104
  export function resolveBundlePath(binaryPath) {
63
- return `${binaryPath}.bundle`;
105
+ const candidates = resolveBundleCandidates(binaryPath);
106
+ return candidates.find((p) => fs.existsSync(p)) ?? candidates[0];
107
+ }
108
+
109
+ /**
110
+ * Detect detached cosign artifacts (`<binary>.sig` + `<binary>.cert`)
111
+ * for a binary. Returns `{ signature, certificate }` when both
112
+ * siblings exist; null otherwise.
113
+ *
114
+ * WAVE-B-BUNDLE-FORMAT (v2.6.3): cosign supports two output shapes for
115
+ * `cosign sign-blob`:
116
+ *
117
+ * 1. Bundled — `--bundle <path>` writes a JSON envelope containing
118
+ * sig + cert + tlog + bundle metadata. Verified via
119
+ * `cosign verify-blob --bundle <path>`.
120
+ * 2. Detached — `--output-signature <sig> --output-certificate <cert>`
121
+ * writes two sidecar files. Verified via
122
+ * `cosign verify-blob --signature <sig> --certificate <cert>`.
123
+ *
124
+ * `automagik-dev/genie` (and many slsa-github-generator-driven release
125
+ * pipelines) emit the detached pair as their release-identity-bearing
126
+ * signature, alongside a separate `provenance.intoto.jsonl` carrying
127
+ * the SLSA generator's identity. Pre-fix pgserve only consumed bundled
128
+ * formats and silently failed `pgserve verify` against detached
129
+ * producers because `provenance.intoto.jsonl`'s cert subject doesn't
130
+ * match the consumer-side trust regex. This helper closes that gap on
131
+ * the consumer side per the asymmetric-cohort principle (Postel's Law:
132
+ * pgserve adapts to producer-side standards-compliant emit shapes).
133
+ *
134
+ * Priority semantics (used in verifyBinary):
135
+ * - When a caller passes `options.bundlePath` explicitly, that wins.
136
+ * - Otherwise, detached takes priority over bundle resolution when
137
+ * both shapes' siblings exist for the same binary. Reasoning:
138
+ * producer-explicit publishing of `<binary>.sig` + `<binary>.cert`
139
+ * is the established cosign convention; preferring bundles
140
+ * silently could mask producer-side regressions in a dual-emit
141
+ * setup. Operators who specifically want bundle resolution can
142
+ * pass `--bundle <path>` to override.
143
+ */
144
+ export function resolveDetachedCosign(binaryPath) {
145
+ const signature = `${binaryPath}.sig`;
146
+ const certificate = `${binaryPath}.cert`;
147
+ if (fs.existsSync(signature) && fs.existsSync(certificate)) {
148
+ return { signature, certificate };
149
+ }
150
+ return null;
151
+ }
152
+
153
+ /**
154
+ * Compute the verification material pgserve will hand to cosign for a
155
+ * given binary, applying the priority rules documented on
156
+ * `resolveDetachedCosign`. Returned shapes:
157
+ *
158
+ * { kind: 'bundle', bundlePath, exists, probed: string[] }
159
+ * { kind: 'detached', signature, certificate, exists, probed: string[] }
160
+ *
161
+ * `exists` is true iff every required sibling file is on disk. `probed`
162
+ * lists the paths inspected so the bundle-missing diagnostic can echo
163
+ * them back to operators.
164
+ */
165
+ export function resolveVerificationMaterial(binaryPath, { bundlePath } = {}) {
166
+ if (bundlePath) {
167
+ return {
168
+ kind: 'bundle',
169
+ bundlePath,
170
+ exists: fs.existsSync(bundlePath),
171
+ probed: [bundlePath],
172
+ };
173
+ }
174
+ const detached = resolveDetachedCosign(binaryPath);
175
+ if (detached) {
176
+ return {
177
+ kind: 'detached',
178
+ signature: detached.signature,
179
+ certificate: detached.certificate,
180
+ exists: true,
181
+ probed: [detached.signature, detached.certificate],
182
+ };
183
+ }
184
+ const candidates = resolveBundleCandidates(binaryPath);
185
+ const found = candidates.find((p) => fs.existsSync(p));
186
+ return {
187
+ kind: 'bundle',
188
+ bundlePath: found ?? candidates[0],
189
+ exists: !!found,
190
+ probed: [...candidates, `${binaryPath}.sig`, `${binaryPath}.cert`],
191
+ };
64
192
  }
65
193
 
66
194
  /**
@@ -187,12 +315,23 @@ export function verifyBinary(binaryPath, options = {}) {
187
315
  return { ok: false, reason: 'binary-not-a-file', detail: binaryPath };
188
316
  }
189
317
 
190
- const bundlePath = options.bundlePath || resolveBundlePath(binaryPath);
191
- if (!fs.existsSync(bundlePath)) {
318
+ const material = resolveVerificationMaterial(binaryPath, { bundlePath: options.bundlePath });
319
+ if (!material.exists) {
320
+ // CV-VERIFY-BUNDLE-NAMING (v2.6.3) + WAVE-B-BUNDLE-FORMAT (v2.6.3):
321
+ // when neither bundled siblings nor the detached `.sig`+`.cert` pair
322
+ // exist, surface the full probed list in the error detail so
323
+ // operators see exactly which paths pgserve inspected. The
324
+ // bundle-missing reason code is unchanged so any log-grep wired
325
+ // against it keeps working; an explicit override via
326
+ // options.bundlePath narrows the probe list to just that path.
192
327
  return {
193
328
  ok: false,
194
329
  reason: 'bundle-missing',
195
- detail: `expected sigstore bundle at ${bundlePath} (run \`cosign sign-blob --bundle ${bundlePath} ${binaryPath}\` to attest)`,
330
+ detail:
331
+ `expected cosign verification material for ${binaryPath} — probed: ${material.probed.join(', ')}. `
332
+ + `Pass --bundle <path> to override, or run `
333
+ + `\`cosign sign-blob --bundle ${binaryPath}.bundle ${binaryPath}\` to attest `
334
+ + `(or use detached \`--output-signature ${binaryPath}.sig --output-certificate ${binaryPath}.cert\`).`,
196
335
  };
197
336
  }
198
337
 
@@ -225,7 +364,7 @@ export function verifyBinary(binaryPath, options = {}) {
225
364
  }
226
365
  const result = invokeCosign({
227
366
  cosignBin,
228
- bundlePath,
367
+ material,
229
368
  binaryPath,
230
369
  identity,
231
370
  });
@@ -238,7 +377,10 @@ export function verifyBinary(binaryPath, options = {}) {
238
377
  tier: COSIGN_TIER,
239
378
  sha256,
240
379
  cosignBin,
241
- bundle: bundlePath,
380
+ // Back-compat: callers reading `.bundle` still get a value
381
+ // (null when detached, the path when bundled).
382
+ bundle: material.kind === 'bundle' ? material.bundlePath : null,
383
+ material,
242
384
  identityChain,
243
385
  };
244
386
  }
@@ -254,10 +396,18 @@ export function verifyBinary(binaryPath, options = {}) {
254
396
  };
255
397
  }
256
398
 
257
- function invokeCosign({ cosignBin, bundlePath, binaryPath, identity }) {
399
+ function invokeCosign({ cosignBin, material, binaryPath, identity }) {
400
+ // WAVE-B-BUNDLE-FORMAT (v2.6.3): dispatch on material.kind so cosign
401
+ // gets the matching argv shape — `--bundle <path>` for bundled
402
+ // sigstore JSON envelopes; `--signature <sig> --certificate <cert>`
403
+ // for detached cosign sign-blob output (genie's release-identity-
404
+ // bearing artifacts ship in this shape).
405
+ const materialArgs = material.kind === 'detached'
406
+ ? ['--signature', material.signature, '--certificate', material.certificate]
407
+ : ['--bundle', material.bundlePath];
258
408
  const args = [
259
409
  'verify-blob',
260
- '--bundle', bundlePath,
410
+ ...materialArgs,
261
411
  '--certificate-identity-regexp', identity.identityRegexp,
262
412
  '--certificate-oidc-issuer', identity.issuer,
263
413
  binaryPath,