Skip to main content

Three layers: a short note at the top, the key lines with our take in the middle, the full source at the bottom.

API route

audit.ts

The audit log route. Every change to your data leaves a record on this path.

Repo path apps/api/src/routes/audit.tsLanguage TypeScript

What this is

The code that records every change to your ledger — every read, every confirm, every export, every delete. The route lets you replay your own history; it does not let you edit it.

What it proves

Backs the promise that every action in your account is hash-chained and verifiable. Every state change leaves a record on this path. Read the promise →

What to look for in the source below

  • The endpoint accepts read queries only — no PUT, no DELETE, no PATCH.
  • Each audit entry includes a timestamp, a workspace id, and a description; identifiable details from the invoice content stay out of the log.
  • Rate-limited so a noisy reader cannot drown out the rest of the system.
Show the full file (251 lines)

250 lines

import { Hono } from "hono";
import type { Env } from "../env";
import { verifyChain } from "../lib/audit";
import { D1AuditStore } from "../lib/audit-d1";
import { signEnvelopeEd25519 } from "../lib/chain-head-signer";
import { requireAuth, type AuthContext } from "../middleware/require-auth";

export const audit = new Hono<{
  Bindings: Env;
  Variables: { auth: AuthContext };
}>();

// C-priv-1 Lane 3: the customer-visible chain-head canary lives at
// /v1/audit/chain-head (auth-gated, per-org). A small unauthenticated
// route at /v1/audit/chain-head/public/global serves the apex-domain
// canary; the Next route handler at apps/web/app/.well-known/chain-head
// proxies through this so the public commitment surface stays a
// single Worker. We register the public route BEFORE the requireAuth
// middleware so the auth guard does not gate it.

/**
 * GET /v1/audit/chain-head/public/global -- the apex chain-head
 * canary. Unauthenticated; reads the global signed envelope from R2
 * and serves it verbatim. The Next route handler at
 * /.well-known/chain-head proxies this. Cached aggressively (1h)
 * because the canary fires every 6h; serving a stale-by-up-to-1h
 * envelope is fine, and reduces the R2 read load.
 */
audit.get("/chain-head/public/global", async (c) => {
  const bucket = c.env.DOCUMENTS;
  if (!bucket) return c.json({ error: "r2_unbound" }, 503);
  const obj = await bucket.get("chain-head/global/latest.json");
  if (!obj) return c.json({ error: "no_canary_yet" }, 404);
  const body = await obj.text();
  return new Response(body, {
    status: 200,
    headers: {
      "content-type": "application/json",
      "cache-control": "public, max-age=3600",
    },
  });
});

audit.use("*", requireAuth);

// Page-size policy for GET /events. Default keeps the first paint
// cheap; the cap stops a caller from pulling an unbounded chain in one
// request (and stops a slow D1 scan).
const EVENTS_DEFAULT_LIMIT = 100;
const EVENTS_MAX_LIMIT = 500;

/**
 * GET /v1/audit/events -- list events for the caller's org, paginated.
 * Caller can only ever read their own org's events; the WHERE clause
 * lives inside the AuditStore implementation, not in the route.
 *
 * Query params:
 *   - `cursor` — the seq of the last event from the previous page;
 *     this page returns events with seq strictly greater. Omit for
 *     the first page.
 *   - `limit`  — page size (1..500, default 100). Out-of-range or
 *     non-numeric values clamp to the default/bounds rather than
 *     erroring, so a hand-built URL never 400s.
 *
 * Response: `{ events, count, next_cursor }`. `next_cursor` is the seq
 * to pass back for the next page, or null when this is the last page.
 */
audit.get("/events", async (c) => {
  const ctx = c.get("auth");
  const store = new D1AuditStore(c.env.DB);

  const rawCursor = Number(c.req.query("cursor"));
  const afterSeq =
    Number.isInteger(rawCursor) && rawCursor >= 0 ? rawCursor : undefined;

  const rawLimit = Number(c.req.query("limit"));
  const limit =
    Number.isInteger(rawLimit) && rawLimit > 0
      ? Math.min(rawLimit, EVENTS_MAX_LIMIT)
      : EVENTS_DEFAULT_LIMIT;

  // UR3-13: optional per-resource filter (the /document/[id] audit
  // panel passes the document id) so a single document's chain can be
  // read without paging the whole org history.
  const rawTargetRef = c.req.query("target_ref");
  const targetRef =
    typeof rawTargetRef === "string" && rawTargetRef.length > 0
      ? rawTargetRef
      : undefined;

  // Over-fetch by one to learn whether another page exists without a
  // second COUNT query.
  const page = await store.listForOrg(ctx.org_id, {
    afterSeq,
    limit: limit + 1,
    targetRef,
  });
  const hasMore = page.length > limit;
  const events = hasMore ? page.slice(0, limit) : page;
  const next_cursor = hasMore ? (events[events.length - 1]?.seq ?? null) : null;

  return c.json({ events, count: events.length, next_cursor });
});

// UR2-2b: signed audit-log export.
const EXPORT_DOMAIN = "muntin-audit-export-v1";
const EXPORT_DAILY_CAP = 3;

/**
 * GET /v1/audit/events/export.json.sig -- a signed, downloadable JSON
 * envelope of the caller's whole audit chain. The customer (or their
 * regulator) verifies the Ed25519 signature against the published
 * audit-export public key over `domain "\n" canonical(payload)`.
 *
 * Signing key is AUDIT_EXPORT_SIGNING_PRIVATE_KEY — deliberately
 * separate from the apex canary key (founder decision F34). When the
 * secret is unset the route is inert (503 export_signing_unconfigured),
 * so it ships dark until the operator provisions the key.
 *
 * Daily cap: EXPORT_DAILY_CAP signed exports per org per day, tracked
 * in AUTH_KV. The counter is a fixed-window per-day bound (not an exact
 * quota — KV RMW is non-atomic) and fails OPEN when AUTH_KV is absent
 * (dev/test). A slot is consumed only on a SUCCESSFUL signed response.
 */
audit.get("/events/export.json.sig", async (c) => {
  const ctx = c.get("auth");
  const privkey = c.env.AUDIT_EXPORT_SIGNING_PRIVATE_KEY;
  if (!privkey) {
    return c.json({ error: "export_signing_unconfigured" }, 503);
  }

  const kv = c.env.AUTH_KV;
  const day = new Date().toISOString().slice(0, 10);
  const capKey = `audit-export:${ctx.org_id}:${day}`;
  let used = 0;
  if (kv) {
    const raw = await kv.get(capKey);
    used = raw ? parseInt(raw, 10) || 0 : 0;
    if (used >= EXPORT_DAILY_CAP) {
      return c.json(
        { error: "export_rate_limited", daily_cap: EXPORT_DAILY_CAP },
        429,
      );
    }
  }

  const store = new D1AuditStore(c.env.DB);
  const events = await store.listForOrg(ctx.org_id);
  const head = events.length > 0 ? events[events.length - 1]! : null;

  const payload = {
    kind: "muntin-audit-export",
    version: 1,
    org_id: ctx.org_id,
    generated_at: new Date().toISOString(),
    count: events.length,
    chain_head:
      head === null ? null : { seq: head.seq, event_hash: head.event_hash },
    events,
  };

  let signature_b64: string;
  try {
    signature_b64 = await signEnvelopeEd25519(privkey, EXPORT_DOMAIN, payload);
  } catch {
    // An undecodable key is a misconfig, not a client error — surface
    // the same shape as "unset" so the UI shows one message.
    return c.json({ error: "export_signing_unconfigured" }, 503);
  }

  // Consume a daily slot only now that we have a real signed export.
  if (kv) {
    await kv.put(capKey, String(used + 1), { expirationTtl: 60 * 60 * 48 });
  }

  const envelope = {
    payload,
    signature_b64,
    signature_domain: EXPORT_DOMAIN,
    signing_kid: "audit-export-v1",
  };
  return new Response(JSON.stringify(envelope, null, 2), {
    status: 200,
    headers: {
      "content-type": "application/json",
      "content-disposition": `attachment; filename="muntin-audit-export-${day}.json"`,
      "cache-control": "no-store",
    },
  });
});

/**
 * GET /v1/audit/verify -- recompute the chain hashes for the caller's
 * org and return whether the chain is intact. Customers can run this
 * any time and get cryptographic confirmation that nothing has been
 * mutated under them.
 */
audit.get("/verify", async (c) => {
  const ctx = c.get("auth");
  const store = new D1AuditStore(c.env.DB);
  const result = await verifyChain(store, ctx.org_id);
  return c.json(result);
});

/**
 * GET /v1/audit/chain-head -- the latest signed chain-head canary
 * envelope for the caller's org. Read from R2 at
 * `chain-head/{org_id}/latest.json`; 404 when no canary has fired
 * yet (private-beta orgs may sit in this state until the first
 * 6-hour tick after their first audit event).
 *
 * Response shape:
 *   {
 *     payload: ChainHeadPayload,
 *     signature_b64: string,
 *     signing_kid: string,
 *     computed_at: string,
 *     well_known_url: string  // the apex public-canary URL
 *   }
 *
 * The `well_known_url` is the apex-domain commitment URL so the
 * UI's "Verify this yourself" CTA can deep-link to a stable, public
 * canary recipe.
 */
audit.get("/chain-head", async (c) => {
  const ctx = c.get("auth");
  const bucket = c.env.DOCUMENTS;
  if (!bucket) return c.json({ error: "r2_unbound" }, 503);
  const key = `chain-head/${ctx.org_id}/latest.json`;
  const obj = await bucket.get(key);
  if (!obj) return c.json({ error: "no_canary_yet" }, 404);
  let envelope: {
    payload?: { computed_at?: string };
    signature_b64?: string;
    signing_kid?: string;
  };
  try {
    envelope = (await obj.json()) as typeof envelope;
  } catch {
    return c.json({ error: "canary_unreadable" }, 500);
  }
  const apex = c.env.APP_ORIGIN ?? "https://muntin.digital";
  return c.json({
    payload: envelope.payload ?? null,
    signature_b64: envelope.signature_b64 ?? null,
    signing_kid: envelope.signing_kid ?? null,
    computed_at: envelope.payload?.computed_at ?? null,
    well_known_url: `${apex}/.well-known/chain-head`,
  });
});

See also

This is the file as it lives at the moment of this build. The canonical history lives in git. If you want the full history or a specific commit, write to hello@muntin.digital.

audit.ts · Verify · Muntin Ledger · Muntin