From eefcb32b3db84ffb1774c6c512ba911ee33421b1 Mon Sep 17 00:00:00 2001 From: rohanharikr Date: Fri, 15 May 2026 14:50:58 +0100 Subject: [PATCH 1/5] Drop What-is-AAuth TL;DR from --log output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --log is now a pure event log: HTTP exchanges, decoded JWTs, request/response headers. Protocol explanation moves out of the CLI and into the prompt's spec-draft reference, where an AI agent can fetch it on demand. Removed from bootstrap --ps --log: - "What is AAuth?" opener prose - "Protocol parties:" block (AGENT / RESOURCE / PS / AS descriptions) - ASCII flow diagram (one-time + per-call) - "Key properties:" footer line - "You're about to run the one-time setup." closer line Removed from fetch --log: - Same TL;DR block when marker stale - Condensed 4-line flow diagram + bootstrap pointer when marker fresh Marker file (~/.aauth/.tldr-shown) logic dropped entirely — there's no TL;DR left to suppress, so the read/write/freshness-check codepath becomes dead. Removes node:os, node:path, node:fs imports from fetch/src/log.ts and node:fs/node:path imports from bootstrap/src/log.ts. Kept (these are log data, not pedagogy): - Step cards with numbered actor-prefixed titles and per-step prose - HTTP request/response data (headers, status, body) - Decoded JWT payloads with inline annotations - bootstrap Step 0 with bullet prose + identity data - fetch Already-set-up block (agent identity that will sign requests) - fetch This-call block (per-invocation params) - Closing summary line Net: bootstrap/src/log.ts −69 lines, fetch/src/log.ts −78 lines. Co-Authored-By: Claude Opus 4.7 (1M context) --- bootstrap/src/log.ts | 70 +------------------------------------- fetch/src/log.ts | 80 +------------------------------------------- 2 files changed, 2 insertions(+), 148 deletions(-) diff --git a/bootstrap/src/log.ts b/bootstrap/src/log.ts index 0819876..f32dcae 100644 --- a/bootstrap/src/log.ts +++ b/bootstrap/src/log.ts @@ -1,7 +1,3 @@ -import { homedir } from 'node:os' -import { join } from 'node:path' -import { writeFileSync, mkdirSync } from 'node:fs' - export interface BootstrapEvent { step: string phase: 'start' | 'done' | 'info' @@ -10,8 +6,6 @@ export interface BootstrapEvent { export type OnBootstrapEvent = (event: BootstrapEvent) => void -const MARKER_PATH = join(homedir(), '.aauth', '.tldr-shown') - // ── ANSI styling (TTY only, respects NO_COLOR) ──────────────────────────────── const IS_TTY = process.stderr.isTTY === true || process.env.AAUTH_FORCE_PRETTY === '1' const COLOR_ENABLED = IS_TTY && !process.env.NO_COLOR @@ -28,48 +22,6 @@ const c = { const RULE = '─'.repeat(80) const section = (title: string) => `${c.dim('─── ')}${c.bold(title)} ${c.dim(RULE.slice(title.length + 5))}` -// ── TL;DR block (shown once at the top of bootstrap --ps --log) ─────────────── -function renderTldr(): string { - return [ - section('What is AAuth?'), - '', - 'AAuth gives every agent its own cryptographic identity. The agent signs every', - 'HTTP request with a private key only it holds; resources verify the signature', - 'and decide whether to authorize. A Person Server represents the user and', - 'grants the agent permission to act on their behalf — no pre-registration, no', - 'shared secrets.', - '', - 'Protocol parties:', - '', - ` ${c.cyan('AGENT')} this CLI on your device. Identifies via an Ed25519 keypair`, - ' generated locally — the private key never leaves the OS keychain.', - ` ${c.green('RESOURCE')} the API the agent wants to call.`, - ` ${c.magenta('PERSON SERVER')} represents the user. Holds identity, decides authorization,`, - ' issues auth_tokens the resource will trust.', - ` ${c.dim('ACCESS SERVER (out of scope for this demo) policy engine that guards')}`, - ` ${c.dim('resources in federated mode.')}`, - '', - 'The user (you) approves consent in a browser the first time the PS sees', - 'this agent.', - '', - 'The flow:', - '', - ` ${c.dim('one-time')} ${c.cyan('AGENT')} generates keypair on this device`, - ` ${c.cyan('AGENT')} registers a Person Server it will delegate consent to`, - ` ${c.dim('per call')} ${c.cyan('AGENT')} ─▶ ${c.green('RESOURCE')} (401: who are you?)`, - ` ${c.cyan('AGENT')} ─▶ ${c.magenta('PERSON SERVER')} (token exchange — first time needs consent)`, - ` ${c.yellow('user')} ─▶ ${c.magenta('PERSON SERVER')} (approve in browser, first time only)`, - ` ${c.cyan('AGENT')} ─▶ ${c.green('RESOURCE')} (200: data)`, - '', - `${c.dim('Key properties: agent identity without pre-registration · proof-of-possession')}`, - `${c.dim('on every request · user consent at the Person Server, never at the resource.')}`, - '', - `${c.dim("You're about to run the one-time setup.")}`, - '', - '', - ].join('\n') -} - // ── Step 0 card builder (accumulates from events) ───────────────────────────── interface Step0State { agentUrl?: string @@ -148,16 +100,6 @@ function renderStep0(state: Step0State, hasNewKey: boolean): string { return lines.join('\n') } -// ── Marker file ────────────────────────────────────────────────────────────── -function writeTldrMarker(): void { - try { - mkdirSync(join(homedir(), '.aauth'), { recursive: true }) - writeFileSync(MARKER_PATH, new Date().toISOString(), 'utf8') - } catch { - // Non-fatal — marker is purely a UX hint - } -} - // ── Public API ──────────────────────────────────────────────────────────────── const narrations: Record string | undefined> = { @@ -203,28 +145,18 @@ export function buildLogEmitter(enabled: boolean): OnBootstrapEvent | undefined } } - // TTY: collect events, render TL;DR once, then render Step 0 on completion. - let printedTldr = false + // TTY: collect events, render Step 0 on completion. const state: Step0State = {} let hasNewKey = false let rendered = false - function maybePrintTldr() { - if (!printedTldr) { - process.stderr.write(renderTldr()) - printedTldr = true - } - } - function finalize() { if (rendered) return rendered = true process.stderr.write(renderStep0(state, hasNewKey)) - writeTldrMarker() } return (event: BootstrapEvent) => { - maybePrintTldr() switch (event.step) { case 'bootstrap_started': state.agentUrl = event.agentUrl as string | undefined diff --git a/fetch/src/log.ts b/fetch/src/log.ts index 6c977b3..41cd2d1 100644 --- a/fetch/src/log.ts +++ b/fetch/src/log.ts @@ -1,6 +1,3 @@ -import { homedir } from 'node:os' -import { join } from 'node:path' -import { readFileSync, existsSync } from 'node:fs' import type { AAuthEvent, OnEvent } from '@aauth/mcp-agent' import { readConfig, readKeychain } from '@aauth/local-keys' import { createHash } from 'node:crypto' @@ -21,78 +18,8 @@ const c = { const RULE = '─'.repeat(80) const section = (title: string) => `${c.dim('─── ')}${c.bold(title)} ${c.dim(RULE.slice(title.length + 5))}` -// ── Marker file (written by bootstrap --log, read here) ─────────────────────── -const MARKER_PATH = join(homedir(), '.aauth', '.tldr-shown') -const MARKER_FRESH_MS = 5 * 60 * 1000 - -function isMarkerFresh(): boolean { - try { - if (!existsSync(MARKER_PATH)) return false - const ts = readFileSync(MARKER_PATH, 'utf8').trim() - const when = Date.parse(ts) - if (Number.isNaN(when)) return false - return Date.now() - when < MARKER_FRESH_MS - } catch { - return false - } -} - // ── Header / preamble blocks ────────────────────────────────────────────────── -function renderTldr(): string { - return [ - section('What is AAuth?'), - '', - 'AAuth gives every agent its own cryptographic identity. The agent signs every', - 'HTTP request with a private key only it holds; resources verify the signature', - 'and decide whether to authorize. A Person Server represents the user and', - 'grants the agent permission to act on their behalf — no pre-registration, no', - 'shared secrets.', - '', - 'Protocol parties:', - '', - ` ${c.cyan('AGENT')} this CLI on your device. Identifies via an Ed25519 keypair`, - ' generated locally — the private key never leaves the OS keychain.', - ` ${c.green('RESOURCE')} the API the agent wants to call.`, - ` ${c.magenta('PERSON SERVER')} represents the user. Holds identity, decides authorization,`, - ' issues auth_tokens the resource will trust.', - ` ${c.dim('ACCESS SERVER (out of scope for this demo) policy engine that guards')}`, - ` ${c.dim('resources in federated mode.')}`, - '', - 'The user (you) approves consent in a browser the first time the PS sees', - 'this agent.', - '', - 'The flow:', - '', - ` ${c.dim('one-time')} ${c.cyan('AGENT')} generates keypair on this device`, - ` ${c.cyan('AGENT')} registers a Person Server it will delegate consent to`, - ` ${c.dim('per call')} ${c.cyan('AGENT')} ─▶ ${c.green('RESOURCE')} (401: who are you?)`, - ` ${c.cyan('AGENT')} ─▶ ${c.magenta('PERSON SERVER')} (token exchange — first time needs consent)`, - ` ${c.yellow('user')} ─▶ ${c.magenta('PERSON SERVER')} (approve in browser, first time only)`, - ` ${c.cyan('AGENT')} ─▶ ${c.green('RESOURCE')} (200: data)`, - '', - `${c.dim('Key properties: agent identity without pre-registration · proof-of-possession')}`, - `${c.dim('on every request · user consent at the Person Server, never at the resource.')}`, - '', - '', - ].join('\n') -} - -function renderCondensedFlow(): string { - return [ - `${c.bold('AAuth · per-call flow')}`, - '', - ` ${c.cyan('AGENT')} ─▶ ${c.green('RESOURCE')} (401: who are you?)`, - ` ${c.cyan('AGENT')} ─▶ ${c.magenta('PERSON SERVER')} (token exchange — needs consent)`, - ` ${c.yellow('user')} ─▶ ${c.magenta('PERSON SERVER')} (approve in browser)`, - ` ${c.cyan('AGENT')} ─▶ ${c.green('RESOURCE')} (200: data)`, - '', - c.dim('(Actors and one-time setup: see `npx @aauth/bootstrap --ps --log`.)'), - '', - '', - ].join('\n') -} - function computeJkt(jwk: Record): string { const kty = jwk.kty as string const crv = jwk.crv as string @@ -573,12 +500,7 @@ export function buildLogEmitter( function printPreamble() { if (preamblePrinted) return preamblePrinted = true - if (isMarkerFresh()) { - process.stderr.write(renderCondensedFlow()) - } else { - process.stderr.write(renderTldr()) - process.stderr.write(renderAlreadySetUp(context.agentUrl)) - } + process.stderr.write(renderAlreadySetUp(context.agentUrl)) if (context.url) { process.stderr.write(renderThisCall(context.url, context.agentUrl, context.personServer)) } From 6f89be2276932c0fb31f5e2d2188c044bec9fe6a Mon Sep 17 00:00:00 2001 From: rohanharikr Date: Fri, 15 May 2026 15:06:15 +0100 Subject: [PATCH 2/5] =?UTF-8?q?Drop=20dim=20styling=20from=20response-stat?= =?UTF-8?q?us=20=E2=86=90=20arrow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ← preceding `HTTP/1.1 200 OK` (and similar) on every response line was wrapped in c.dim(), making it visibly fainter than the rest of the status line. Strip the dim wrapper so the arrow renders in the terminal's default colour, matching the surrounding text. Two call sites: - fetch/src/log.ts statusLine() — used by every step card's response - bootstrap/src/log.ts renderStep0() — PS metadata response Other arrows unchanged: step-header → between actors stays plain, the consent-prompt → Open stays bold, inline annotation arrows (← same key as Step 1) stay dim because the whole annotation reads as a footnote. Co-Authored-By: Claude Opus 4.7 (1M context) --- bootstrap/src/log.ts | 2 +- fetch/src/log.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bootstrap/src/log.ts b/bootstrap/src/log.ts index f32dcae..de31ec0 100644 --- a/bootstrap/src/log.ts +++ b/bootstrap/src/log.ts @@ -84,7 +84,7 @@ function renderStep0(state: Step0State, hasNewKey: boolean): string { lines.push(` ${c.bold('Host:')} ${url.host}`) lines.push('') const statusColor = state.metadataStatus && state.metadataStatus < 300 ? c.green : c.red - lines.push(` ${c.dim('←')} HTTP/1.1 ${statusColor(String(state.metadataStatus ?? '?'))} ${state.metadataStatus === 200 ? 'OK' : ''}`) + lines.push(` ← HTTP/1.1 ${statusColor(String(state.metadataStatus ?? '?'))} ${state.metadataStatus === 200 ? 'OK' : ''}`) lines.push(` ${c.bold('Content-Type:')} application/json`) if (state.metadataBody) { const body = JSON.stringify(state.metadataBody, null, 2).split('\n').map(l => ` ${l}`).join('\n') diff --git a/fetch/src/log.ts b/fetch/src/log.ts index 41cd2d1..04ad18b 100644 --- a/fetch/src/log.ts +++ b/fetch/src/log.ts @@ -138,7 +138,7 @@ function stepHeader(n: number, from: Actor, to: Actor, subtitle: string): string function statusLine(status: number): string { const tint = status >= 200 && status < 300 ? c.green : status >= 400 ? c.red : c.yellow const label = STATUS_LABELS[status] ?? '' - return ` ${c.dim('←')} HTTP/1.1 ${tint(String(status))} ${tint(label)}` + return ` ← HTTP/1.1 ${tint(String(status))} ${tint(label)}` } const STATUS_LABELS: Record = { From f874865b9f159b5f7ee8e814ededd57d8bd87ee9 Mon Sep 17 00:00:00 2001 From: rohanharikr Date: Mon, 18 May 2026 23:19:46 +0100 Subject: [PATCH 3/5] Capture signed request data and surface it on --log events Events emitted by mcp-agent now carry the real on-the-wire signed request (Signature, Signature-Input, Signature-Key with full JWT, Content-Type, Content-Digest, Prefer, Aauth-Capabilities, etc.) plus request body for POSTs and response body for the 401 challenge / 202 deferred / PS metadata steps. Previously consumers had to fabricate these values or rely on hardcoded placeholders. How it works: - signed-fetch.ts: createSignedFetch gains an optional `onSigned: (sent: CapturedSent) => void` callback. When provided, it calls @hellocoop/httpsig with returnSent: true, fires the callback synchronously with the captured request, returns just the Response (so existing return-type expectations are unchanged). - aauth-fetch.ts/token-exchange.ts/deferred.ts: a shared `sentTracker: { latest: CapturedSent | undefined }` is threaded through. onSigned updates `sentTracker.latest`. Each emitting site reads `sentTracker.latest?.headers` immediately after the awaited signedFetch() returns (onSigned fires synchronously inside it, so no race) and attaches to the next emitted :done event. - log-helpers.ts: captureSentFromHttpsig() converts httpsig's Headers-object SentRequest to a JSON-serialisable CapturedSent with plain Record headers. peekResponseBody() clones the Response so the body can be read for logging without consuming it for downstream code. - types.ts: AAuthEvent gains optional request_headers, request_body fields. New CapturedSent type. Response bodies are peeked (via Response.clone().text()) for the 401 challenge in aauth-fetch.ts, the 202 deferred response and PS metadata in token-exchange.ts. Bodies for 200 responses aren't peeked because downstream code consumes them via .json() and we'd rather not double-buffer. Co-Authored-By: Claude Opus 4.7 (1M context) --- mcp-agent/src/aauth-fetch.ts | 58 ++++++++++++++++++++++++++------- mcp-agent/src/deferred.ts | 6 +++- mcp-agent/src/log-helpers.ts | 32 +++++++++++++++++- mcp-agent/src/signed-fetch.ts | 57 +++++++++++++++++++++++++++++--- mcp-agent/src/token-exchange.ts | 35 ++++++++++++++++---- mcp-agent/src/types.ts | 20 ++++++++++++ 6 files changed, 184 insertions(+), 24 deletions(-) diff --git a/mcp-agent/src/aauth-fetch.ts b/mcp-agent/src/aauth-fetch.ts index ea1942c..2114412 100644 --- a/mcp-agent/src/aauth-fetch.ts +++ b/mcp-agent/src/aauth-fetch.ts @@ -4,8 +4,8 @@ import { parseAAuthHeader, buildCapabilitiesHeader, buildMissionHeader } from '. import { exchangeToken } from './token-exchange.js' import { pollDeferred } from './deferred.js' import { decodeJwtPayload } from './decode-jwt.js' -import { summarizeResponseHeaders, decodeSignatureKey } from './log-helpers.js' -import type { GetKeyMaterial, FetchLike, OnEvent } from './types.js' +import { summarizeResponseHeaders, decodeSignatureKey, captureSentFromHttpsig, peekResponseBody } from './log-helpers.js' +import type { GetKeyMaterial, FetchLike, OnEvent, CapturedSent } from './types.js' import type { Capability, AAuthMission } from './aauth-header.js' export interface AAuthFetchOptions { @@ -57,7 +57,15 @@ export function createAAuthFetch(options: AAuthFetchOptions): FetchLike { prompt, } = options - const signedFetch = createSignedFetch(getKeyMaterial, { capabilities, mission }) + // Shared mutable holder for the latest signed-request capture. signed-fetch + // and the fetchWith* helpers populate `.latest` via onSigned; the next + // emitted :done event (here, in token-exchange, or in deferred) reads it. + // Wide explicit type so TS doesn't narrow `.latest` to never after we set + // it to undefined before an awaited call that mutates it via callback. + const sentTracker: { latest: CapturedSent | undefined } = { latest: undefined } + const onSigned = onEvent ? (sent: CapturedSent) => { sentTracker.latest = sent } : undefined + + const signedFetch = createSignedFetch(getKeyMaterial, { capabilities, mission, onSigned }) const tokenCache = new Map() const accessCache = new Map() @@ -69,7 +77,7 @@ export function createAAuthFetch(options: AAuthFetchOptions): FetchLike { const cached = findCachedToken(tokenCache, resourceOrigin) if (cached) { // Use cached auth token — sign with auth token instead of agent token - const response = await fetchWithAuthToken(url, init, cached.authToken, getKeyMaterial) + const response = await fetchWithAuthToken(url, init, cached.authToken, getKeyMaterial, onSigned) // If the cached token is rejected, fall through to challenge flow if (response.status !== 401) { cacheAccessToken(accessCache, resourceOrigin, response) @@ -82,7 +90,7 @@ export function createAAuthFetch(options: AAuthFetchOptions): FetchLike { // Check cache for an opaque AAuth-Access token (two-party mode) const cachedAccess = accessCache.get(resourceOrigin) if (cachedAccess) { - const response = await fetchWithAccessToken(url, init, cachedAccess.token, getKeyMaterial) + const response = await fetchWithAccessToken(url, init, cachedAccess.token, getKeyMaterial, onSigned) if (response.status !== 401) { cacheAccessToken(accessCache, resourceOrigin, response) return handleResourceInteraction(response, signedFetch, onInteraction, onClarification) @@ -103,11 +111,16 @@ export function createAAuthFetch(options: AAuthFetchOptions): FetchLike { }) } const response = await signedFetch(url, init) + const responseBody = response.status === 401 ? await peekResponseBody(response) : undefined onEvent?.({ step: 'signed_request', phase: 'done', status: response.status, - response: { headers: summarizeResponseHeaders(response.headers) }, + request_headers: sentTracker.latest?.headers, + response: { + headers: summarizeResponseHeaders(response.headers), + ...(responseBody !== undefined ? { body: responseBody } : {}), + }, }) // 200: success — check for AAuth-Access token @@ -152,6 +165,7 @@ export function createAAuthFetch(options: AAuthFetchOptions): FetchLike { onClarification, onEvent, getKeyMaterial, + sentTracker, }) // Cache the auth token @@ -170,12 +184,13 @@ export function createAAuthFetch(options: AAuthFetchOptions): FetchLike { auth_token: decodeJwtPayload(result.authToken), }) const retryResponse = await fetchWithAuthToken( - url, init, result.authToken, getKeyMaterial, + url, init, result.authToken, getKeyMaterial, onSigned, ) onEvent?.({ step: 'retry_with_auth_token', phase: 'done', status: retryResponse.status, + request_headers: sentTracker.latest?.headers, response: { headers: summarizeResponseHeaders(retryResponse.headers) }, }) cacheAccessToken(accessCache, resourceOrigin, retryResponse) @@ -247,14 +262,24 @@ async function fetchWithAuthToken( init: RequestInit | undefined, authToken: string, getKeyMaterial: GetKeyMaterial, + onSigned?: (sent: CapturedSent) => void, ): Promise { const { signingKey } = await getKeyMaterial() - const response = await httpSigFetch(url, { + if (onSigned) { + const { response, sent } = await httpSigFetch(url, { + ...init, + signingKey, + signatureKey: { type: 'jwt', jwt: authToken }, + returnSent: true, + }) + onSigned(captureSentFromHttpsig(sent)) + return response + } + return await httpSigFetch(url, { ...init, signingKey, signatureKey: { type: 'jwt', jwt: authToken }, }) - return response as Response } /** @@ -266,6 +291,7 @@ async function fetchWithAccessToken( init: RequestInit | undefined, accessToken: string, getKeyMaterial: GetKeyMaterial, + onSigned?: (sent: CapturedSent) => void, ): Promise { const { signingKey, signatureKey } = await getKeyMaterial() const headers = new Headers(init?.headers) @@ -273,13 +299,23 @@ async function fetchWithAccessToken( const httpSigKey = signatureKey.type === 'jkt-jwt' ? { type: 'jwt' as const, jwt: signatureKey.jwt } : signatureKey - const response = await httpSigFetch(url, { + if (onSigned) { + const { response, sent } = await httpSigFetch(url, { + ...init, + headers, + signingKey, + signatureKey: httpSigKey, + returnSent: true, + }) + onSigned(captureSentFromHttpsig(sent)) + return response + } + return await httpSigFetch(url, { ...init, headers, signingKey, signatureKey: httpSigKey, }) - return response as Response } /** diff --git a/mcp-agent/src/deferred.ts b/mcp-agent/src/deferred.ts index f3895d1..e127d3f 100644 --- a/mcp-agent/src/deferred.ts +++ b/mcp-agent/src/deferred.ts @@ -1,6 +1,6 @@ import { parseAAuthHeader } from './aauth-header.js' import { summarizeResponseHeaders } from './log-helpers.js' -import type { FetchLike, OnEvent } from './types.js' +import type { FetchLike, OnEvent, CapturedSent } from './types.js' export interface DeferredOptions { signedFetch: FetchLike @@ -11,6 +11,8 @@ export interface DeferredOptions { onClarification?: (question: string) => Promise onEvent?: OnEvent maxPollDuration?: number // total timeout in seconds, default 300 + /** Shared sent-request tracker; see TokenExchangeOptions. */ + sentTracker?: { latest?: CapturedSent } } export interface AAuthError { @@ -46,6 +48,7 @@ export async function pollDeferred(options: DeferredOptions): Promise = {} + sent.headers.forEach((value, key) => { headers[key] = value }) + return { + method: sent.method, + url: sent.url, + headers, + body: typeof sent.body === 'string' ? sent.body : undefined, + } +} + +/** + * Read a Response's body as text without consuming it for downstream + * consumers. Uses Response.clone() so the caller can still read body + * afterwards. Returns undefined if the body can't be read as text. + */ +export async function peekResponseBody(response: Response): Promise { + try { + return await response.clone().text() + } catch { + return undefined + } +} diff --git a/mcp-agent/src/signed-fetch.ts b/mcp-agent/src/signed-fetch.ts index 008d4a2..0c43a78 100644 --- a/mcp-agent/src/signed-fetch.ts +++ b/mcp-agent/src/signed-fetch.ts @@ -1,11 +1,37 @@ import { fetch as httpSigFetch } from '@hellocoop/httpsig' +import type { SentRequest } from '@hellocoop/httpsig' import { buildCapabilitiesHeader, buildMissionHeader } from './aauth-header.js' -import type { GetKeyMaterial, FetchLike } from './types.js' +import type { GetKeyMaterial, FetchLike, CapturedSent } from './types.js' import type { Capability, AAuthMission } from './aauth-header.js' export interface SignedFetchOptions { capabilities?: Capability[] mission?: AAuthMission + /** + * Called synchronously after each signed request returns, with the actual + * on-the-wire headers + body. Used by the AAuth flow to capture the + * signed request data for --log rendering. + */ + onSigned?: (sent: CapturedSent) => void +} + +function headersToRecord(headers: Headers): Record { + const out: Record = {} + headers.forEach((value, key) => { out[key] = value }) + return out +} + +function captureSent(sent: SentRequest): CapturedSent { + let body: string | undefined + if (typeof sent.body === 'string') { + body = sent.body + } + return { + method: sent.method, + url: sent.url, + headers: headersToRecord(sent.headers), + body, + } } export function createSignedFetch(getKeyMaterial: GetKeyMaterial, options?: SignedFetchOptions): FetchLike { @@ -18,6 +44,8 @@ export function createSignedFetch(getKeyMaterial: GetKeyMaterial, options?: Sign ? { type: 'jwt' as const, jwt: signatureKey.jwt } : signatureKey + const wantSent = !!options?.onSigned + if (hasExtraHeaders) { const headers = new Headers(init?.headers) if (options?.capabilities?.length) { @@ -26,20 +54,39 @@ export function createSignedFetch(getKeyMaterial: GetKeyMaterial, options?: Sign if (options?.mission) { headers.set('aauth-mission', buildMissionHeader(options.mission)) } - const response = await httpSigFetch(url, { + if (wantSent) { + const { response, sent } = await httpSigFetch(url, { + ...init, + headers, + signingKey, + signatureKey: httpSigKey, + returnSent: true, + }) + options!.onSigned!(captureSent(sent)) + return response + } + return await httpSigFetch(url, { ...init, headers, signingKey, signatureKey: httpSigKey, }) - return response as Response } - const response = await httpSigFetch(url, { + if (wantSent) { + const { response, sent } = await httpSigFetch(url, { + ...init, + signingKey, + signatureKey: httpSigKey, + returnSent: true, + }) + options!.onSigned!(captureSent(sent)) + return response + } + return await httpSigFetch(url, { ...init, signingKey, signatureKey: httpSigKey, }) - return response as Response } } diff --git a/mcp-agent/src/token-exchange.ts b/mcp-agent/src/token-exchange.ts index 3efaaa9..0957d42 100644 --- a/mcp-agent/src/token-exchange.ts +++ b/mcp-agent/src/token-exchange.ts @@ -1,9 +1,9 @@ -import type { FetchLike, GetKeyMaterial, OnEvent } from './types.js' +import type { FetchLike, GetKeyMaterial, OnEvent, CapturedSent } from './types.js' import { pollDeferred } from './deferred.js' import type { AAuthError } from './deferred.js' import { parseAAuthHeader } from './aauth-header.js' import { decodeJwtPayload } from './decode-jwt.js' -import { summarizeResponseHeaders, decodeSignatureKey } from './log-helpers.js' +import { summarizeResponseHeaders, decodeSignatureKey, peekResponseBody } from './log-helpers.js' export class TokenExchangeError extends Error { constructor( @@ -38,6 +38,12 @@ export interface TokenExchangeOptions { * outgoing-token visibility under --log. */ getKeyMaterial?: GetKeyMaterial + /** + * Optional: mutable holder that signed-fetch's onSigned callback updates + * after each signed request. exchangeToken reads `.latest` after its own + * signedFetch calls to attach request_headers to :done events. + */ + sentTracker?: { latest?: CapturedSent } } export interface TokenExchangeResult { @@ -74,10 +80,11 @@ export async function exchangeToken(options: TokenExchangeOptions): Promise { const metadataUrl = `${authServerUrl.replace(/\/$/, '')}/.well-known/aauth-person.json` if (onEvent) { @@ -192,11 +209,17 @@ async function fetchMetadata( onEvent({ step: 'ps_metadata_request', phase: 'start', url: metadataUrl, agent_token: agentToken }) } const response = await signedFetch(metadataUrl, { method: 'GET' }) + // Peek body so the rendered card can show the discovered endpoints. + const responseBody = response.ok ? await peekResponseBody(response) : undefined onEvent?.({ step: 'ps_metadata_request', phase: 'done', status: response.status, - response: { headers: summarizeResponseHeaders(response.headers) }, + request_headers: sentTracker?.latest?.headers, + response: { + headers: summarizeResponseHeaders(response.headers), + ...(responseBody !== undefined ? { body: responseBody } : {}), + }, }) if (!response.ok) { diff --git a/mcp-agent/src/types.ts b/mcp-agent/src/types.ts index d5f8677..cbcf326 100644 --- a/mcp-agent/src/types.ts +++ b/mcp-agent/src/types.ts @@ -24,7 +24,27 @@ export type FetchLike = (url: string | URL, init?: RequestInit) => Promise + /** Raw request body (POST/PUT bodies). String form for readability. */ + request_body?: string [key: string]: unknown } export type OnEvent = (event: AAuthEvent) => void + +/** + * The signed request that was sent. Mirrors @hellocoop/httpsig's SentRequest + * but uses a plain Record for headers so it survives JSON serialisation. + */ +export interface CapturedSent { + method: string + url: string + headers: Record + body?: string +} From 54c629d47fc4e6a31e84adb385ce683ed4726b7d Mon Sep 17 00:00:00 2001 From: rohanharikr Date: Mon, 18 May 2026 23:19:54 +0100 Subject: [PATCH 4/5] Split --log / --jsonl, render real headers, attach prose to events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related changes to the fetch + bootstrap --log UX. 1. Explicit format flags replace TTY auto-detect - --log: always pretty narrative on stderr - --jsonl (alias --ndjson): always one JSON object per line on stderr - both: error ("--log and --jsonl are mutually exclusive (same events, different formats). Pick one.") - neither: silent buildLogEmitter now takes an explicit `LogMode` enum instead of a boolean + TTY check. AAUTH_FORCE_PRETTY env-var override is no longer needed (kept for now in case existing tests use it). 2. Render real signed headers, with shared prose fetch/src/log.ts renderRequestHeaders() now takes a real Record of captured headers (from the mcp-agent event's request_headers field) instead of synthesising `created=`, `sig=::`, etc. Headers render in canonical order (Content-Type, Content-Digest, Prefer, Signature-Input, Signature, Signature-Key) with display truncation for the long base64 values: signature → `=:…:`, signature-key → `jwt="…"`. POST body (e.g. the token-endpoint request body) is JSON-pretty-printed inline with long JWT-shaped string values truncated. The hardcoded per-step prose paragraphs in renderColdFlow used to live as duplicated `out.push(...)` lines. They are now a single `descriptions` map keyed by step+phase+status (e.g., ps_token_request:done branches on 200 for warm-path vs 202 for cold-path narration). formatNdjson attaches the same prose under a `description` field on each JSON event, so --jsonl consumers (AI agents, log shippers) get the same paragraph --log shows. The pretty renderer uses a `describe()` lookup + `wrap()` helper to word-wrap to terminal width. Same descriptions-map pattern is applied symmetrically to bootstrap. Additional UX cleanup: - Step 4 (POST to PS) now shows the agent_token decoded block again (was previously implicit "see Step 1"). Step 6/8 (retry) shows the auth_token decoded block again. Cross-reference annotations (`← same key as Step 1`, `← the auth_token from the previous step`) are gone — repetition is fine when the reader can compare values side-by-side themselves. bootstrap mirrors the same pattern: pickLogMode helper, descriptions map for the three prose'd events (key_info, key_generation, ps_metadata_request, bootstrap_complete), formatNdjson attaches description, renderStep0 reads via describe() with bulletWrap(). Co-Authored-By: Claude Opus 4.7 (1M context) --- bootstrap/src/cli.ts | 28 +++- bootstrap/src/log.ts | 85 +++++++++--- fetch/src/args.ts | 18 ++- fetch/src/handlers.ts | 26 ++-- fetch/src/log.ts | 310 +++++++++++++++++++++++++++++++----------- 5 files changed, 355 insertions(+), 112 deletions(-) diff --git a/bootstrap/src/cli.ts b/bootstrap/src/cli.ts index 681cfc7..4d8805d 100644 --- a/bootstrap/src/cli.ts +++ b/bootstrap/src/cli.ts @@ -24,7 +24,20 @@ import { import type { KeyAlgorithm, KeyBackend, AAuthPublicJwk } from '@aauth/local-keys' import { listSkills, getSkill } from './skills.js' import { bootstrapWithPS } from './bootstrap-ps.js' -import { buildLogEmitter } from './log.js' +import { buildLogEmitter, type LogMode } from './log.js' + +// --log → pretty narrative; --jsonl (alias --ndjson) → NDJSON; neither → silent. +function pickLogMode(flags: Record): LogMode | undefined { + const log = flags.log === 'true' + const jsonl = flags.jsonl === 'true' || flags.ndjson === 'true' + if (log && jsonl) { + console.error(JSON.stringify({ error: '--log and --jsonl are mutually exclusive (same events, different formats). Pick one.' })) + process.exit(1) + } + if (log) return 'pretty' + if (jsonl) return 'jsonl' + return undefined +} import { createHash } from 'node:crypto' import { createRequire } from 'node:module' @@ -64,7 +77,7 @@ function parseArgs(args: string[]) { // === Commands === function cmdDiscover(flags: Record) { - const onEvent = buildLogEmitter(flags.log === 'true') + const onEvent = buildLogEmitter(pickLogMode(flags)) onEvent?.({ step: 'backend_discovery', phase: 'start' }) const backends = discoverBackends() onEvent?.({ step: 'backend_discovery', phase: 'done', backends: backends.map(b => b.backend) }) @@ -76,7 +89,7 @@ async function cmdGenerate(flags: Record) { const algorithm = (flags.algorithm || (backend === 'software' ? 'EdDSA' : 'ES256')) as KeyAlgorithm const agentUrl = flags.agent const kid = generateKid() - const onEvent = buildLogEmitter(flags.log === 'true') + const onEvent = buildLogEmitter(pickLogMode(flags)) const driver = getBackend(backend) const deviceLabel = driver.getDeviceLabel() @@ -135,7 +148,7 @@ async function cmdGenerate(flags: Record) { async function cmdSignToken(flags: Record) { const agentUrl = flags.agent const lifetime = parseInt(flags.lifetime || '3600', 10) - const onEvent = buildLogEmitter(flags.log === 'true') + const onEvent = buildLogEmitter(pickLogMode(flags)) if (!agentUrl) { console.error(JSON.stringify({ error: '--agent required' })) @@ -241,7 +254,7 @@ function cmdConfig() { } function cmdShow(flags: Record = {}) { - const onEvent = buildLogEmitter(flags.log === 'true') + const onEvent = buildLogEmitter(pickLogMode(flags)) console.log('@aauth/bootstrap — set up an agent identity for AAuth') console.log('') @@ -395,8 +408,9 @@ async function runBootstrapPS(flags: Record) { } } - const logEnabled = flags.log === 'true' - const onEvent = buildLogEmitter(logEnabled) + const logMode = pickLogMode(flags) + const onEvent = buildLogEmitter(logMode) + const logEnabled = logMode !== undefined if (logEnabled) { onEvent?.({ step: 'bootstrap_started', phase: 'info', agentUrl, personServerUrl }) diff --git a/bootstrap/src/log.ts b/bootstrap/src/log.ts index de31ec0..72faa92 100644 --- a/bootstrap/src/log.ts +++ b/bootstrap/src/log.ts @@ -56,8 +56,7 @@ function renderStep0(state: Step0State, hasNewKey: boolean): string { // Sub-bullet 1: keypair if (hasNewKey && state.publicJwk && state.kid) { - lines.push(' • Generate Ed25519 keypair on this device — the private key stays in the OS') - lines.push(' keychain and never leaves. The public key thumbprint is the agent\'s identity.') + lines.push(...bulletWrap(describe('key_generation', 'start', { algorithm: state.algorithm ?? 'Ed25519' }) ?? '')) lines.push('') if (state.agentUrl) lines.push(` ${c.bold('agent')} ${state.agentUrl}`) if (state.kid) lines.push(` ${c.bold('kid')} ${state.kid}`) @@ -65,8 +64,7 @@ function renderStep0(state: Step0State, hasNewKey: boolean): string { if (state.jkt) lines.push(` ${c.bold('jkt')} ${state.jkt}`) lines.push('') } else if (state.agentUrl) { - lines.push(' • Use the existing keypair on this device — no new key generated.') - lines.push(' The public key thumbprint below is this agent\'s identity.') + lines.push(...bulletWrap(describe('key_info', 'info') ?? '')) lines.push('') lines.push(` ${c.bold('agent')} ${state.agentUrl}`) if (state.kid) lines.push(` ${c.bold('kid')} ${state.kid} ${c.dim('(current)')}`) @@ -77,7 +75,7 @@ function renderStep0(state: Step0State, hasNewKey: boolean): string { // Sub-bullet 2: PS metadata if (state.metadataUrl) { - lines.push(' • Fetch Person Server metadata to confirm it\'s reachable and well-formed.') + lines.push(...bulletWrap(describe('ps_metadata_request', 'start') ?? '')) lines.push('') const url = new URL(state.metadataUrl) lines.push(` ${c.bold('GET')} ${url.pathname} HTTP/1.1`) @@ -94,7 +92,11 @@ function renderStep0(state: Step0State, hasNewKey: boolean): string { } if (state.personServerUrl) { - lines.push(` ${c.green('✓')} Bootstrap complete. The agent will bind to a user on its first authorized request.`) + const desc = describe('bootstrap_complete', 'info') ?? '' + const wrapped = wrap(desc, 76) + wrapped.forEach((line, i) => { + lines.push(i === 0 ? ` ${c.green('✓')} ${line}` : ` ${line}`) + }) lines.push('') } return lines.join('\n') @@ -102,7 +104,9 @@ function renderStep0(state: Step0State, hasNewKey: boolean): string { // ── Public API ──────────────────────────────────────────────────────────────── -const narrations: Record string | undefined> = { +type EventDescriber = (e: BootstrapEvent) => string | undefined + +const narrations: Record = { backend_discovery: (e) => e.phase === 'start' ? 'Discovering available key backends on this machine' : `Found ${(e.backends as unknown[] | undefined)?.length ?? 0} backend(s)`, @@ -119,27 +123,72 @@ const narrations: Record string | undefined> = { sign_token: (e) => e.phase === 'start' ? `Signing agent_token` : 'Agent token signed', } +// Long-form per-step prose explaining what's happening at the protocol level. +// Single source of truth for both pretty (renderStep0) and JSON (--jsonl) +// consumers — same map approach as fetch/src/log.ts uses for the per-call flow. +const descriptions: Record = { + key_info: () => + "Use the existing keypair on this device — no new key generated. The public key thumbprint below is this agent's identity.", + key_generation: (e) => e.phase === 'start' + ? `Generate ${e.algorithm ?? 'Ed25519'} keypair on this device — the private key stays in the OS keychain and never leaves. The public key thumbprint is the agent's identity.` + : undefined, + ps_metadata_request: (e) => e.phase === 'start' + ? "Fetch Person Server metadata to confirm it's reachable and well-formed." + : undefined, + bootstrap_complete: () => + "Bootstrap complete. The agent will bind to a user on its first authorized request.", +} + function formatNdjson(event: BootstrapEvent): string { const narration = narrations[event.step]?.(event) - const line = narration ? { ...event, narration } : event + const description = descriptions[event.step]?.(event) + const line: Record = { ...event } + if (narration) line.narration = narration + if (description) line.description = description return JSON.stringify(line) + '\n' } +// Word-wrap a paragraph at `width` columns for terminal rendering. +function wrap(text: string, width = 78): string[] { + const words = text.split(/\s+/).filter(Boolean) + const lines: string[] = [] + let cur = '' + for (const w of words) { + if (cur.length === 0) cur = w + else if (cur.length + 1 + w.length <= width) cur += ' ' + w + else { lines.push(cur); cur = w } + } + if (cur) lines.push(cur) + return lines +} + +// Render a paragraph as a bullet with hanging-indent continuation lines. +// " • first line of paragraph..." +// " continuation..." +function bulletWrap(text: string, width = 76): string[] { + return wrap(text, width).map((line, i) => i === 0 ? ` • ${line}` : ` ${line}`) +} + +// Look up a description for a synthetic event shape — used by renderStep0 +// so both pretty and JSON consumers read from the same map. +function describe(step: string, phase: 'start' | 'done' | 'info', extra?: Record): string | undefined { + const e: BootstrapEvent = { step, phase, ...(extra ?? {}) } + return descriptions[step]?.(e) +} + /** * Build a stream-aware bootstrap event handler. * - * When stderr is a TTY: prints TL;DR + Step 0 grouped card, then writes a - * marker file so a subsequent `fetch --log` can suppress its own TL;DR. - * - * When stderr is piped: emits NDJSON (one line per event) as before. + * mode='pretty': prints Step 0 grouped card on stderr. + * mode='jsonl': emits each event as one JSON object per line on stderr. + * mode=undefined: returns undefined (no logging). */ -export function buildLogEmitter(enabled: boolean): OnBootstrapEvent | undefined { - if (!enabled) return undefined +export type LogMode = 'pretty' | 'jsonl' - const pretty = IS_TTY +export function buildLogEmitter(mode: LogMode | undefined): OnBootstrapEvent | undefined { + if (!mode) return undefined - if (!pretty) { - // Piped — keep NDJSON shape for programmatic consumers. + if (mode === 'jsonl') { return (event: BootstrapEvent) => { process.stderr.write(formatNdjson(event)) } @@ -202,5 +251,5 @@ export function buildLogEmitter(enabled: boolean): OnBootstrapEvent | undefined export function logEvent(enabled: boolean, event: BootstrapEvent): void { if (!enabled) return - buildLogEmitter(true)?.(event) + buildLogEmitter('jsonl')?.(event) } diff --git a/fetch/src/args.ts b/fetch/src/args.ts index 91a9fac..6b69318 100644 --- a/fetch/src/args.ts +++ b/fetch/src/args.ts @@ -40,6 +40,7 @@ export interface FetchArgs { verbose: boolean debug: boolean log: boolean + jsonl: boolean } function usage(): never { @@ -88,7 +89,10 @@ Interaction: Output: -v, --verbose Show headers + status on stderr --debug Show all requests/responses with headers on stderr - --log Narrate each AAuth protocol step on stderr (JSONL) + --log Narrate each AAuth protocol step on stderr (human-readable) + --jsonl, --ndjson Emit each AAuth protocol step on stderr as one JSON object + per line. Each event carries 'narration' (one-line) and + 'description' (paragraph) fields. Mutually exclusive with --log. `) process.exit(1) } @@ -109,6 +113,7 @@ export function parseArgs(argv: string[]): FetchArgs { verbose: false, debug: false, log: false, + jsonl: false, } for (let i = 0; i < args.length; i++) { @@ -215,6 +220,10 @@ export function parseArgs(argv: string[]): FetchArgs { case '--log': result.log = true break + case '--jsonl': + case '--ndjson': + result.jsonl = true + break default: if (args[i].startsWith('-')) { @@ -234,5 +243,12 @@ export function parseArgs(argv: string[]): FetchArgs { result.signingKey = result.signingKey ?? process.env.AAUTH_SIGNING_KEY result.personServer = result.personServer ?? process.env.AAUTH_PERSON_SERVER + // --log and --jsonl are mutually exclusive — they emit the same protocol + // events in different formats. Pick one. + if (result.log && result.jsonl) { + console.error(JSON.stringify({ error: '--log and --jsonl are mutually exclusive (same events, different formats). Pick one.' })) + process.exit(1) + } + return result } diff --git a/fetch/src/handlers.ts b/fetch/src/handlers.ts index d477f2a..76b6301 100644 --- a/fetch/src/handlers.ts +++ b/fetch/src/handlers.ts @@ -9,7 +9,15 @@ import { } from '@aauth/mcp-agent' import type { GetKeyMaterial, KeyMaterial, FetchLike, Capability, OnEvent } from '@aauth/mcp-agent' import open from 'open' -import { buildLogEmitter } from './log.js' +import { buildLogEmitter, type LogMode } from './log.js' + +// --log → pretty narrative; --jsonl → NDJSON; neither → silent. The args +// parser already enforces mutual exclusion between the two. +function pickLogMode(args: { log?: boolean; jsonl?: boolean }): LogMode | undefined { + if (args.log) return 'pretty' + if (args.jsonl) return 'jsonl' + return undefined +} /** * Filter response headers to AAuth-relevant set for --log events. Mirrors the @@ -105,7 +113,7 @@ export function buildRequestInit(args: { method: string; data?: string; headers: export async function handleAuthorize( args: { url: string; agentUrl?: string; operations?: string; scope?: string; - browser?: boolean; nonInteractive: boolean; verbose: boolean; debug?: boolean; log?: boolean; + browser?: boolean; nonInteractive: boolean; verbose: boolean; debug?: boolean; log?: boolean; jsonl?: boolean; loginHint?: string; domainHint?: string; tenant?: string; justification?: string; capabilities?: string[]; forceConsent?: boolean; }, @@ -114,7 +122,7 @@ export async function handleAuthorize( ): Promise { const shouldOpenBrowser = args.browser ?? true const capabilities = args.capabilities as Capability[] | undefined - const log = buildLogEmitter(args.log ?? false, { url: args.url, agentUrl: args.agentUrl, personServer }) + const log = buildLogEmitter(pickLogMode(args), { url: args.url, agentUrl: args.agentUrl, personServer }) const onEvent: OnEvent | undefined = log?.onEvent const keyMaterial = await getKeyMaterial() @@ -289,7 +297,7 @@ export async function handleAuthorize( * Pre-authed mode: use provided auth token + signing key. */ export async function handlePreAuthed( - args: { url: string; method: string; authToken: string; signingKey: string; verbose: boolean; log?: boolean; data?: string; headers: string[] }, + args: { url: string; method: string; authToken: string; signingKey: string; verbose: boolean; log?: boolean; jsonl?: boolean; data?: string; headers: string[] }, init: RequestInit, ): Promise { let signingKey: JsonWebKey @@ -301,7 +309,7 @@ export async function handlePreAuthed( return } - const log = buildLogEmitter(args.log ?? false, { url: args.url }) + const log = buildLogEmitter(pickLogMode(args), { url: args.url }) const onEvent: OnEvent | undefined = log?.onEvent const getKeyMaterial: GetKeyMaterial = async () => ({ signingKey, @@ -333,11 +341,11 @@ export async function handlePreAuthed( * --agent-only mode: sign with agent token, don't handle 401. */ export async function handleAgentOnly( - args: { url: string; verbose: boolean; log?: boolean }, + args: { url: string; verbose: boolean; log?: boolean; jsonl?: boolean }, init: RequestInit, getKeyMaterial: GetKeyMaterial, ): Promise { - const log = buildLogEmitter(args.log ?? false, { url: args.url }) + const log = buildLogEmitter(pickLogMode(args), { url: args.url }) const onEvent: OnEvent | undefined = log?.onEvent const signedFetch = createSignedFetch(getKeyMaterial) @@ -368,7 +376,7 @@ export async function handleAgentOnly( */ export async function handleFullFlow( args: { - url: string; agentUrl?: string; browser?: boolean; nonInteractive: boolean; verbose: boolean; debug?: boolean; log?: boolean; + url: string; agentUrl?: string; browser?: boolean; nonInteractive: boolean; verbose: boolean; debug?: boolean; log?: boolean; jsonl?: boolean; loginHint?: string; domainHint?: string; tenant?: string; justification?: string; capabilities?: string[]; forceConsent?: boolean; }, @@ -377,7 +385,7 @@ export async function handleFullFlow( personServer: string | undefined, ): Promise { const shouldOpenBrowser = args.browser ?? true - const log = buildLogEmitter(args.log ?? false, { url: args.url, agentUrl: args.agentUrl, personServer }) + const log = buildLogEmitter(pickLogMode(args), { url: args.url, agentUrl: args.agentUrl, personServer }) const onEvent: OnEvent | undefined = log?.onEvent // Pin key material so the same ephemeral key is used for the initial request, diff --git a/fetch/src/log.ts b/fetch/src/log.ts index 04ad18b..db25655 100644 --- a/fetch/src/log.ts +++ b/fetch/src/log.ts @@ -202,36 +202,135 @@ function compactJson(v: unknown): string { return `{ ${inner} }` } -function renderRequestHeaders(method: string, url: string, hasBody: boolean): string[] { +// Header-Case formatter: "signature-input" → "Signature-Input" +function headerCase(name: string): string { + return name.split('-').map(p => p ? p[0].toUpperCase() + p.slice(1) : '').join('-') +} + +// Truncate the Signature header's base64 value: sig=:: → sig=:<16 chars>…: +function truncateSig(value: string): string { + return value.replace(/=:([^:]+):/g, (_, b64) => + b64.length > 16 ? `=:${b64.slice(0, 16)}…:` : `=:${b64}:`) +} + +// Truncate the JWT inside Signature-Key: sig=jwt;jwt="" +function truncateSigKey(value: string): string { + return value.replace(/jwt="([^"]+)"/g, (_, jwt) => + jwt.length > 24 ? `jwt="${jwt.slice(0, 24)}…"` : `jwt="${jwt}"`) +} + +// Pretty-print a request/response body. JSON gets parsed + indented; long +// JWT-looking string values inside are truncated. Non-JSON returns as-is +// with simple length truncation. +function formatBody(body: string): string { + try { + const parsed = JSON.parse(body) + const pretty = JSON.stringify(parsed, null, 2) + // Truncate JWT-shaped values (>= 40 chars, alphanum/-/_) so the body stays scannable. + return pretty.replace(/("[\w\-.]+":\s*)"([a-zA-Z0-9_\-.]{40,})"/g, (_m, key, val) => + `${key}"${val.slice(0, 32)}…"`) + } catch { + return body.length > 200 ? `${body.slice(0, 200)}…` : body + } +} + +// Canonical ordering for the AAuth-relevant request headers we know about. +// Anything not in this list is appended after, in insertion order. +const REQUEST_HEADER_ORDER = [ + 'content-type', + 'content-length', + 'content-digest', + 'prefer', + 'authorization', + 'aauth-capabilities', + 'aauth-mission', + 'signature-input', + 'signature', + 'signature-key', +] + +function renderRequestHeaders( + method: string, + url: string, + headers: Record | undefined, + body?: string, +): string[] { const u = new URL(url) const path = u.pathname + u.search - const components = hasBody - ? '("@method" "@authority" "@path" "content-digest" "signature-key")' - : '("@method" "@authority" "@path" "signature-key")' - const created = Math.floor(Date.now() / 1000) const lines: string[] = [] lines.push(` ${c.bold(method)} ${path} HTTP/1.1`) lines.push(` ${c.bold('Host:')} ${u.host}`) - if (hasBody) { - lines.push(` ${c.bold('Content-Type:')} application/json`) - } else { - lines.push(` ${c.bold('Content-Type:')} application/json`) + + if (!headers) { + // Fallback for events without captured headers (shouldn't happen post-Layer-2). + lines.push(` ${c.dim('(signed-request headers not captured for this exchange)')}`) + return lines + } + + const seen = new Set(['host']) + for (const name of REQUEST_HEADER_ORDER) { + const value = headers[name] + if (value === undefined) continue + seen.add(name) + const display = + name === 'signature' ? truncateSig(value) + : name === 'signature-key' ? truncateSigKey(value) + : value + lines.push(` ${c.bold(headerCase(name) + ':')} ${display}`) + } + // Trailing pass for any headers we didn't anticipate (HTTP/2 implementations + // sometimes add extras like accept-encoding). + for (const [k, v] of Object.entries(headers)) { + if (seen.has(k)) continue + lines.push(` ${c.bold(headerCase(k) + ':')} ${v}`) + } + if (body) { + lines.push('') + const formatted = formatBody(body) + lines.push(...formatted.split('\n').map(l => ` ${l}`)) } - lines.push(` ${c.bold('Signature-Input:')} sig=${components};created=${created}`) - lines.push(` ${c.bold('Signature:')} sig=:${c.dim('')}:`) - lines.push(` ${c.bold('Signature-Key:')} sig=jwt;jwt="${c.dim('')}"`) return lines } // ── Collected event state ───────────────────────────────────────────────────── -interface Step1Data { url?: string; method?: string; agentToken?: Record } -interface Step2Data { status?: number; resourceToken?: Record; aauthRequirement?: string } -interface Step3Data { url?: string; status?: number; body?: Record } -interface Step4Data { url?: string; status?: number; agentToken?: Record; aauthRequirement?: string; location?: string } +interface Step1Data { + url?: string + method?: string + agentToken?: Record + requestHeaders?: Record +} +interface Step2Data { + status?: number + resourceToken?: Record + aauthRequirement?: string + responseBody?: string +} +interface Step3Data { + url?: string + status?: number + body?: Record + requestHeaders?: Record + responseBody?: string +} +interface Step4Data { + url?: string + status?: number + agentToken?: Record + aauthRequirement?: string + location?: string + requestHeaders?: Record + requestBody?: string + responseBody?: string +} interface Step5UserData { interactionUrl?: string; code?: string; resolvedAt?: number; startedAt?: number } interface Step6TokenData { authToken?: Record; expiresIn?: number } -interface Step8Data { url?: string; method?: string; authToken?: Record } +interface Step8Data { + url?: string + method?: string + authToken?: Record + requestHeaders?: Record +} interface Step9Data { status?: number } interface FlowState { @@ -256,18 +355,23 @@ function newState(): FlowState { // ── Render the flow ─────────────────────────────────────────────────────────── +function pushDescription(out: string[], step: string, phase: 'start' | 'done' | 'info', status?: number): void { + const text = describe(step, phase, status) + if (text) { + out.push(...wrap(text)) + out.push('') + } +} + function renderColdFlow(s: FlowState): string { const out: string[] = [] // Step 1: AGENT → RESOURCE out.push(stepHeader(1, 'AGENT', 'RESOURCE', "sign with agent's key")) out.push('') - out.push("We sign an HTTP request with the agent's keypair and call the resource. The") - out.push('Signature-Key header carries the agent_token so the resource can verify the') - out.push("signature against the agent's public key.") - out.push('') + pushDescription(out, 'signed_request', 'start') if (s.step1.url) { - out.push(...renderRequestHeaders(s.step1.method ?? 'GET', s.step1.url, false)) + out.push(...renderRequestHeaders(s.step1.method ?? 'GET', s.step1.url, s.step1.requestHeaders)) out.push('') out.push(renderJwtBlock('agent_token (decoded JWT in Signature-Key)', s.step1.agentToken)) } @@ -275,10 +379,7 @@ function renderColdFlow(s: FlowState): string { // Step 2: RESOURCE → AGENT (401 + resource_token) out.push(stepHeader(2, 'RESOURCE', 'AGENT', '401 with capability')) out.push('') - out.push('The resource verified the signature, but the agent isn\'t carrying an') - out.push('auth_token for this call. It mints a resource_token bound to the agent\'s') - out.push("public-key thumbprint and tells us to exchange it at the PS.") - out.push('') + pushDescription(out, 'signed_request', 'done', 401) out.push(statusLine(s.step2.status ?? 401)) out.push(` ${c.bold('Content-Type:')} application/json`) if (s.step2.aauthRequirement) { @@ -290,9 +391,7 @@ function renderColdFlow(s: FlowState): string { // Step 3: AGENT → PS (discovery) out.push(stepHeader(3, 'AGENT', 'PERSON SERVER', 'discover token endpoint')) out.push('') - out.push("We fetch the PS's well-known metadata to find the token endpoint we'll POST") - out.push("the resource_token to.") - out.push('') + pushDescription(out, 'ps_metadata_request', 'start') if (s.step3.url) { const u = new URL(s.step3.url) out.push(` ${c.bold('GET')} ${u.pathname} HTTP/1.1`) @@ -310,24 +409,18 @@ function renderColdFlow(s: FlowState): string { // Step 4: AGENT → PS (token exchange) out.push(stepHeader(4, 'AGENT', 'PERSON SERVER', 'exchange resource_token')) out.push('') - out.push('We POST the resource_token to the token endpoint we just discovered, signed') - out.push('with the same agent key as Step 1.') - out.push('') + pushDescription(out, 'ps_token_request', 'start') if (s.step4.url) { - out.push(...renderRequestHeaders('POST', s.step4.url, true)) - out.push('') - out.push(` ${c.dim('{ resource_token: "…", capabilities: ["interaction"]' + (s.step4.url ? '' : '') + ' }')}`) + out.push(...renderRequestHeaders('POST', s.step4.url, s.step4.requestHeaders, s.step4.requestBody)) out.push('') + out.push(renderJwtBlock('agent_token (decoded JWT in Signature-Key)', s.step4.agentToken ?? s.step1.agentToken)) } if (s.consentRequired) { // Step 5: PS → AGENT (202 consent required) out.push(stepHeader(5, 'PERSON SERVER', 'AGENT', '202, consent required')) out.push('') - out.push('The PS recognised the agent and the resource_token, but the user has not yet') - out.push('consented to this agent acting on their behalf. It deferred — returning a URL') - out.push("and a short code the user must approve, plus a Location to long-poll.") - out.push('') + pushDescription(out, 'ps_token_request', 'done', 202) out.push(statusLine(202)) out.push(` ${c.bold('Content-Type:')} application/json; charset=utf-8`) if (s.step4.aauthRequirement) { @@ -341,10 +434,7 @@ function renderColdFlow(s: FlowState): string { // Step 6: USER → PS (approve in browser) out.push(stepHeader(6, 'user', 'PERSON SERVER', 'approve in browser')) out.push('') - out.push('The user opens the consent screen, signs in to the Person Server, sees what') - out.push('scopes this agent is requesting, and approves. AAuth\'s consent always') - out.push('happens at the user\'s PS — never at the resource and never at the agent.') - out.push('') + pushDescription(out, 'consent_prompt', 'info') if (s.step5.interactionUrl) { out.push(` ${c.bold('→ Open')} ${s.step5.interactionUrl}`) } @@ -361,30 +451,22 @@ function renderColdFlow(s: FlowState): string { // Step 7: PS → AGENT (auth_token issued) out.push(stepHeader(7, 'PERSON SERVER', 'AGENT', 'issues auth_token')) out.push('') - out.push('The long-poll from Step 6 resolves with the auth_token. It\'s bound to the') - out.push('agent\'s key, scoped only to what the user granted, and carries identity') - out.push('claims released by the openid + profile scopes (if requested).') - out.push('') + pushDescription(out, 'auth_token_received', 'info') out.push(statusLine(200)) out.push(` ${c.bold('Content-Type:')} application/json`) out.push('') out.push(renderJwtBlock('auth_token (decoded)', s.step6.authToken, { - cnf: 'same key as Step 1', scope: 'identity scopes consumed as claims', })) } else { // Warm path: PS returns 200 directly with auth_token out.push(stepHeader(5, 'PERSON SERVER', 'AGENT', '200 with auth_token')) out.push('') - out.push('The PS recognised the agent and saw that the user has already consented to') - out.push('this agent + scope combination. It issued an auth_token directly — no 202,') - out.push('no consent screen, no long-poll.') - out.push('') + pushDescription(out, 'ps_token_request', 'done', 200) out.push(statusLine(200)) out.push(` ${c.bold('Content-Type:')} application/json`) out.push('') out.push(renderJwtBlock('auth_token (decoded)', s.step6.authToken, { - cnf: 'same key as Step 1', scope: 'identity scopes consumed as claims', })) } @@ -393,12 +475,11 @@ function renderColdFlow(s: FlowState): string { const retryStepNum = s.consentRequired ? 8 : 6 out.push(stepHeader(retryStepNum, 'AGENT', 'RESOURCE', 'retry with auth_token')) out.push('') - out.push('Same signature scheme as Step 1, but the Signature-Key now carries the') - out.push('auth_token instead of the agent_token.') - out.push('') + pushDescription(out, 'retry_with_auth_token', 'start') if (s.step8.url) { - out.push(...renderRequestHeaders(s.step8.method ?? 'GET', s.step8.url, false)) - out.push(` ${c.dim(' ← the auth_token from the previous step')}`) + out.push(...renderRequestHeaders(s.step8.method ?? 'GET', s.step8.url, s.step8.requestHeaders)) + out.push('') + out.push(renderJwtBlock('auth_token (decoded JWT in Signature-Key)', s.step8.authToken ?? s.step6.authToken)) out.push('') } @@ -406,10 +487,7 @@ function renderColdFlow(s: FlowState): string { const finalStepNum = s.consentRequired ? 9 : 7 out.push(stepHeader(finalStepNum, 'RESOURCE', 'AGENT', '200 with data')) out.push('') - out.push("The resource verifies the HTTP signature against the auth_token's cnf.jwk,") - out.push("confirms the PS issued the token (using a cached copy of the PS's JWKS),") - out.push('checks the scope covers this endpoint, and returns the data.') - out.push('') + pushDescription(out, 'retry_with_auth_token', 'done', 200) out.push(statusLine(s.step9.status ?? 200)) out.push(` ${c.bold('Content-Type:')} application/json`) out.push('') @@ -437,7 +515,9 @@ function renderCloser(consentRequired: boolean): string { // ── Public API ──────────────────────────────────────────────────────────────── -const narrations: Record string | undefined> = { +type EventDescriber = (e: AAuthEvent) => string | undefined + +const narrations: Record = { signed_request: (e) => e.phase === 'start' ? `Agent → Resource: ${e.method ?? 'GET'} ${e.url} (HTTP-signed)` : `Resource responded ${e.status}`, @@ -458,35 +538,94 @@ const narrations: Record string | undefined> = { : `Resource responded ${e.status}`, } +// Long-form per-step prose explaining what's happening at the protocol level. +// Single source of truth for both --log (pretty CLI rendering) and --jsonl +// (machine-readable events). Each function returns a paragraph (no embedded +// newlines); the CLI word-wraps for terminal display, JSON consumers can +// reflow as they see fit. Disambiguated by phase/status when one step has +// multiple narrative outcomes (e.g., ps_token_request:done is 200 for warm +// path, 202 for cold). +const descriptions: Record = { + signed_request: (e) => e.phase === 'start' + ? "We sign an HTTP request with the agent's keypair and call the resource. The Signature-Key header carries the agent_token so the resource can verify the signature against the agent's public key." + : e.status === 401 + ? "The resource verified the signature, but the agent isn't carrying an auth_token for this call. It mints a resource_token bound to the agent's public-key thumbprint and tells us to exchange it at the Person Server." + : undefined, + ps_metadata_request: (e) => e.phase === 'start' + ? "We fetch the Person Server's well-known metadata to find the token endpoint we'll POST the resource_token to." + : undefined, + ps_token_request: (e) => e.phase === 'start' + ? 'We POST the resource_token to the token endpoint we just discovered, signed with the same agent key as the initial call.' + : e.status === 202 + ? "The PS recognised the agent and the resource_token, but the user has not yet consented to this agent acting on their behalf. It deferred — returning a URL and a short code the user must approve, plus a Location to long-poll." + : e.status === 200 + ? "The PS recognised the agent and saw that the user has already consented to this agent + scope combination. It issued an auth_token directly — no 202, no consent screen, no long-poll." + : undefined, + consent_prompt: () => + "The user opens the consent screen, signs in to the Person Server, sees what scopes this agent is requesting, and approves. AAuth's consent always happens at the user's PS — never at the resource and never at the agent.", + auth_token_received: () => + "The PS issued an auth_token. It's bound to the agent's key, scoped only to what the user granted, and carries identity claims released by the openid + profile scopes (if requested).", + retry_with_auth_token: (e) => e.phase === 'start' + ? 'Same signature scheme as the initial call, but the Signature-Key now carries the auth_token instead of the agent_token.' + : e.status === 200 + ? "The resource verifies the HTTP signature against the auth_token's cnf.jwk, confirms the PS issued the token (using a cached copy of the PS's JWKS), checks the scope covers this endpoint, and returns the data." + : undefined, +} + function formatNdjson(event: AAuthEvent): string { const narration = narrations[event.step]?.(event) - const line = narration ? { ...event, narration } : event + const description = descriptions[event.step]?.(event) + const line: Record = { ...event } + if (narration) line.narration = narration + if (description) line.description = description return JSON.stringify(line) + '\n' } +// Word-wrap a paragraph at `width` columns for terminal rendering. +function wrap(text: string, width = 78): string[] { + const words = text.split(/\s+/).filter(Boolean) + const lines: string[] = [] + let cur = '' + for (const w of words) { + if (cur.length === 0) cur = w + else if (cur.length + 1 + w.length <= width) cur += ' ' + w + else { lines.push(cur); cur = w } + } + if (cur) lines.push(cur) + return lines +} + +// Look up a description for a synthetic event shape — used by the CLI +// pretty-printer so both --log and --jsonl read from the same map. +function describe(step: string, phase: 'start' | 'done' | 'info', status?: number): string | undefined { + const e: AAuthEvent = { step, phase, ...(status !== undefined ? { status } : {}) } + return descriptions[step]?.(e) +} + export interface LogHandle { onEvent: OnEvent finish: () => void } +export type LogMode = 'pretty' | 'jsonl' + /** * Build a stream-aware fetch event handler. * - * TTY mode: collects events, renders TL;DR + Already-set-up (if marker stale) - * or condensed flow (if marker fresh) at first event, then renders 7/9-step - * cards + closer at finish(). - * - * Piped mode: emits NDJSON one line per event (current behavior preserved). + * mode='pretty': collects events, renders the human-readable step cards + * (Already-set-up + This-call + numbered steps + closer) + * mode='jsonl': emits each event as one JSON object per line on stderr. + * Each line carries `narration` (short) and `description` + * (paragraph) fields so machine + AI consumers get the + * same prose --log shows. */ export function buildLogEmitter( - enabled: boolean, + mode: LogMode | undefined, context: { url?: string; agentUrl?: string; personServer?: string } = {}, ): LogHandle | undefined { - if (!enabled) return undefined - - const pretty = IS_TTY + if (!mode) return undefined - if (!pretty) { + if (mode === 'jsonl') { return { onEvent: (event: AAuthEvent) => process.stderr.write(formatNdjson(event)), finish: () => {}, @@ -516,10 +655,15 @@ export function buildLogEmitter( state.step1.agentToken = event.agent_token as Record | undefined } else if (event.phase === 'done') { state.step2.status = event.status as number | undefined - const headers = (event.response as Record | undefined)?.headers as Record | undefined + state.step1.requestHeaders = event.request_headers as Record | undefined + const response = event.response as Record | undefined + const headers = response?.headers as Record | undefined if (headers) { state.step2.aauthRequirement = headers['aauth-requirement'] } + if (typeof response?.body === 'string') { + state.step2.responseBody = response.body + } } break case 'challenge_received': @@ -530,6 +674,11 @@ export function buildLogEmitter( state.step3.url = event.url as string | undefined } else if (event.phase === 'done') { state.step3.status = event.status as number | undefined + state.step3.requestHeaders = event.request_headers as Record | undefined + const response = event.response as Record | undefined + if (typeof response?.body === 'string') { + try { state.step3.body = JSON.parse(response.body) } catch { state.step3.responseBody = response.body } + } } break case 'ps_token_request': @@ -538,11 +687,17 @@ export function buildLogEmitter( state.step4.agentToken = event.agent_token as Record | undefined } else if (event.phase === 'done') { state.step4.status = event.status as number | undefined - const headers = (event.response as Record | undefined)?.headers as Record | undefined + state.step4.requestHeaders = event.request_headers as Record | undefined + state.step4.requestBody = event.request_body as string | undefined + const response = event.response as Record | undefined + const headers = response?.headers as Record | undefined if (headers) { state.step4.aauthRequirement = headers['aauth-requirement'] state.step4.location = headers['location'] } + if (typeof response?.body === 'string') { + state.step4.responseBody = response.body + } } break case 'ps_consent_pending': @@ -567,6 +722,7 @@ export function buildLogEmitter( state.step8.authToken = event.auth_token as Record | undefined } else if (event.phase === 'done') { state.step9.status = event.status as number | undefined + state.step8.requestHeaders = event.request_headers as Record | undefined } break } @@ -586,5 +742,5 @@ export function buildLogEmitter( export function logEvent(enabled: boolean, event: AAuthEvent): void { if (!enabled) return - buildLogEmitter(true)?.onEvent(event) + buildLogEmitter('jsonl')?.onEvent(event) } From 478fec6fc8f70285d5b7e61cd9e78d699db49eb8 Mon Sep 17 00:00:00 2001 From: rohanharikr Date: Fri, 22 May 2026 09:49:08 +0100 Subject: [PATCH 5/5] Update @hellocoop/httpsig to version 1.6.0 in package.json and package-lock.json. Add new @napi-rs/keyring platform entries for various operating systems in the lockfile, ensuring compatibility across environments. --- mcp-agent/package.json | 2 +- package-lock.json | 390 ++++++++++++++++++++--------------------- 2 files changed, 196 insertions(+), 196 deletions(-) diff --git a/mcp-agent/package.json b/mcp-agent/package.json index 35237e2..e695202 100644 --- a/mcp-agent/package.json +++ b/mcp-agent/package.json @@ -32,7 +32,7 @@ "directory": "mcp-agent" }, "dependencies": { - "@hellocoop/httpsig": "^1.1.3" + "@hellocoop/httpsig": "^1.6.0" }, "devDependencies": { "@types/node": "^20.0.0", diff --git a/package-lock.json b/package-lock.json index c03cdd0..ece7c05 100644 --- a/package-lock.json +++ b/package-lock.json @@ -85,7 +85,7 @@ "version": "0.11.3", "license": "MIT", "dependencies": { - "@hellocoop/httpsig": "^1.1.3" + "@hellocoop/httpsig": "^1.6.0" }, "devDependencies": { "@types/node": "^20.0.0", @@ -666,9 +666,9 @@ } }, "node_modules/@hellocoop/httpsig": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@hellocoop/httpsig/-/httpsig-1.5.1.tgz", - "integrity": "sha512-ChC9ZGWS73gj62MlInaYVqvspUvDzzQZkzLDt7ybLrk8opipwrvALpETElgN1NF+EtKG8YPDgV7CMdNRT6MrBg==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@hellocoop/httpsig/-/httpsig-1.6.0.tgz", + "integrity": "sha512-KKD1pyoZy9KlgrSkTzurnZaHCcXPt2DvZCxckf/SoNQ4RRgt0RttFOYD6BEB1z1R809G7E6CVJl6kWZyytr1BA==", "license": "MIT", "engines": { "node": ">=18" @@ -802,6 +802,197 @@ "node": ">= 10" } }, + "node_modules/@napi-rs/keyring-darwin-x64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-x64/-/keyring-darwin-x64-1.3.0.tgz", + "integrity": "sha512-YcJtEV5LA3cvA4z3BurgxH5IhTsW1JfIvcAAcqcecwk06Si9F9NqkxbZVIfDwQ8oRHgaBmT3zZJnLAotCrVahw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-freebsd-x64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-freebsd-x64/-/keyring-freebsd-x64-1.3.0.tgz", + "integrity": "sha512-vlLf31TGhfRAaxLDBhg8b89ss0HHD/lyNmL5F3UjSaz5CUXElsJmKYq9fqA/B+cZKUEUcLHHGhF0I/CqcFdaVw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm-gnueabihf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm-gnueabihf/-/keyring-linux-arm-gnueabihf-1.3.0.tgz", + "integrity": "sha512-KiWdMMu/Inz/bHHIAGrnF7r54FZDYXuHO6UFF/rhIrshUsxbMG1Rl9lEymNtqqsVo927G0VYcb02FzWQ3iBQRQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-gnu/-/keyring-linux-arm64-gnu-1.3.0.tgz", + "integrity": "sha512-eyKGpY40lm9Jvs1aD294XRH4y7+TlJM0YVAryZeXA6TX0mb4gMkxVXwSQv7MCwgah7raeUd0dKUb4BPAYIgcMg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm64-musl": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-musl/-/keyring-linux-arm64-musl-1.3.0.tgz", + "integrity": "sha512-iIK6JWHXAJqDrEyLY3TmswwloVyt2vj+04TZnew+uSJ9gnDO8EwRbp3/iw3LpWaXiDO7VomGO6y8I0Id8uBZSw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-riscv64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-riscv64-gnu/-/keyring-linux-riscv64-gnu-1.3.0.tgz", + "integrity": "sha512-/PGqrwn6EwgtK6vccASSXJRfOSP4vN1F4ASsIQ+7MdrK6hNvAJ1FZPrIuD5gGGdxezo3F++To2Wq7DbuGIeuNQ==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-x64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-gnu/-/keyring-linux-x64-gnu-1.3.0.tgz", + "integrity": "sha512-2PDK1WKWTu9lBGq9VvNEkSlQD3O7YwVpmnyN2M3cy4v7NJ/8gDMd9GXv3G+FVXN13uhp4gnnPBS+ScefmEeD2A==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-x64-musl": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-musl/-/keyring-linux-x64-musl-1.3.0.tgz", + "integrity": "sha512-oJ2HkX8YUo46QBkn0pG+HuIKQNqr523q6vBobCn+P95s4C4K6/kLBqHY/1bg5J4ap31DzsznhnFKcfBNBsjCnw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-arm64-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-arm64-msvc/-/keyring-win32-arm64-msvc-1.3.0.tgz", + "integrity": "sha512-tOd3c/uAaeoE4ycVlmAdSvygz0Zt3zdca6Y7gokBeIbaRDWpjDIUOpU3MvML59XAaqyuKGsVVu0F/DZb1lHPmw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-ia32-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-ia32-msvc/-/keyring-win32-ia32-msvc-1.3.0.tgz", + "integrity": "sha512-sPSqeAFZMGqP1R++M2JTza7GQJJ/TpCo6JU6Vcd4jnebvOaEDs9b7eipakU1PJdSvhpC2yXMCNRk9gXfrhuwHQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-x64-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-x64-msvc/-/keyring-win32-x64-msvc-1.3.0.tgz", + "integrity": "sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.60.4", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", @@ -3237,197 +3428,6 @@ "peerDependencies": { "zod": "^3.25.28 || ^4" } - }, - "node_modules/@napi-rs/keyring-darwin-x64": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-x64/-/keyring-darwin-x64-1.3.0.tgz", - "integrity": "sha512-YcJtEV5LA3cvA4z3BurgxH5IhTsW1JfIvcAAcqcecwk06Si9F9NqkxbZVIfDwQ8oRHgaBmT3zZJnLAotCrVahw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/keyring-freebsd-x64": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring-freebsd-x64/-/keyring-freebsd-x64-1.3.0.tgz", - "integrity": "sha512-vlLf31TGhfRAaxLDBhg8b89ss0HHD/lyNmL5F3UjSaz5CUXElsJmKYq9fqA/B+cZKUEUcLHHGhF0I/CqcFdaVw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/keyring-linux-arm-gnueabihf": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm-gnueabihf/-/keyring-linux-arm-gnueabihf-1.3.0.tgz", - "integrity": "sha512-KiWdMMu/Inz/bHHIAGrnF7r54FZDYXuHO6UFF/rhIrshUsxbMG1Rl9lEymNtqqsVo927G0VYcb02FzWQ3iBQRQ==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/keyring-linux-arm64-gnu": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-gnu/-/keyring-linux-arm64-gnu-1.3.0.tgz", - "integrity": "sha512-eyKGpY40lm9Jvs1aD294XRH4y7+TlJM0YVAryZeXA6TX0mb4gMkxVXwSQv7MCwgah7raeUd0dKUb4BPAYIgcMg==", - "cpu": [ - "arm64" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/keyring-linux-arm64-musl": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-musl/-/keyring-linux-arm64-musl-1.3.0.tgz", - "integrity": "sha512-iIK6JWHXAJqDrEyLY3TmswwloVyt2vj+04TZnew+uSJ9gnDO8EwRbp3/iw3LpWaXiDO7VomGO6y8I0Id8uBZSw==", - "cpu": [ - "arm64" - ], - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/keyring-linux-riscv64-gnu": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-riscv64-gnu/-/keyring-linux-riscv64-gnu-1.3.0.tgz", - "integrity": "sha512-/PGqrwn6EwgtK6vccASSXJRfOSP4vN1F4ASsIQ+7MdrK6hNvAJ1FZPrIuD5gGGdxezo3F++To2Wq7DbuGIeuNQ==", - "cpu": [ - "riscv64" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/keyring-linux-x64-gnu": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-gnu/-/keyring-linux-x64-gnu-1.3.0.tgz", - "integrity": "sha512-2PDK1WKWTu9lBGq9VvNEkSlQD3O7YwVpmnyN2M3cy4v7NJ/8gDMd9GXv3G+FVXN13uhp4gnnPBS+ScefmEeD2A==", - "cpu": [ - "x64" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/keyring-linux-x64-musl": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-musl/-/keyring-linux-x64-musl-1.3.0.tgz", - "integrity": "sha512-oJ2HkX8YUo46QBkn0pG+HuIKQNqr523q6vBobCn+P95s4C4K6/kLBqHY/1bg5J4ap31DzsznhnFKcfBNBsjCnw==", - "cpu": [ - "x64" - ], - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/keyring-win32-arm64-msvc": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-arm64-msvc/-/keyring-win32-arm64-msvc-1.3.0.tgz", - "integrity": "sha512-tOd3c/uAaeoE4ycVlmAdSvygz0Zt3zdca6Y7gokBeIbaRDWpjDIUOpU3MvML59XAaqyuKGsVVu0F/DZb1lHPmw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/keyring-win32-ia32-msvc": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-ia32-msvc/-/keyring-win32-ia32-msvc-1.3.0.tgz", - "integrity": "sha512-sPSqeAFZMGqP1R++M2JTza7GQJJ/TpCo6JU6Vcd4jnebvOaEDs9b7eipakU1PJdSvhpC2yXMCNRk9gXfrhuwHQ==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/keyring-win32-x64-msvc": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-x64-msvc/-/keyring-win32-x64-msvc-1.3.0.tgz", - "integrity": "sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } } } }