verify-rhf-d-catalog-consent.mjs
444 lines 13.5 KB
Raw
sha256:4215cecbbabf5591b1ff69053cc938b4b6c800f0d487be40618c1d4254d8b67b security: npm audit fix pre-bridge 2026-09-05 Human 5 days ago
1 #!/usr/bin/env node
2 /**
3 * RHF-d — verify deployed retail catalog actor + establish/read personal smoke consent.
4 *
5 * - Confirms production exposes exactly agent_codex_retail (immutable catalog).
6 * - When consent is missing, runs reviewed consent workflow:
7 * propose → evaluate → approve → apply-approved (never grant mint, never renew-personal).
8 * - helper-access may return 503 DELEGATION_AUTHORITY_UNAVAILABLE pre-marker (expected).
9 * - Prints only actor id, consent id, status, scope, timestamps — no JWT/bearer.
10 *
11 * Usage:
12 * node scripts/hub-session-refresh.mjs
13 * KNOWTATION_HUB_VAULT_ID=Business node scripts/verify-rhf-d-catalog-consent.mjs
14 *
15 * Optional:
16 * KNOWTATION_HUB_API=https://api.knowtation.store
17 * RHF_D_ESTABLISH_CONSENT=0 skip consent establishment (verify catalog only)
18 */
19
20 import path from 'node:path';
21 import { fileURLToPath } from 'node:url';
22 import dotenv from 'dotenv';
23
24 import { RETAIL_ACTOR_ID } from '../lib/agent/delegation-authority-store.mjs';
25 import { getTrustedCatalogIdentity } from '../lib/agent/trusted-external-provider-catalog.mjs';
26 import { ensureHostedSessionAccessToken } from './lib/hub-session-auth.mjs';
27
28 const __dirname = path.dirname(fileURLToPath(import.meta.url));
29 dotenv.config({ path: path.join(__dirname, '..', '.env') });
30
31 const apiBase = (process.env.KNOWTATION_HUB_API || process.env.KNOWTATION_HUB_URL || 'https://api.knowtation.store').replace(/\/$/, '');
32 const vaultId = process.env.KNOWTATION_HUB_VAULT_ID || 'Business';
33 const actorId = RETAIL_ACTOR_ID;
34 const establishConsent = process.env.RHF_D_ESTABLISH_CONSENT !== '0';
35 const TIMEOUT_MS = 25_000;
36
37 /** @type {{ step: string, ok: boolean, detail: string }[]} */
38 const results = [];
39
40 /** @type {Record<string, unknown>} */
41 const evidence = {
42 phase: 'RHF-d',
43 vault_id: vaultId,
44 actor_agent_id: actorId,
45 hosted_origin: apiBase,
46 catalog: null,
47 helper_access: null,
48 consent: null,
49 no_grant_mint: true,
50 no_production_marker: true,
51 };
52
53 function record(step, ok, detail) {
54 results.push({ step, ok, detail });
55 console.log(`[${ok ? 'PASS' : 'FAIL'}] ${step}: ${detail}`);
56 }
57
58 /**
59 * @param {string} accessToken
60 * @param {Record<string, string>} [extraHeaders]
61 */
62 function authHeaders(accessToken, extraHeaders = {}) {
63 return {
64 Accept: 'application/json',
65 'Content-Type': 'application/json',
66 Authorization: `Bearer ${accessToken}`,
67 'X-Vault-Id': vaultId,
68 ...extraHeaders,
69 };
70 }
71
72 /**
73 * @param {string} method
74 * @param {string} pathSuffix
75 * @param {string} accessToken
76 * @param {object} [body]
77 */
78 async function api(method, pathSuffix, accessToken, body) {
79 const res = await fetch(`${apiBase}${pathSuffix}`, {
80 method,
81 headers: authHeaders(accessToken),
82 body: body ? JSON.stringify(body) : undefined,
83 signal: AbortSignal.timeout(TIMEOUT_MS),
84 });
85 const text = await res.text();
86 /** @type {Record<string, unknown>} */
87 let json = {};
88 try {
89 json = text ? JSON.parse(text) : {};
90 } catch {
91 json = {};
92 }
93 return { status: res.status, json };
94 }
95
96 /**
97 * @param {object} entry
98 * @param {object} expected
99 */
100 export function catalogEntryMatches(entry, expected) {
101 const fields = [
102 'schema',
103 'agent_id',
104 'kind',
105 'provider',
106 'owner_ref',
107 'registry_scope',
108 'vault_id',
109 'scope_ceiling',
110 'status',
111 'created',
112 'updated',
113 ];
114 for (const key of fields) {
115 const a = entry[key];
116 const b = expected[key];
117 if (JSON.stringify(a) !== JSON.stringify(b)) {
118 return { ok: false, field: key, got: a, expected: b };
119 }
120 }
121 return { ok: true };
122 }
123
124 /**
125 * @param {string} accessToken
126 */
127 async function verifyCatalog(accessToken) {
128 const expected = getTrustedCatalogIdentity(actorId);
129 if (!expected) {
130 return { ok: false, detail: 'local catalog missing retail actor (should never happen)' };
131 }
132
133 const res = await api(
134 'GET',
135 '/api/v1/agents/identities?kind=external_provider&status=active',
136 accessToken,
137 );
138 if (res.status !== 200 || !Array.isArray(res.json.identities)) {
139 return { ok: false, detail: `identity list HTTP ${res.status}` };
140 }
141
142 const providers = res.json.identities.filter((i) => i && typeof i === 'object');
143 const retail = providers.filter((i) => i.agent_id === actorId);
144 if (retail.length !== 1) {
145 return {
146 ok: false,
147 detail: `expected exactly one ${actorId}; found ${retail.length} among ${providers.length} active external_provider`,
148 };
149 }
150
151 const match = catalogEntryMatches(retail[0], expected);
152 if (!match.ok) {
153 return {
154 ok: false,
155 detail: `catalog field mismatch ${match.field}: got ${JSON.stringify(match.got)} expected ${JSON.stringify(match.expected)}`,
156 };
157 }
158
159 evidence.catalog = {
160 agent_id: actorId,
161 kind: 'external_provider',
162 provider: 'codex',
163 scope_ceiling: 'personal',
164 status: 'active',
165 created: expected.created,
166 updated: expected.updated,
167 active_external_provider_count: providers.length,
168 };
169
170 return { ok: true, detail: `exact catalog match; ${providers.length} active external_provider(s)` };
171 }
172
173 /**
174 * @param {string} accessToken
175 */
176 async function readHelperAccess(accessToken) {
177 const res = await api(
178 'GET',
179 `/api/v1/delegation/helper-access?actor_agent_id=${encodeURIComponent(actorId)}`,
180 accessToken,
181 );
182 const code = typeof res.json.code === 'string' ? res.json.code : null;
183 if (res.status === 503 && code === 'DELEGATION_AUTHORITY_UNAVAILABLE') {
184 evidence.helper_access = {
185 state: 'unavailable_pre_marker',
186 code,
187 note: 'expected until production authority marker Tier-3 cutover',
188 };
189 return { ok: true, preMarker: true, detail: `pre-marker 503 ${code} (expected)` };
190 }
191 if (res.status !== 200) {
192 return { ok: false, detail: `helper-access HTTP ${res.status} code=${code ?? 'none'}` };
193 }
194 const state = typeof res.json.state === 'string' ? res.json.state : '';
195 evidence.helper_access = { state, actor_agent_id: actorId };
196 return { ok: true, state, detail: `state=${state}` };
197 }
198
199 /**
200 * @param {string} accessToken
201 */
202 async function loadRubricChecklist(accessToken) {
203 const res = await api('GET', '/api/v1/settings', accessToken);
204 const items = res.json?.proposal_rubric?.items;
205 if (!Array.isArray(items)) return [];
206 return items.map((item) => ({ id: item.id, passed: true }));
207 }
208
209 /**
210 * @param {string} accessToken
211 * @param {string} proposalId
212 */
213 async function evaluateApproveApply(accessToken, proposalId) {
214 const checklist = await loadRubricChecklist(accessToken);
215 const evaluation = await api(
216 'POST',
217 `/api/v1/proposals/${encodeURIComponent(proposalId)}/evaluation`,
218 accessToken,
219 {
220 outcome: 'pass',
221 checklist,
222 comment: 'RHF-d retail Codex helper personal consent',
223 },
224 );
225 if (evaluation.status < 200 || evaluation.status >= 300) {
226 return {
227 ok: false,
228 detail: `evaluation HTTP ${evaluation.status} ${evaluation.json?.code ?? ''}`,
229 };
230 }
231
232 const approve = await api(
233 'POST',
234 `/api/v1/proposals/${encodeURIComponent(proposalId)}/approve`,
235 accessToken,
236 { waiver_reason: 'RHF-d operator retail helper consent setup' },
237 );
238 if (approve.status < 200 || approve.status >= 300) {
239 return {
240 ok: false,
241 detail: `approve HTTP ${approve.status} ${approve.json?.code ?? ''}`,
242 };
243 }
244
245 const apply = await api(
246 'POST',
247 `/api/v1/delegation/proposals/${encodeURIComponent(proposalId)}/apply-approved`,
248 accessToken,
249 );
250 if (apply.status < 200 || apply.status >= 300) {
251 if (apply.status === 409 && apply.json?.idempotent === true) {
252 return { ok: true, detail: `apply idempotent for ${proposalId}`, apply: apply.json };
253 }
254 return {
255 ok: false,
256 detail: `apply-approved HTTP ${apply.status} ${apply.json?.code ?? ''}`,
257 };
258 }
259 return { ok: true, detail: `proposal ${proposalId} applied`, apply: apply.json };
260 }
261
262 /**
263 * @param {object} body
264 */
265 function consentEvidenceFromBody(body) {
266 return {
267 consent_id: body.consent_id ?? null,
268 actor_agent_id: actorId,
269 scope: body.scope ?? 'personal',
270 status: body.revoked_at ? 'revoked' : 'active',
271 created: body.created ?? null,
272 updated: body.updated ?? body.created ?? null,
273 expires_at: body.expires_at ?? null,
274 };
275 }
276
277 /**
278 * @param {string} accessToken
279 */
280 async function findActiveRetailConsent(accessToken) {
281 const list = await api('GET', '/api/v1/proposals?status=approved&limit=100', accessToken);
282 if (list.status !== 200 || !Array.isArray(list.json.proposals)) {
283 return null;
284 }
285 /** @type {{ created: string, consent: Record<string, unknown> }[]} */
286 const matches = [];
287 for (const summary of list.json.proposals) {
288 if (!summary || summary.intent !== 'delegation_consent_create') continue;
289 if (summary.vault_id && summary.vault_id !== vaultId) continue;
290 const proposalId =
291 typeof summary.proposal_id === 'string' ? summary.proposal_id : null;
292 if (!proposalId) continue;
293 const detail = await api(
294 'GET',
295 `/api/v1/proposals/${encodeURIComponent(proposalId)}`,
296 accessToken,
297 );
298 if (detail.status !== 200) continue;
299 /** @type {Record<string, unknown>} */
300 let body = {};
301 try {
302 body =
303 typeof detail.json.body === 'string'
304 ? JSON.parse(detail.json.body)
305 : detail.json.body ?? {};
306 } catch {
307 body = {};
308 }
309 if (body.delegate_agent_id !== actorId) continue;
310 if (body.revoked_at) continue;
311 matches.push({
312 created: typeof body.created === 'string' ? body.created : '',
313 consent: consentEvidenceFromBody(body),
314 });
315 }
316 if (!matches.length) return null;
317 matches.sort((a, b) => {
318 const ca = Date.parse(a.created);
319 const cb = Date.parse(b.created);
320 if (cb !== ca) return cb - ca;
321 return String(a.consent.consent_id).localeCompare(String(b.consent.consent_id));
322 });
323 return matches[0].consent;
324 }
325
326 /**
327 * @param {string} accessToken
328 */
329 async function establishPersonalConsent(accessToken) {
330 const existing = await findActiveRetailConsent(accessToken);
331 if (existing?.consent_id) {
332 return { ok: true, detail: `existing consent ${existing.consent_id}`, consent: existing, existing: true };
333 }
334
335 const propose = await api('POST', '/api/v1/delegation/consents', accessToken, {
336 delegate_agent_id: actorId,
337 scope: 'personal',
338 });
339 if (propose.status !== 201 || typeof propose.json.proposal_id !== 'string') {
340 return {
341 ok: false,
342 detail: `consent propose HTTP ${propose.status} ${propose.json?.code ?? propose.json?.error ?? ''}`,
343 };
344 }
345
346 const preview =
347 propose.json.consent_preview && typeof propose.json.consent_preview === 'object'
348 ? propose.json.consent_preview
349 : {};
350
351 const workflow = await evaluateApproveApply(accessToken, propose.json.proposal_id);
352 if (!workflow.ok) {
353 return { ok: false, detail: workflow.detail };
354 }
355
356 const record =
357 workflow.apply?.record && typeof workflow.apply.record === 'object'
358 ? workflow.apply.record
359 : preview;
360
361 return {
362 ok: true,
363 detail: `consent ${record.consent_id ?? propose.json.consent_id} active personal`,
364 consent: consentEvidenceFromBody(record),
365 };
366 }
367
368 async function main() {
369 console.log(`RHF-d catalog + consent — ${apiBase} vault=${vaultId} actor=${actorId}`);
370
371 const session = await ensureHostedSessionAccessToken();
372 if (!session.ok) {
373 record('session', false, `${session.code}: ${session.detail}`);
374 summarize(false);
375 process.exit(1);
376 }
377 record('session', true, `source=${session.source} refreshed=${session.refreshed}`);
378
379 const authProbe = await api('GET', '/api/v1/auth/session', session.accessToken);
380 if (authProbe.status !== 200) {
381 record('auth', false, `session HTTP ${authProbe.status}`);
382 summarize(false);
383 process.exit(1);
384 }
385 record('auth', true, `role=${authProbe.json.role ?? 'unknown'}`);
386
387 const catalogStep = await verifyCatalog(session.accessToken);
388 record('catalog', catalogStep.ok, catalogStep.detail);
389 if (!catalogStep.ok) {
390 summarize(false);
391 process.exit(1);
392 }
393
394 if (establishConsent) {
395 const consentStep = await establishPersonalConsent(session.accessToken);
396 record('consent', consentStep.ok, consentStep.detail);
397 if (!consentStep.ok) {
398 summarize(false);
399 process.exit(1);
400 }
401 evidence.consent = consentStep.consent;
402 } else {
403 const existing = await findActiveRetailConsent(session.accessToken);
404 if (!existing) {
405 record('consent', false, 'no active retail consent found (RHF_D_ESTABLISH_CONSENT=0)');
406 summarize(false);
407 process.exit(1);
408 }
409 evidence.consent = existing;
410 record('consent', true, `existing ${existing.consent_id}`);
411 }
412
413 const helperStep = await readHelperAccess(session.accessToken);
414 record('helper-access', helperStep.ok, helperStep.detail);
415 if (!helperStep.ok) {
416 summarize(false);
417 process.exit(1);
418 }
419
420 record('no grant mint', true, 'script did not call renew-personal or generic grant mint');
421 record('no production marker', true, 'RHF_AUTHORITY_MARKER_AUTHORIZED not activated');
422
423 summarize(true);
424 console.log('\nEvidence (redacted):');
425 console.log(JSON.stringify(evidence, null, 2));
426 process.exit(0);
427 }
428
429 function summarize(pass) {
430 const failed = results.filter((r) => !r.ok);
431 const verdict = pass && failed.length === 0 ? 'PASS' : 'FINDINGS';
432 console.log(`\nVerdict: ${verdict} (${results.length - failed.length}/${results.length} steps)`);
433 if (failed.length) {
434 console.log('Failed:', failed.map((f) => f.step).join(', '));
435 }
436 }
437
438 const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
439 if (isMain) {
440 main().catch((err) => {
441 console.error(err);
442 process.exit(1);
443 });
444 }
File History 1 commit
sha256:4215cecbbabf5591b1ff69053cc938b4b6c800f0d487be40618c1d4254d8b67b security: npm audit fix pre-bridge 2026-09-05 Human 5 days ago