Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions call-log/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion call-log/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
14 changes: 11 additions & 3 deletions call-log/src/caller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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,
Expand All @@ -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) {
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion call-log/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
14 changes: 13 additions & 1 deletion call-log/src/record.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => `${b64({ alg: 'Ed25519', typ })}.${b64(payload)}.c2ln`
Expand Down Expand Up @@ -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 } })
Expand Down
16 changes: 13 additions & 3 deletions call-log/src/record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Role> = {
'aauth-person.json': 'ps',
'aauth-access.json': 'as',
Expand Down Expand Up @@ -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<string>; headers: HeadersLike } | null | undefined,
params?: Record<string, unknown>,
Expand All @@ -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 {
Expand Down
13 changes: 12 additions & 1 deletion call-log/src/sides.test.ts
Original file line number Diff line number Diff line change
@@ -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')
Expand Down Expand Up @@ -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 }) }
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading