audkit
v0.3.0
Published
Tamper-evident audit logging for TypeScript apps and AI agents - RFC 6962 Merkle trees, Ed25519-signed events, offline verification.
Maintainers
Readme
audkit
Tamper-evident audit logging for TypeScript apps and AI agents.
Every event is sequenced and committed as a leaf of your project's RFC 6962 Merkle tree, whose root is Ed25519-signed and published hourly to a public transparency log. There is no API to edit or delete a record — and if anyone rewrites history out-of-band (a rogue DBA, a doctored backup), verify() rebuilds the tree and names the exact sequence where it breaks.
npm install audkitFive minutes to a verifiable trail
- Create a project at audkit.dev — the tree starts at sequence 1.
- Mint a scoped API key (Project → Settings → Keys) and set it as
AUDKIT_API_KEY. - Wrap the code you already have — pick the wrapper that matches where your mutations live:
| Where the action happens | Import | Wrapper |
| --- | --- | --- |
| Next.js route handler | audkit/nextjs | withAuditLogging() |
| Next.js Server Action | audkit/nextjs | withAuditAction() |
| AI agent tool call | audkit/ai | auditedTool() |
| Anywhere else | audkit | audit.log() |
Then prove the whole history is intact whenever you like:
const receipt = await audit.verify();
// { valid: true, checkedCount: 18442, treeSize: 18442, rootHash: "a8d772…", ... } — signedRoute handlers — withAuditLogging()
Wraps a Next.js route handler (web-standard Request/Response only — no next import). Every config field is a literal value or a (possibly async) function of the request, response, result, and error:
import { withAuditLogging } from "audkit/nextjs";
export const POST = withAuditLogging(
{
action: "api_key.created",
risk: "medium",
actor: async ({ request }) => {
const session = await auth.api.getSession({ headers: request.headers });
return { type: "user", id: session!.user.id };
},
target: ({ result }) => ({ type: "api_key", id: result.apiKey.id }),
metadata: ({ result }) => ({ scopes: result.apiKey.scopes }),
},
async (request) => {
return createApiKey(await request.json());
},
);The event status defaults to failed when the handler throws or responds ≥ 400. If your handler returns a Response, read it in resolvers with responseJson() / responseText() — bodies are cloned so your original streams are untouched. On Vercel, the requester IP (spoof-proof x-vercel-forwarded-for) and geolocation/deployment trace are captured automatically; pass forwardVercelHeaders: false to opt out.
Server Actions — withAuditAction()
Same resolver pattern for the place most App Router mutations actually live. Resolvers see { args, result, error, status }:
"use server";
import { withAuditAction } from "audkit/nextjs";
export const changeRole = withAuditAction(
{
action: "role.changed",
risk: "high",
actor: async () => {
const session = await getSession();
return { type: "user", id: session.user.id };
},
target: ({ args }) => ({ type: "member", id: args[0].memberId }),
metadata: ({ args, result }) => ({
to: args[0].role,
previous: result?.previousRole,
}),
},
async (input: { memberId: string; role: string }) => {
return updateRole(input);
},
);Next.js control flow is respected: a redirect() thrown from the action logs as success (the action completed, then navigated); notFound() and real errors log as failed. Whatever was thrown is rethrown so Next handles it normally.
AI agent tools — auditedTool()
Wraps an AI SDK tool so every call an agent makes enters the record — exactly one event per call, carrying success, denied, or failed. Refusals are logged before the error is thrown. Risk labels, required reasons, and an authorization gate are built in:
import { auditedTool } from "audkit/ai";
const refund = auditedTool({
name: "refund_payment",
inputSchema: refundSchema,
risk: "high",
requireReason: true,
authorize: ({ input }) => input.amountCents <= 50_00,
handler: ({ paymentId }) => payments.refund(paymentId),
});
await generateText({
model,
tools: { refund_payment: refund },
experimental_context: { actor: { type: "user", id: user.id } },
prompt,
});If the tool input includes a string reason field it is recorded automatically; make it required in the schema plus requireReason: true for reliable reasons. The returned object is structurally compatible with AI SDK tools (inputSchema and parameters both supported).
The raw client
import { Audkit } from "audkit";
const audit = new Audkit({ apiKey: process.env.AUDKIT_API_KEY! });
await audit.log({
action: "invoice.approved",
actor: { type: "user", id: user.id, display: user.email },
target: { type: "invoice", id: invoice.id },
metadata: { amountCents: invoice.amountCents },
});
const { events } = await audit.query({ action: "invoice.approved", limit: 50 });
// Entity drill-down: what it did (outgoing) vs what happened to it (incoming)
const outgoing = await audit.query({
entityType: "user",
entityId: user.id,
direction: "outgoing",
});All wrappers accept apiKey/baseUrl directly, an existing client, or fall back to the AUDKIT_API_KEY / AUDKIT_BASE_URL environment variables.
Customer-held signing (Ed25519)
Bring your own key and the history becomes unforgeable even by the platform. Generate a keypair in the dashboard (Project → Settings → Signing keys) — the private key is downloaded once and never stored server-side. The SDK then signs every event's payload hash locally before it leaves your infrastructure:
const audit = new Audkit({
apiKey: process.env.AUDKIT_API_KEY!,
signingKeyId: process.env.AUDKIT_SIGNING_KEY_ID!,
signingPrivateKey: process.env.AUDKIT_SIGNING_PRIVATE_KEY!,
});The wrappers honor AUDKIT_SIGNING_KEY_ID / AUDKIT_SIGNING_PRIVATE_KEY env vars automatically. Once a signing key is registered for a project, ingest rejects unsigned events; verify() re-checks every signature against the registered public key — and keeps checking it even after retention has shredded the payload, because the signature covers the payload commitment, which the leaf retains.
Verification
const receipt = await audit.verify();
receipt.valid; // the whole tree rebuilt from sequence 1, leaf by leaf
receipt.checkedCount; // leaves rebuilt
receipt.treeSize; // size of the tree they reproduce
receipt.rootHash; // the root they reproduce
receipt.firstBreak; // { sequence, reason } — names the exact broken record
receipt.signature; // Ed25519 by the platform's service key — check it yourself
// The platform's own control-plane actions form a second verifiable stream:
const controlPlane = await audit.verify({ stream: "project-audit" });The client also checks the platform's work without asking it to grade itself. It pins the last signed tree head it saw and, after log()/query() (throttled) or whenever you call checkConsistency(), proves with local hash arithmetic that the served tree still contains the pinned one — throwing AudkitTamperError if it doesn't:
await audit.checkConsistency(); // proof-checked locally
const { sth } = await audit.getRoot(); // signed head: treeSize + rootHash
const { proof } = await audit.getInclusionProof({ eventId });Store receipts and heads over time and pin (treeSize, rootHash): a later tree that isn't consistent with an earlier one proves history was truncated or rewritten, even if it self-verifies. Roots are also published hourly to Sigstore Rekor, where the platform cannot edit them.
Offline, with nothing but an export and a root you trust:
npx audkit verify full --export events.ndjson --root a8d772…
# exit 0 = verified · 1 = tamper detected, and where · 2 = bad inputAPI
new Audkit({ apiKey, baseUrl?, signingKeyId?, signingPrivateKey? })audit.log(input)→{ id, status: "sealed" }audit.query(input?)→{ events, nextCursor? }audit.exportEvents(input?)→ streamed CSV / NDJSONaudit.verify(input?)→ signed tree receiptaudit.getRoot(stream?)→ current signed tree head + service public keyaudit.getInclusionProof({ eventId | leafIndex, treeSize? })→ RFC 6962 audit pathaudit.getConsistencyProof({ oldSize, newSize? })→ RFC 6962 consistency proofaudit.checkConsistency(stream?)→ locally proves the served tree extends the pinned one
Project API keys need matching scopes: log:write for ingestion, log:read for queries, log:export for exports, and log:verify for verification receipts. Errors throw an AudkitError with status and details.
Canonicalization (RFC 8785 JCS), RFC 6962 Merkle math, and Ed25519 primitives — shared byte-for-byte with the platform's verifier — live in audkit/protocol.
License
MIT
