6. Audit Module
What we want
Section titled “What we want”A “Recent activity” panel that lists every write to the system, with a timestamp. We will not change a single existing component. The audit module observes events the rest of the app already emits.
src/audit-module.ts
Section titled “src/audit-module.ts”import { createProviderToken, defineKitModule, type KitEvent, type KitRuntime,} from '@atheory-ai/kitsune-core'
export type AuditEntry = { id: string type: string timestamp: number surface?: string entity?: { type: string; id: string } detail?: Record<string, unknown>}
export type AuditService = { list(): AuditEntry[] subscribe(listener: () => void): () => void}
export const AuditToken = createProviderToken<AuditService>('audit')
const TRACKED = new Set([ 'note.created', 'note.updated', 'note.deleted',])
export function auditModule(maxEntries = 50) { const entries: AuditEntry[] = [] const listeners = new Set<() => void>()
const notify = () => listeners.forEach((listener) => listener())
const record = (event: KitEvent) => { if (!TRACKED.has(event.type)) return entries.unshift({ id: event.id, type: event.type, timestamp: event.timestamp, surface: event.context?.surface as string | undefined, entity: event.context?.entity ?? (event.payload?.note as { id: string } | undefined), detail: event.payload as Record<string, unknown> | undefined, }) if (entries.length > maxEntries) entries.length = maxEntries notify() }
const service: AuditService = { list: () => entries.slice(), subscribe: (listener) => { listeners.add(listener) return () => listeners.delete(listener) }, }
return defineKitModule({ name: 'audit', setup(runtime: KitRuntime) { runtime.provide(AuditToken, service) }, events: { 'note.created': record, 'note.updated': record, 'note.deleted': record, }, })}The module:
- Subscribes to three events.
- Maintains a fixed-size ring of entries.
- Exposes its log via a provider token so a panel can render it.
A panel that renders the audit
Section titled “A panel that renders the audit”src/render.ts:
import type { AuditEntry } from './audit-module.js'
export function renderAuditPanel(entries: AuditEntry[]): string { if (entries.length === 0) { return `<p class="muted">No activity yet.</p>` }
return ` <ol class="audit"> ${entries.map((entry) => ` <li> <strong>${entry.type}</strong> <small>${new Date(entry.timestamp).toLocaleString()}</small> ${entry.entity ? `<small>${escape(entry.entity.type)}/${escape(entry.entity.id)}</small>` : ''} </li> `).join('')} </ol> `}CSS:
.audit { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.5rem; }.audit li { font-size: 0.875rem; display: grid; gap: 0.125rem; }.audit small { color: light-dark(oklch(45% 0 0), oklch(70% 0 0)); }Wire the panel
Section titled “Wire the panel”Add a third column to the layout in renderApp:
export function renderApp(notes: Note[], audit: AuditEntry[]): string { return ` <div class="app-grid app-grid--three"> <section class="sidebar">${renderNoteList(notes)}</section> <section id="editor" class="editor"> <kit-boundary surface="empty-editor" feature="notes"> <p class="muted">Select a note from the left.</p> </kit-boundary> </section> <aside class="aside"> <kit-boundary surface="audit-panel"> <h2>Recent activity</h2> ${renderAuditPanel(audit)} </kit-boundary> </aside> </div> `}CSS:
.app-grid--three { grid-template-columns: minmax(14rem, 18rem) 1fr minmax(14rem, 18rem); }In main.ts:
import { auditModule, AuditToken } from './audit-module.js'
shell.modules = [ notesModule(seedNotes), auditModule(), notificationModule(), dialogModule(), debugModule(),]
queueMicrotask(() => { const notes = shell.runtime.inject(NotesToken) const audit = shell.runtime.inject(AuditToken) if (!notes || !audit) return
const draw = () => { document.getElementById('main')!.innerHTML = renderApp(notes.list(), audit.list()) }
draw() notes.subscribe(draw) audit.subscribe(draw)})What we have now
Section titled “What we have now”Create a note, update one, delete one. The right panel updates with each action. No other code changed. Not the form. Not the cards. Not the editor. The audit module observed events and the panel re-rendered.
This is the architectural payoff in a single chapter. Adding capability did not require touching the UI. The button that creates a note doesn’t know audit exists.
A note on observation vs imperative writes
Section titled “A note on observation vs imperative writes”You’ll notice the audit module subscribes to note.created, not note.create. The form dispatches the command (note.create), the persistence module handles it and emits the event (note.created). The audit module observes the event.
This separation matters: if you replaced notesModule with a Supabase-backed version, audit would still work. The audit module observes the fact, not the implementation.
What’s next
Section titled “What’s next”A delete-confirmation dialog using native <dialog>. We’ll wire it through the metadata protocol so deletion is a two-step interaction: open the dialog, confirm, dispatch.