From c68ce29027f8a219b3b3a4a47142cd2b53fc527f Mon Sep 17 00:00:00 2001 From: dickhardt Date: Mon, 28 Sep 2026 12:01:22 +0100 Subject: [PATCH] call-log 0.1.3: log a body only if small (8 KB); over it, its content_type and size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dick, 2026-09-28: "call-log sends bodies IF SMALL." MAX_BODY_BYTES = 8 KB, measured on the body's UTF-8 text. Over it the body is not logged and the part carries `content_type` and `size` instead — the shape the record already uses for a body it does not show, which the monitor renders as "Body not logged: application/json, N bytes." No body at all is neither field. A stated Content-Length over the limit is not read. The caller's request body gets the same rule (`{ size }`). The 30 KB record cap stays as the backstop. Fleet data, last three days: every protocol body is under 2 KB (largest: access /token request with the R3 document, 1.7 KB); senzing results under 2 KB; secret.agent.coop JWE messages 6.5 KB. The one body over 8 KB is registry GET /resources, 27.7 KB. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YTSrtvfcNAxTc5KGxr4aKh --- call-log/README.md | 8 +++++--- call-log/package.json | 2 +- call-log/src/caller.ts | 14 +++++++++++--- call-log/src/index.ts | 2 +- call-log/src/record.test.ts | 14 +++++++++++++- call-log/src/record.ts | 16 +++++++++++++--- call-log/src/sides.test.ts | 13 ++++++++++++- package-lock.json | 2 +- 8 files changed, 57 insertions(+), 14 deletions(-) diff --git a/call-log/README.md b/call-log/README.md index fd11f3f..7d485e1 100644 --- a/call-log/README.md +++ b/call-log/README.md @@ -13,9 +13,11 @@ The record is `aauth.call`, specified in `aauth-dev/monitor` - **Tokens are logged as `{ type, payload }`** — the JWT's `typ` header and its claims — wherever they appear: in `signed` and in place of the JWT string in a body, at any depth. Never the JWT, so no log holds a presentable token. -- **Bodies are logged.** JSON bodies as values; anything else as its content - type and size. A record over 30 KB has its larger body cut to text and - `truncated: true`. +- **Bodies are logged if small.** A JSON body up to 8 KB (`MAX_BODY_BYTES`) + as a value. A larger body, or one that is not JSON, as its `content_type` + and `size` in bytes — so a reader tells "no body" (neither field) from + "body not logged" (`size`). A record still over 30 KB has its larger body + cut to text and `truncated: true`. - **`call_id`** is base64url SHA-256 of the `Signature` header. Both ends hold it, so their records join with no new header on the wire. - **`parent`** is the call being handled when an outbound call is made. It is diff --git a/call-log/package.json b/call-log/package.json index 2fd076c..c3ab21d 100644 --- a/call-log/package.json +++ b/call-log/package.json @@ -1,6 +1,6 @@ { "name": "@aauth/call-log", - "version": "0.1.2", + "version": "0.1.3", "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/caller.ts b/call-log/src/caller.ts index 4ec215e..0d77c54 100644 --- a/call-log/src/caller.ts +++ b/call-log/src/caller.ts @@ -13,7 +13,7 @@ // off the caller's path, through host.defer. `parent` is the call being // handled (AsyncLocalStorage) unless the caller passes one. -import { callIdOf, signerOf, withThumbprint, paramsOf, errorOf, partOf, buildRecord, tokenize, targetOf, type Role, type Signed } from './record.js' +import { callIdOf, signerOf, withThumbprint, paramsOf, errorOf, partOf, buildRecord, tokenize, targetOf, MAX_BODY_BYTES, type Part, type Role, type Signed } from './record.js' import { parentFromContext, currentCall } from './context.js' import { emit, defer, type CallLogHost } from './host.js' @@ -52,7 +52,7 @@ async function record( const agent = call.agent ?? currentCall()?.agent const signer: { signed?: Signed } = sent ? await withThumbprint(signerOf(headerOf(sent.headers, 'signature-key'))) : {} const call_id = await callIdOf(sent ? headerOf(sent.headers, 'signature') : undefined) - const requestBody = typeof init?.body === 'string' ? safeJson(init.body) : undefined + const request = typeof init?.body === 'string' ? requestPartOf(init.body) : undefined const base = { side: 'caller' as const, call_id, @@ -64,7 +64,7 @@ async function record( method: (init?.method ?? 'GET').toUpperCase(), started_at: started.toISOString(), signed: signer.signed, - request: requestBody === undefined ? undefined : { body: tokenize(requestBody) }, + request, ...targetOf(String(url)), } if ('error' in outcome) { @@ -79,6 +79,14 @@ async function record( emit(host, buildRecord({ ...base, status: response.status, duration_ms, response: responsePart, error: response.status >= 400 ? errorOf(params, responsePart?.body) : undefined })) } +const encoder = new TextEncoder() + +// Over MAX_BODY_BYTES the body is not logged; its size stands in its place. +function requestPartOf(text: string): Part { + const size = encoder.encode(text).length + return size > MAX_BODY_BYTES ? { size } : { body: tokenize(safeJson(text)) } +} + function safeJson(text: string): unknown { try { return JSON.parse(text) diff --git a/call-log/src/index.ts b/call-log/src/index.ts index 02445ec..288fd61 100644 --- a/call-log/src/index.ts +++ b/call-log/src/index.ts @@ -8,7 +8,7 @@ // which agent a verified call is for. export type { CallRecord, RecordFields, Role, Side, Token, Signed, Part, Signer } from './record.js' -export { callIdOf, tokenOf, tokenize, signerOf, thumbprintOf, withThumbprint, paramsOf, errorOf, levelOf, partOf, cap, buildRecord, targetOf, MAX_RECORD_BYTES } from './record.js' +export { callIdOf, tokenOf, tokenize, signerOf, thumbprintOf, withThumbprint, paramsOf, errorOf, levelOf, partOf, cap, buildRecord, targetOf, MAX_RECORD_BYTES, MAX_BODY_BYTES } from './record.js' export type { CallLogHost } from './host.js' export { emit } from './host.js' export type { CallContext } from './context.js' diff --git a/call-log/src/record.test.ts b/call-log/src/record.test.ts index 9f2af71..b0fd02c 100644 --- a/call-log/src/record.test.ts +++ b/call-log/src/record.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest' import { createHash } from 'node:crypto' -import { callIdOf, tokenOf, tokenize, signerOf, thumbprintOf, withThumbprint, paramsOf, errorOf, levelOf, partOf, cap, buildRecord, targetOf, MAX_RECORD_BYTES } from './index.js' +import { callIdOf, tokenOf, tokenize, signerOf, thumbprintOf, withThumbprint, paramsOf, errorOf, levelOf, partOf, cap, buildRecord, targetOf, MAX_RECORD_BYTES, MAX_BODY_BYTES } from './index.js' const b64 = (o: unknown) => Buffer.from(JSON.stringify(o)).toString('base64url') const jwt = (typ: string, payload: Record) => `${b64({ alg: 'Ed25519', typ })}.${b64(payload)}.c2ln` @@ -113,6 +113,18 @@ describe('bodies', () => { expect(await partOf(new Response(null))).toBeUndefined() }) + it('a body over MAX_BODY_BYTES is not logged: its type and size stand in its place', async () => { + const json = (bytes: number) => JSON.stringify({ pad: 'x'.repeat(bytes - 10) }) + expect(json(MAX_BODY_BYTES)).toHaveLength(MAX_BODY_BYTES) + const at = new Response(json(MAX_BODY_BYTES), { headers: { 'content-type': 'application/json' } }) + expect((await partOf(at))?.body).toEqual({ pad: 'x'.repeat(MAX_BODY_BYTES - 10) }) + const told = new Response(json(MAX_BODY_BYTES + 1), { headers: { 'content-type': 'application/json', 'content-length': String(MAX_BODY_BYTES + 1) } }) + expect(await partOf(told)).toEqual({ content_type: 'application/json', size: MAX_BODY_BYTES + 1 }) + expect(told.bodyUsed).toBe(false) // a stated length over the limit is not read + const measured = new Response(json(MAX_BODY_BYTES + 1), { headers: { 'content-type': 'application/json' } }) + expect(await partOf(measured)).toEqual({ content_type: 'application/json', size: MAX_BODY_BYTES + 1 }) + }) + it('the cap cuts the larger body to text and says so', () => { const big = { entities: Array.from({ length: 2000 }, (_, i) => ({ id: i, name: `Robert Smith ${i}` })) } const r = cap({ request: { body: { q: 'x' } }, response: { body: big } }) diff --git a/call-log/src/record.ts b/call-log/src/record.ts index 00b6766..54b591e 100644 --- a/call-log/src/record.ts +++ b/call-log/src/record.ts @@ -58,6 +58,14 @@ export interface CallRecord { /** A wallet_events entry is capped at 32 KB (Wallet #4285); every party keeps to it. */ export const MAX_RECORD_BYTES = 32 * 1024 - 2048 +/** + * Bodies are logged if small (Dick, 2026-09-28): a body over this many bytes + * is not logged, and its `content_type` and `size` stand in its place. Every + * protocol body seen in the fleet is under 2 KB; two bodies at the limit + * still fit the record cap. + */ +export const MAX_BODY_BYTES = 8 * 1024 + const ROLE_BY_DWK: Record = { 'aauth-person.json': 'ps', 'aauth-access.json': 'as', @@ -280,7 +288,7 @@ export function levelOf(r: { side: Side; status?: number; response?: Part }): nu const JSON_TYPES = /json/i -/** What a body becomes in the record: JSON under the cap as a value, anything else as its type and size. */ +/** What a body becomes in the record: JSON up to MAX_BODY_BYTES as a value, anything else as its type and size. */ export async function partOf( body: { text: () => Promise; headers: HeadersLike } | null | undefined, params?: Record, @@ -292,15 +300,17 @@ export async function partOf( const content_type = header(body.headers, 'content-type') const length = Number(header(body.headers, 'content-length')) const size = Number.isFinite(length) && length > 0 ? length : undefined - if (content_type && JSON_TYPES.test(content_type) && (size === undefined || size <= MAX_RECORD_BYTES * 4)) { + if (content_type && JSON_TYPES.test(content_type) && (size === undefined || size <= MAX_BODY_BYTES)) { try { const text = await body.text() if (text) { + const bytes = encoder.encode(text).length try { + if (bytes > MAX_BODY_BYTES) throw new RangeError('over MAX_BODY_BYTES: not logged') out.body = tokenize(JSON.parse(text)) } catch { out.content_type = content_type - out.size = encoder.encode(text).length + out.size = bytes } } } catch { diff --git a/call-log/src/sides.test.ts b/call-log/src/sides.test.ts index c755f0f..7070dac 100644 --- a/call-log/src/sides.test.ts +++ b/call-log/src/sides.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest' import { createHash } from 'node:crypto' -import { callLogMiddleware, loggedFetch, loggedHttpsigFetch, failedFetch, nameAgent, parentFromContext, runInCall, type CallRecord, type CallLogHost } from './index.js' +import { callLogMiddleware, loggedFetch, loggedHttpsigFetch, failedFetch, nameAgent, parentFromContext, runInCall, MAX_BODY_BYTES, type CallRecord, type CallLogHost } from './index.js' const sha = (s: string) => createHash('sha256').update(s).digest('base64url') const b64 = (o: unknown) => Buffer.from(JSON.stringify(o)).toString('base64url') @@ -139,6 +139,17 @@ describe('the caller side', () => { expect(records.find((r) => r.path === '/b')!.request).toBeUndefined() }) + it('a request body over MAX_BODY_BYTES is logged as its size', async () => { + const { host, records, settled } = testHost() + const makeFetch = (onSigned: (s: { headers: Headers }) => void) => async () => { onSigned(sent('sig=:F:')); return new Response(null, { status: 204 }) } + const send = loggedFetch(makeFetch, host) + await send('https://r.example/small', { method: 'POST', body: JSON.stringify({ q: 'x' }) }) + await send('https://r.example/large', { method: 'POST', body: JSON.stringify({ pad: 'x'.repeat(MAX_BODY_BYTES) }) }) + await settled() + expect(records.find((r) => r.path === '/small')!.request).toEqual({ body: { q: 'x' } }) + expect(records.find((r) => r.path === '/large')!.request).toEqual({ size: MAX_BODY_BYTES + 10 }) + }) + it('an explicit parent wins over the context; outside any call there is none', async () => { const { host, records, settled } = testHost() const makeFetch = (onSigned: (s: { headers: Headers }) => void) => async () => { onSigned(sent('sig=:C:')); return new Response(null, { status: 204 }) } diff --git a/package-lock.json b/package-lock.json index 3bcd227..0610cf4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -52,7 +52,7 @@ }, "call-log": { "name": "@aauth/call-log", - "version": "0.1.2", + "version": "0.1.3", "license": "MIT", "devDependencies": { "@types/node": "^20.0.0",