JSPM

audkit

0.2.0
  • ESM via JSPM
  • ES Module Entrypoint
  • Export Map
  • Keywords
  • License
  • Repository URL
  • TypeScript Types
  • README
  • Created
  • Published
  • Downloads 10
  • Score
    100M100P100Q59019F
  • License MIT

Tamper-evident audit logging for TypeScript apps and AI agents — hash-chained, Ed25519-signed events with one-call verification.

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 audkit

Five minutes to a verifiable trail

  1. Create a project at audkit.dev — the chain starts at sequence 1.
  2. Mint a scoped API key (Project → Settings → Keys) and set it as AUDKIT_API_KEY.
  3. 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, ... } — signed

Route 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 / NDJSON
  • audit.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