Package Exports
- audkit
- audkit/ai
- audkit/nextjs
- audkit/protocol
Readme
audkit
Tamper-evident audit logging for TypeScript apps and AI agents.
Every event is sequenced, hash-linked to the one before it, and signed. 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() re-walks the chain and names the exact sequence where it breaks.
npm install audkitFive minutes to a verifiable trail
- Create a project at audkit.dev — the chain 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, headSequence: 18442, ... } — 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 — as pending, then success, denied, or failed. 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 a payload is deleted by retention, because the signature covers the retained payload hash.
Verification
const receipt = await audit.verify();
receipt.valid; // the whole chain re-walked from sequence 1
receipt.firstBreak; // { sequence, reason } — names the exact broken record
receipt.signature; // the receipt itself is signed by the platform
// The platform's own control-plane actions form a second verifiable stream:
const controlPlane = await audit.verify({ stream: "project-audit" });Store receipts over time and pin headSequence/headHash: a later receipt reporting a lower head — or a different hash at the same sequence — means history was truncated, even if the remaining chain still self-verifies.
API
new Audkit({ apiKey, baseUrl?, signingKeyId?, signingPrivateKey? })audit.log(input)→{ id, status: "queued" }audit.query(input?)→{ events, nextCursor? }audit.exportEvents(input?)→ streamed CSV / NDJSONaudit.verify(input?)→ signed chain receipt
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.
Low-level canonicalization and Ed25519 primitives (shared byte-for-byte with the platform's verifier) live in audkit/protocol.
License
MIT