From 83e164d182ec6f2f3e6277438248b1b88881bcb1 Mon Sep 17 00:00:00 2001 From: dickhardt Date: Sun, 27 Sep 2026 12:55:02 +0100 Subject: [PATCH] call-log 0.1.1: tokenize parsed headers, tolerate Hono's executionCtx getter, accept httpsig's fetch Found while wiring the seven fleet workers (access #9, senzing #12, test-resource #2, whoami #3, notes #4, web-agent-demo #7, registry #9): - partOf left a resource token inside a parsed AAuth-Requirement (every auth-token challenge) as the JWT string. It is tokenized now, like a body, so no record holds a presentable token. - The middleware read `c.executionCtx?.waitUntil` as a property; Hono's getter throws where there is no execution context (Node, app.request), which 500ed every request on senzing's Node entrypoint and in tests. - HttpsigFetchLike did not accept @hellocoop/httpsig's fetch, which is overloaded on dryRun / returnSent; loggedHttpsigFetch had never been used. The options are `any` now, and the result is unwrapped by shape. - README: the callee record waits on the client reading the response (workerd's clone is a tee), and a constant fake Signature in a harness gives every call the same call_id. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014n2B6Qwjkwkdft4ms5NgMF --- call-log/README.md | 11 ++++++++ call-log/package.json | 2 +- call-log/src/callee.ts | 16 +++++++++--- call-log/src/caller.ts | 11 ++++++-- call-log/src/record.ts | 3 ++- call-log/src/sides.test.ts | 52 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 88 insertions(+), 7 deletions(-) diff --git a/call-log/README.md b/call-log/README.md index c1cdb9a..3e29a2d 100644 --- a/call-log/README.md +++ b/call-log/README.md @@ -80,6 +80,17 @@ const res = await send(url, { method: 'POST', body, signingKey, signatureKey }) For a fetch you cannot wrap, `failedFetch(host, { url, status | error })` records a failure. +## Two things a host should know + +- **The callee record waits on the client reading the response.** The + record reads a clone of the response, and in workerd a clone is a tee + that completes when the original body is consumed. Every real caller + reads the body; a test that checks only `res.status` never gets the + record — read the body. +- **Where a `Signature` header is fake and constant** (a test harness that + trusts `Signature-Key`), every call gets the same `call_id`. Make it + distinct per request. + ## Pieces `callIdOf`, `tokenOf`, `tokenize`, `signerOf`, `paramsOf`, `errorOf`, diff --git a/call-log/package.json b/call-log/package.json index 0caea7b..b74d6a7 100644 --- a/call-log/package.json +++ b/call-log/package.json @@ -1,6 +1,6 @@ { "name": "@aauth/call-log", - "version": "0.1.0", + "version": "0.1.1", "description": "The aauth.call record: one log record per HTTP call between AAuth roles, at each end. A pure builder, a Hono-shaped middleware for the callee side, and logged fetch wrappers for the caller side.", "type": "module", "exports": { diff --git a/call-log/src/callee.ts b/call-log/src/callee.ts index 4dbd76f..64e0390 100644 --- a/call-log/src/callee.ts +++ b/call-log/src/callee.ts @@ -23,6 +23,17 @@ export interface CalleeOptions { skip?: (request: Request) => boolean } +// Hono's `executionCtx` is a getter that throws where there is none (Node, +// `app.request` in tests); read it as such. +const executionCtxOf = (c: ContextLike): { waitUntil(p: Promise): void } | undefined => { + try { + const ctx = c.executionCtx + return ctx && typeof ctx.waitUntil === 'function' ? ctx : undefined + } catch { + return undefined + } +} + const skipByDefault = (request: Request) => { if (request.method === 'OPTIONS' || request.method === 'HEAD') return true const path = new URL(request.url).pathname @@ -57,9 +68,8 @@ export function callLogMiddleware(host: CallLogHost, options: CalleeOptions = {} const response = c.res const ended = Date.now() const responseClone = response.clone() - const hostWithCtx: CallLogHost = c.executionCtx?.waitUntil - ? { ...host, defer: host.defer ?? ((p) => c.executionCtx!.waitUntil(p)) } - : host + const ctx = executionCtxOf(c) + const hostWithCtx: CallLogHost = ctx ? { ...host, defer: host.defer ?? ((p) => ctx.waitUntil(p)) } : host defer( hostWithCtx, (async () => { diff --git a/call-log/src/caller.ts b/call-log/src/caller.ts index d8f2df3..4ec215e 100644 --- a/call-log/src/caller.ts +++ b/call-log/src/caller.ts @@ -111,8 +111,14 @@ export function loggedFetch(makeFetch: (onSigned: (sent: SentLike) => void) => F } } +/** + * @hellocoop/httpsig's `fetch`, loosely: it is overloaded on `dryRun` and + * `returnSent`, and the wrapper only ever calls it with `returnSent: true`. + * The options are `any` so the overloaded function is assignable as is. + */ export interface HttpsigFetchLike { - (url: string, options: Record): Promise + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (url: string | URL, options: any): Promise } /** @@ -130,7 +136,8 @@ export function loggedHttpsigFetch(httpsigFetch: HttpsigFetchLike, host: CallLog defer(host, record(host, call, url, init, started, undefined, { error })) throw error } - const { response, sent } = 'response' in result ? result : { response: result, sent: undefined } + const { response, sent } = + 'response' in result ? result : result instanceof Response ? { response: result, sent: undefined } : { response: new Response(null), sent: result } defer(host, record(host, call, url, init, started, sent, { response, clone: response.clone() })) return response } diff --git a/call-log/src/record.ts b/call-log/src/record.ts index 1db699d..00b6766 100644 --- a/call-log/src/record.ts +++ b/call-log/src/record.ts @@ -286,7 +286,8 @@ export async function partOf( params?: Record, ): Promise { const out: Part = {} - if (params) out.params = params + // A resource token rides in AAuth-Requirement on the auth-token challenge: a token, so payload only. + if (params) out.params = tokenize(params) as Record if (body) { const content_type = header(body.headers, 'content-type') const length = Number(header(body.headers, 'content-length')) diff --git a/call-log/src/sides.test.ts b/call-log/src/sides.test.ts index 68b75b6..528b171 100644 --- a/call-log/src/sides.test.ts +++ b/call-log/src/sides.test.ts @@ -158,3 +158,55 @@ describe('the caller side', () => { expect(records[0]).toMatchObject({ side: 'caller', parent: 'p', method: 'GET', path: '/.well-known/aauth-resource.json', status: 404, level: 40 }) }) }) + +describe('0.1.1', () => { + const resourceJwt = `${b64({ alg: 'Ed25519', typ: 'aa-resource+jwt' })}.${b64({ iss: 'https://notes.example', aud: 'https://as.example', scope: 'notes' })}.c2ln` + + it('a resource token inside a parsed AAuth-Requirement is logged as payload, not as the JWT', async () => { + const { host, records, settled } = testHost() + const request = new Request('https://encrypt.aauth.dev/notes', { headers: { signature: 'sig=:BBBB:', 'signature-key': `sig=jwt; jwt="${agentJwt}"` } }) + const { c, next } = contextFor(request, async () => + Response.json({ error: 'auth_token_required' }, { status: 401, headers: { 'AAuth-Requirement': `requirement=auth-token; resource-token="${resourceJwt}"` } }), + ) + await callLogMiddleware(host)(c, next) + await settled() + const [r] = records + expect(r.response?.params).toEqual({ 'AAuth-Requirement': { requirement: 'auth-token', 'resource-token': { type: 'aa-resource+jwt', payload: { iss: 'https://notes.example', aud: 'https://as.example', scope: 'notes' } } } }) + expect(JSON.stringify(r)).not.toContain(resourceJwt) + expect(r.level).toBe(30) // still a challenge + }) + + it('a context whose executionCtx getter throws (Hono on Node) is logged, not a 500', async () => { + const { host, records, settled } = testHost() + const request = new Request('https://encrypt.aauth.dev/health-ish', { headers: { signature: 'sig=:CCCC:' } }) + const c = { + req: { raw: request }, + res: new Response(null, { status: 404 }), + get executionCtx(): { waitUntil(p: Promise): void } { + throw new Error('This context has no ExecutionContext') + }, + } + const next = async () => { c.res = Response.json({ ok: true }) } + await expect(callLogMiddleware(host)(c, next)).resolves.toBeUndefined() + await settled() + expect(records).toHaveLength(1) + expect(records[0]).toMatchObject({ status: 200, call_id: sha('sig=:CCCC:') }) + }) + + it('loggedHttpsigFetch accepts a fetch typed like @hellocoop/httpsig (overloaded on returnSent)', async () => { + const { host, records, settled } = testHost('as') + // The shape of httpsig's fetch: with returnSent it answers { response, sent }. + async function httpsigLike(url: string | URL, options: { returnSent: true } & Record): Promise<{ response: Response; sent: { headers: Headers } }> + async function httpsigLike(url: string | URL, options: Record): Promise + async function httpsigLike(_url: string | URL, options: Record): Promise { + const response = new Response(null, { status: 200 }) + const sent = { headers: new Headers({ signature: 'sig=:DDDD:', 'signature-key': 'sig=jwks_uri; id="https://access.aauth.dev"; dwk="aauth-access.json"; kid="k"' }) } + return options.returnSent ? { response, sent } : response + } + const send = loggedHttpsigFetch(httpsigLike, host, { to_role: 'resource' }) + const res = await send('https://notes.example/aauth/revoke', { method: 'POST', body: JSON.stringify({ jti: 'x', exp: 1 }) }) + expect(res.status).toBe(200) + await settled() + expect(records[0]).toMatchObject({ side: 'caller', call_id: sha('sig=:DDDD:'), to: 'https://notes.example', path: '/aauth/revoke', to_role: 'resource', signed: { scheme: 'jwks_uri', id: 'https://access.aauth.dev' } }) + }) +})