Ecosystem/12audit-chain

audit-chain

npm version

GitHub: Stackbilt-dev/audit-chain · Apache-2.0

Part of the Stackbilt ecosystem. Pairs with @stackbilt/evidence-core to make E-E-A-T decisions tamper-provable, and with worker-observability for complementary monitoring. Used in production in the Stackbilder platform Content Provenance API — see Security for the full architecture.

Tamper-evident audit trail for Cloudflare Workers in under 200 lines of core logic. SHA-256 hash chaining with R2 immutability and D1 indexing. Zero production dependencies — uses only the Web Crypto API and Cloudflare bindings.


How it works

Every record includes a SHA-256 hash computed from the previous record’s hash plus the current record’s content:

Record 1                Record 2                Record 3
+-----------------+     +-----------------+     +-----------------+
| prev: GENESIS   |     | prev: hash_1    |     | prev: hash_2    |
| data: {...}     |---->| data: {...}     |---->| data: {...}     |
| hash: hash_1    |     | hash: hash_2    |     | hash: hash_3    |
+-----------------+     +-----------------+     +-----------------+

hash_N = SHA-256(prev_hash_bytes + JSON.stringify(record_data))

R2 is the immutable source of truth. D1 is a searchable index. If D1 is wiped, the chain in R2 remains intact and verifiable.


Setup

1. Install

npm install @stackbilt/audit-chain

2. Create the D1 table

npx wrangler d1 execute YOUR_DB --remote --file=node_modules/@stackbilt/audit-chain/schema.sql

Or copy schema.sql into your migrations directory.

3. Configure bindings

[[r2_buckets]]
binding = "AUDIT_BUCKET"
bucket_name = "your-audit-bucket"

[[d1_databases]]
binding = "AUDIT_DB"
database_name = "your-database"
database_id = "your-database-id"

Core API

writeRecord(bindings, opts)

Write a new audit record to R2 and index it in D1.

import { writeRecord, GENESIS_HASH } from '@stackbilt/audit-chain';
import type { AuditBindings } from '@stackbilt/audit-chain';

const bindings: AuditBindings = {
  AUDIT_BUCKET: env.AUDIT_BUCKET,
  AUDIT_DB: env.AUDIT_DB,
};

const { record, newChainHead } = await writeRecord(bindings, {
  namespace:  'orders',
  chainHead:  currentHead,   // start with GENESIS_HASH for a new chain
  event_type: 'order.placed',
  actor:      'user:alice',
  payload:    { order_id: 'ord_123', total: 99.99 },
  metadata:   { ip: '203.0.113.1' },
});

// Persist newChainHead for the next write

If the audit write fails, the audited action must not proceed.

Parameter Type Description
namespace string Chain namespace for isolation
chainHead string Current chain head hash
event_type string Application-defined event type
actor string Who or what caused the event
payload Record<string, unknown> Event data
metadata Record<string, unknown> Optional metadata

Returns { record: AuditRecord, newChainHead: string }.


verifyChain(bindings, namespace, opts?)

Verify hash chain integrity for an entire namespace. Recomputes every hash, confirms no branches, rejects missing records.

import { verifyChain } from '@stackbilt/audit-chain';

const result = await verifyChain(bindings, 'orders', {
  expectedChainHead:   currentHead,  // detect tail truncation
  expectedRecordCount: 42,           // detect missing records
});

if (!result.valid) {
  console.error(`Chain broken at ${result.broken_at}: ${result.error}`);
}

Returns VerificationResult: { valid, record_count, broken_at?, error? }.


queryIndex(db, opts)

Query the D1 index with filters.

import { queryIndex } from '@stackbilt/audit-chain';

const rows = await queryIndex(env.AUDIT_DB, {
  namespace:  'orders',
  event_type: 'order.placed',
  actor:      'user:alice',
  after:      '2026-01-01T00:00:00Z',
  limit:      50,
});
Parameter Type Description
namespace string Required
event_type string Filter by event type
actor string Filter by actor
after / before string ISO 8601 time bounds
limit number Max results (default 100, max 1000)
offset number Pagination offset

getRecord / getRecords

Retrieve records directly from R2 (authoritative):

const record = await getRecord(bindings, 'orders', recordId);
const all    = await getRecords(bindings, 'orders'); // sorted ascending

Chain head management

The library does not manage the chain head — you must persist newChainHead and pass it back on each write. Appends for a namespace must be serialized; concurrent writers that share the same head will create a branch.

Recommended options:

Storage Notes
Durable Object Single-writer guarantee — no race conditions. Recommended.
KV Works if writes are serialized externally
D1 row Fallback — query the latest record hash from the index

If you lose the head, reconstruct it by reading the most recent R2 record and using its hash. Pass the persisted head to verifyChain() as expectedChainHead to detect missing tail records.


D1 schema

CREATE TABLE IF NOT EXISTS audit_index (
  record_id        TEXT PRIMARY KEY,
  namespace        TEXT NOT NULL,
  event_type       TEXT NOT NULL,
  hash             TEXT NOT NULL,
  prev_hash        TEXT NOT NULL,
  actor            TEXT NOT NULL,
  timestamp        TEXT NOT NULL,
  payload_summary  TEXT,
  created_at       TEXT NOT NULL DEFAULT (datetime('now'))
);

CREATE INDEX IF NOT EXISTS idx_audit_namespace  ON audit_index(namespace);
CREATE INDEX IF NOT EXISTS idx_audit_event_type ON audit_index(event_type);
CREATE INDEX IF NOT EXISTS idx_audit_actor      ON audit_index(actor);
CREATE INDEX IF NOT EXISTS idx_audit_timestamp  ON audit_index(timestamp);
CREATE INDEX IF NOT EXISTS idx_audit_ns_ts      ON audit_index(namespace, timestamp);

Integration with evidence-core

@stackbilt/evidence-core produces content quality decisions; audit-chain makes them provable. The ./audit export in evidence-core shapes records for direct use with writeRecord().

import { validateEvidence } from '@stackbilt/evidence-core';
import { toAuditPayload } from '@stackbilt/evidence-core/audit';
import { writeRecord, getChainHead } from '@stackbilt/audit-chain';

const result    = await validateEvidence(content, { policyVersion: 'google_march_2024_core' });
const auditRec  = toAuditPayload(result, { contentId, namespace: `content:${contentId}` });
const chainHead = await getChainHead(bindings, auditRec.namespace);
await writeRecord(bindings, { ...auditRec, chainHead });

Canonical evidence event types

Event type When
evidence.validation.completed After validateEvidence() runs
evidence.assets.merged After mergeEvidence()
evidence.gaps.detected Missing signals identified
evidence.approval.granted Human or policy approves publish
evidence.publish.allowed All gates clear
evidence.publish.blocked One or more gates failed

Design principles

  • R2 is truth, D1 is convenience. If they diverge, R2 wins.
  • Append-only. No update or delete operation.
  • Fail loud. If the audit write fails, abort the audited operation.
  • Namespace isolation. Multiple independent chains in the same R2 bucket and D1 table.
  • Zero dependencies. Web Crypto API and Cloudflare bindings only.