From 12e7dad0257ab22e205ffeea917eef7d920aaf1d Mon Sep 17 00:00:00 2001 From: f-amine Date: Mon, 29 Jun 2026 12:23:48 +0100 Subject: [PATCH] refactor(audit): single recordAuditLog() seam for audit writes (#110) Replace 17 hand-rolled `db.insert(auditLog)...onConflictDoNothing()` blocks across 9 server-side action handlers with one `recordAuditLog()` seam in `@vibestack/db`. The id strategy, conflict policy, and append-only invariant now live in one place instead of being copy-pasted; callers no longer import `randomUUID`, the `auditLog` schema, or `db` just to record an event. - New `packages/db/src/audit.ts` + unit test (the interface is now the test surface; audit writes previously had no test). - Migrated: api/{affiliate,gdpr,referral}, auth/welcome, web onboarding + polar/resend webhooks, admin users + feature-flags actions. - Seed script left as-is (bulk insert, not an action handler). - CONTEXT.md: name the seam as the only sanctioned write path. Closes #110 Co-Authored-By: Claude Opus 4.8 (1M context) --- CONTEXT.md | 4 +- .../app/[locale]/feature-flags/_actions.ts | 22 ++--- apps/admin/src/app/[locale]/users/_actions.ts | 22 ++--- apps/web/src/app/api/webhooks/polar/route.ts | 22 ++--- apps/web/src/app/api/webhooks/resend/route.ts | 39 ++++----- apps/web/src/app/onboarding/_actions.ts | 21 ++--- packages/api/src/affiliate.ts | 72 ++++++---------- packages/api/src/gdpr.ts | 71 ++++++---------- packages/api/src/referral.ts | 45 ++++------ packages/auth/src/lib/welcome.ts | 21 ++--- packages/db/package.json | 6 +- packages/db/src/audit.test.ts | 83 +++++++++++++++++++ packages/db/src/audit.ts | 42 ++++++++++ packages/db/src/index.ts | 2 + pnpm-lock.yaml | 3 + 15 files changed, 265 insertions(+), 210 deletions(-) create mode 100644 packages/db/src/audit.test.ts create mode 100644 packages/db/src/audit.ts diff --git a/CONTEXT.md b/CONTEXT.md index 306d9c9..10f2d19 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -41,14 +41,14 @@ Domain language for how a fresh copy of vibestack becomes someone's product — - **Auth** (`packages/auth`) — sign-in/up, sessions, OAuth, magic links, passkeys, 2FA, password reset, email verification. Owns the `user`, `session`, `account`, `verification`, `passkey`, `two_factor` tables. - **Org** (`packages/auth/plugin organization`) — orgs, members, invitations. Owns the `organization`, `member`, `invitation` tables. - **Billing** (`packages/billing` + `packages/db/schema/billing.ts`) — Polar customer + subscription mirror. Listens for Polar webhooks. -- **Audit** (`packages/db/schema/audit.ts`) — append-only log. Written from server-side action handlers; never directly by clients. +- **Audit** (`packages/db/schema/audit.ts`) — append-only log. Written from server-side action handlers via the `recordAuditLog()` seam (`packages/db/src/audit.ts`); never directly by clients. - **Identity surface** (apps) — `web`, `marketing`, `admin` share auth via Better Auth cookies on the same root domain. ## Invariants - A `Member` always belongs to exactly one `Organization` and exactly one `User`. - A `Session` is invalidated on user ban, password change, or explicit revoke. -- `AuditLog` rows are immutable once written. No `UPDATE`/`DELETE` allowed. +- `AuditLog` rows are immutable once written. No `UPDATE`/`DELETE` allowed. All writes go through `recordAuditLog()` (`@vibestack/db`) — the only sanctioned write path; never `db.insert(auditLog)` directly outside that seam (the seed script excepted). - All emails are sent via `@vibestack/email` (Resend). Never call the email provider directly from app code. - All R2 access goes through `@vibestack/storage`. Never instantiate an S3 client elsewhere. - All cross-app API calls go through tRPC procedures in `packages/api`. No direct DB queries from `apps/marketing`. diff --git a/apps/admin/src/app/[locale]/feature-flags/_actions.ts b/apps/admin/src/app/[locale]/feature-flags/_actions.ts index 060dffe..5a48614 100644 --- a/apps/admin/src/app/[locale]/feature-flags/_actions.ts +++ b/apps/admin/src/app/[locale]/feature-flags/_actions.ts @@ -1,9 +1,7 @@ "use server"; import "server-only"; -import { randomUUID } from "node:crypto"; -import { db } from "@vibestack/db"; -import { auditLog } from "@vibestack/db/schema/audit"; +import { recordAuditLog } from "@vibestack/db"; import { toggleFlag } from "@vibestack/feature-flags/admin"; import { revalidatePath } from "next/cache"; @@ -21,17 +19,13 @@ export async function toggleFlagAction(input: { if (!result.ok) { return { ok: false, error: result.error }; } - await db - .insert(auditLog) - .values({ - id: randomUUID(), - actorUserId: session.user.id, - action: "feature_flag.toggled", - targetType: "feature_flag", - targetId: String(input.id), - metadata: { key: input.key, active: input.active }, - }) - .onConflictDoNothing(); + await recordAuditLog({ + action: "feature_flag.toggled", + actorUserId: session.user.id, + targetType: "feature_flag", + targetId: String(input.id), + metadata: { key: input.key, active: input.active }, + }); revalidatePath("/[locale]/feature-flags", "page"); return { ok: true }; } diff --git a/apps/admin/src/app/[locale]/users/_actions.ts b/apps/admin/src/app/[locale]/users/_actions.ts index af81e77..7c92b4e 100644 --- a/apps/admin/src/app/[locale]/users/_actions.ts +++ b/apps/admin/src/app/[locale]/users/_actions.ts @@ -1,10 +1,8 @@ "use server"; import "server-only"; -import { randomUUID } from "node:crypto"; import { auth } from "@vibestack/auth"; -import { db } from "@vibestack/db"; -import { auditLog } from "@vibestack/db/schema/audit"; +import { recordAuditLog } from "@vibestack/db"; import { revalidatePath } from "next/cache"; import { headers } from "next/headers"; @@ -18,17 +16,13 @@ async function audit( targetUserId: string, metadata: Record = {}, ): Promise { - await db - .insert(auditLog) - .values({ - id: randomUUID(), - actorUserId: actorId, - action, - targetType: "user", - targetId: targetUserId, - metadata, - }) - .onConflictDoNothing(); + await recordAuditLog({ + action, + actorUserId: actorId, + targetType: "user", + targetId: targetUserId, + metadata, + }); } function callerHeaders() { diff --git a/apps/web/src/app/api/webhooks/polar/route.ts b/apps/web/src/app/api/webhooks/polar/route.ts index 545e06f..418bebf 100644 --- a/apps/web/src/app/api/webhooks/polar/route.ts +++ b/apps/web/src/app/api/webhooks/polar/route.ts @@ -3,9 +3,8 @@ // dashboard delivers events via Svix; we re-implement Svix's signature // algorithm inline so we don't pull a heavy dep. -import { createHmac, randomUUID, timingSafeEqual } from "node:crypto"; -import { db } from "@vibestack/db"; -import { auditLog } from "@vibestack/db/schema/audit"; +import { createHmac, timingSafeEqual } from "node:crypto"; +import { db, recordAuditLog } from "@vibestack/db"; import { polarCustomer, subscription } from "@vibestack/db/schema/billing"; import { env } from "@vibestack/env/server"; import { eq } from "drizzle-orm"; @@ -144,17 +143,12 @@ async function upsertSubscription(payload: PolarSubscription): Promise { } async function audit(action: string, targetId: string, metadata: unknown) { - await db - .insert(auditLog) - .values({ - id: randomUUID(), - actorUserId: null, - action, - targetType: "polar", - targetId, - metadata: metadata as Record, - }) - .onConflictDoNothing(); + await recordAuditLog({ + action, + targetType: "polar", + targetId, + metadata: metadata as Record, + }); } export async function POST(request: Request) { diff --git a/apps/web/src/app/api/webhooks/resend/route.ts b/apps/web/src/app/api/webhooks/resend/route.ts index b7dd05b..930579b 100644 --- a/apps/web/src/app/api/webhooks/resend/route.ts +++ b/apps/web/src/app/api/webhooks/resend/route.ts @@ -1,6 +1,5 @@ -import { createHmac, randomUUID, timingSafeEqual } from "node:crypto"; -import { db } from "@vibestack/db"; -import { auditLog } from "@vibestack/db/schema/audit"; +import { createHmac, timingSafeEqual } from "node:crypto"; +import { db, recordAuditLog } from "@vibestack/db"; import { user as userTable } from "@vibestack/db/schema/auth"; import { env } from "@vibestack/env/server"; import { eq } from "drizzle-orm"; @@ -148,25 +147,21 @@ export async function POST(request: Request) { const recipient = recipientOf(payload); const actorUserId = await actorIdFor(recipient); - await db - .insert(auditLog) - .values({ - id: randomUUID(), - actorUserId, - action: actionFor(payload.type), - targetType: "email", - targetId: payload.data?.email_id ?? null, - metadata: { - to: recipient, - from: payload.data?.from, - subject: payload.data?.subject, - bounce: payload.data?.bounce, - complaint: payload.data?.complaint, - svixId: id, - createdAt: payload.created_at, - }, - }) - .onConflictDoNothing(); + await recordAuditLog({ + action: actionFor(payload.type), + actorUserId, + targetType: "email", + targetId: payload.data?.email_id ?? undefined, + metadata: { + to: recipient, + from: payload.data?.from, + subject: payload.data?.subject, + bounce: payload.data?.bounce, + complaint: payload.data?.complaint, + svixId: id, + createdAt: payload.created_at, + }, + }); return NextResponse.json({ ok: true }); } diff --git a/apps/web/src/app/onboarding/_actions.ts b/apps/web/src/app/onboarding/_actions.ts index 8799e61..fe60e98 100644 --- a/apps/web/src/app/onboarding/_actions.ts +++ b/apps/web/src/app/onboarding/_actions.ts @@ -1,9 +1,8 @@ "use server"; import "server-only"; -import { randomUUID } from "node:crypto"; import { auth } from "@vibestack/auth"; -import { db } from "@vibestack/db"; +import { db, recordAuditLog } from "@vibestack/db"; import { auditLog } from "@vibestack/db/schema/audit"; import { and, eq } from "drizzle-orm"; import { headers } from "next/headers"; @@ -38,17 +37,13 @@ export async function completeOnboardingAction(input: { if (already.length > 0) { return { ok: true }; } - await db - .insert(auditLog) - .values({ - id: randomUUID(), - actorUserId: user.id, - action: ONBOARDING_DONE_ACTION, - targetType: "user", - targetId: user.id, - metadata: { skipped: input.skipped ?? false }, - }) - .onConflictDoNothing(); + await recordAuditLog({ + action: ONBOARDING_DONE_ACTION, + actorUserId: user.id, + targetType: "user", + targetId: user.id, + metadata: { skipped: input.skipped ?? false }, + }); return { ok: true }; } catch (err) { return { diff --git a/packages/api/src/affiliate.ts b/packages/api/src/affiliate.ts index 1a489be..02d2f0e 100644 --- a/packages/api/src/affiliate.ts +++ b/packages/api/src/affiliate.ts @@ -4,14 +4,13 @@ import "server-only"; import { randomBytes, randomUUID } from "node:crypto"; -import { db } from "@vibestack/db"; +import { db, recordAuditLog } from "@vibestack/db"; import { affiliate, affiliateClick, affiliatePayout, affiliateSignup, } from "@vibestack/db/schema/affiliate"; -import { auditLog } from "@vibestack/db/schema/audit"; import { env } from "@vibestack/env/server"; import { and, count, desc, eq, inArray } from "drizzle-orm"; @@ -40,17 +39,13 @@ export async function enroll(opts: { userId: string }): Promise<{ code, commissionRate: env.AFFILIATE_DEFAULT_RATE, }); - await db - .insert(auditLog) - .values({ - id: randomUUID(), - actorUserId: opts.userId, - action: "affiliate.enrolled", - targetType: "affiliate", - targetId: id, - metadata: { code }, - }) - .onConflictDoNothing(); + await recordAuditLog({ + action: "affiliate.enrolled", + actorUserId: opts.userId, + targetType: "affiliate", + targetId: id, + metadata: { code }, + }); return { id, code }; } @@ -116,16 +111,12 @@ export async function attributeSignup(opts: { referredUserId: opts.referredUserId, }) .onConflictDoNothing(); - await db - .insert(auditLog) - .values({ - id: randomUUID(), - actorUserId: opts.referredUserId, - action: "affiliate.signup", - targetType: "affiliate", - targetId: aff.id, - }) - .onConflictDoNothing(); + await recordAuditLog({ + action: "affiliate.signup", + actorUserId: opts.referredUserId, + targetType: "affiliate", + targetId: aff.id, + }); } export async function stats(affiliateId: string): Promise<{ @@ -186,17 +177,12 @@ export async function requestPayout(opts: { affiliateId: opts.affiliateId, amountCents: opts.amountCents, }); - await db - .insert(auditLog) - .values({ - id: randomUUID(), - actorUserId: null, - action: "affiliate.payout.requested", - targetType: "affiliate_payout", - targetId: id, - metadata: { amountCents: opts.amountCents }, - }) - .onConflictDoNothing(); + await recordAuditLog({ + action: "affiliate.payout.requested", + targetType: "affiliate_payout", + targetId: id, + metadata: { amountCents: opts.amountCents }, + }); return { id }; } @@ -227,17 +213,13 @@ export async function decidePayout(opts: { decidedAt: new Date(), }) .where(eq(affiliatePayout.id, opts.id)); - await db - .insert(auditLog) - .values({ - id: randomUUID(), - actorUserId: opts.actorId ?? null, - action: `affiliate.payout.${opts.status}`, - targetType: "affiliate_payout", - targetId: opts.id, - metadata: { polarPayoutId: opts.polarPayoutId }, - }) - .onConflictDoNothing(); + await recordAuditLog({ + action: `affiliate.payout.${opts.status}`, + actorUserId: opts.actorId ?? null, + targetType: "affiliate_payout", + targetId: opts.id, + metadata: { polarPayoutId: opts.polarPayoutId }, + }); } export async function topReferrers(limit = 100) { diff --git a/packages/api/src/gdpr.ts b/packages/api/src/gdpr.ts index 9652e6c..9c347bf 100644 --- a/packages/api/src/gdpr.ts +++ b/packages/api/src/gdpr.ts @@ -5,7 +5,7 @@ import "server-only"; import { randomUUID } from "node:crypto"; import { PutObjectCommand } from "@aws-sdk/client-s3"; -import { db } from "@vibestack/db"; +import { db, recordAuditLog } from "@vibestack/db"; import { auditLog } from "@vibestack/db/schema/audit"; import { member, user as userTable } from "@vibestack/db/schema/auth"; import { polarCustomer, subscription } from "@vibestack/db/schema/billing"; @@ -71,17 +71,13 @@ export async function exportUserData(userId: string): Promise<{ const url = await presignDownload(key, EXPORT_LINK_TTL_SECONDS); - await db - .insert(auditLog) - .values({ - id: randomUUID(), - actorUserId: userId, - action: "gdpr.export.generated", - targetType: "user", - targetId: userId, - metadata: { key, bytes: body.byteLength }, - }) - .onConflictDoNothing(); + await recordAuditLog({ + action: "gdpr.export.generated", + actorUserId: userId, + targetType: "user", + targetId: userId, + metadata: { key, bytes: body.byteLength }, + }); return { key, url, expiresInSeconds: EXPORT_LINK_TTL_SECONDS }; } @@ -105,17 +101,13 @@ export async function scheduleAccountDeletion(input: { target: accountDeletion.userId, set: { scheduledAt, canceledAt: null, reason: input.reason ?? null }, }); - await db - .insert(auditLog) - .values({ - id: randomUUID(), - actorUserId: input.userId, - action: "gdpr.deletion.scheduled", - targetType: "user", - targetId: input.userId, - metadata: { scheduledAt: scheduledAt.toISOString() }, - }) - .onConflictDoNothing(); + await recordAuditLog({ + action: "gdpr.deletion.scheduled", + actorUserId: input.userId, + targetType: "user", + targetId: input.userId, + metadata: { scheduledAt: scheduledAt.toISOString() }, + }); return { scheduledAt }; } @@ -124,16 +116,12 @@ export async function cancelAccountDeletion(userId: string): Promise { .update(accountDeletion) .set({ canceledAt: new Date() }) .where(eq(accountDeletion.userId, userId)); - await db - .insert(auditLog) - .values({ - id: randomUUID(), - actorUserId: userId, - action: "gdpr.deletion.canceled", - targetType: "user", - targetId: userId, - }) - .onConflictDoNothing(); + await recordAuditLog({ + action: "gdpr.deletion.canceled", + actorUserId: userId, + targetType: "user", + targetId: userId, + }); } export async function pendingDeletion(userId: string) { @@ -167,17 +155,12 @@ export async function purgeExpiredDeletions(): Promise<{ purged: number }> { .update(accountDeletion) .set({ purgedAt }) .where(eq(accountDeletion.id, row.id)), - db - .insert(auditLog) - .values({ - id: randomUUID(), - actorUserId: null, - action: "gdpr.deletion.purged", - targetType: "user", - targetId: row.userId, - metadata: { scheduledAt: row.scheduledAt.toISOString() }, - }) - .onConflictDoNothing(), + recordAuditLog({ + action: "gdpr.deletion.purged", + targetType: "user", + targetId: row.userId, + metadata: { scheduledAt: row.scheduledAt.toISOString() }, + }), ]); await db.delete(userTable).where(eq(userTable.id, row.userId)); }), diff --git a/packages/api/src/referral.ts b/packages/api/src/referral.ts index e79f856..eba0857 100644 --- a/packages/api/src/referral.ts +++ b/packages/api/src/referral.ts @@ -4,8 +4,7 @@ import "server-only"; import { randomUUID } from "node:crypto"; -import { db } from "@vibestack/db"; -import { auditLog } from "@vibestack/db/schema/audit"; +import { db, recordAuditLog } from "@vibestack/db"; import { user as userTable } from "@vibestack/db/schema/auth"; import { referral } from "@vibestack/db/schema/referral"; import { env } from "@vibestack/env/server"; @@ -44,17 +43,13 @@ export async function inviteFriend(input: { rewardCents: env.REFERRAL_CREDIT_CENTS, }) .onConflictDoNothing(); - await db - .insert(auditLog) - .values({ - id: randomUUID(), - actorUserId: input.referrerUserId, - action: "referral.invited", - targetType: "referral", - targetId: id, - metadata: { email: input.email.toLowerCase() }, - }) - .onConflictDoNothing(); + await recordAuditLog({ + action: "referral.invited", + actorUserId: input.referrerUserId, + targetType: "referral", + targetId: id, + metadata: { email: input.email.toLowerCase() }, + }); return { id }; } @@ -91,20 +86,16 @@ export async function recordAcceptance(input: { acceptedAt, }) .where(eq(referral.id, row.id)), - db - .insert(auditLog) - .values({ - id: randomUUID(), - actorUserId: input.newUserId, - action: "referral.accepted", - targetType: "referral", - targetId: row.id, - metadata: { - referrerUserId: row.referrerUserId, - rewardCents: row.rewardCents, - }, - }) - .onConflictDoNothing(), + recordAuditLog({ + action: "referral.accepted", + actorUserId: input.newUserId, + targetType: "referral", + targetId: row.id, + metadata: { + referrerUserId: row.referrerUserId, + rewardCents: row.rewardCents, + }, + }), ]), ), ); diff --git a/packages/auth/src/lib/welcome.ts b/packages/auth/src/lib/welcome.ts index 4095996..be119df 100644 --- a/packages/auth/src/lib/welcome.ts +++ b/packages/auth/src/lib/welcome.ts @@ -2,10 +2,9 @@ // `emailVerified = true`. Idempotency comes from an audit_log row — we never // store a separate "welcome_email_sent_at" column, and never re-send. -import { createDb } from "@vibestack/db"; +import { createDb, recordAuditLog } from "@vibestack/db"; import { auditLog } from "@vibestack/db/schema/audit"; import { and, eq } from "drizzle-orm"; -import { nanoid } from "nanoid"; import { sendWelcome } from "./send"; @@ -59,15 +58,11 @@ export async function maybeSendWelcomeOnVerify(user: UserLike): Promise { return; } - await db() - .insert(auditLog) - .values({ - id: nanoid(), - actorUserId: user.id, - action: WELCOME_AUDIT_ACTION, - targetType: "user", - targetId: user.id, - metadata: { email: user.email }, - }) - .onConflictDoNothing(); + await recordAuditLog({ + action: WELCOME_AUDIT_ACTION, + actorUserId: user.id, + targetType: "user", + targetId: user.id, + metadata: { email: user.email }, + }); } diff --git a/packages/db/package.json b/packages/db/package.json index edffcc9..7069c82 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -18,7 +18,8 @@ "db:start": "docker compose -f ../../docker-compose.yml up -d", "db:watch": "docker compose -f ../../docker-compose.yml up", "db:stop": "docker compose -f ../../docker-compose.yml stop", - "db:down": "docker compose -f ../../docker-compose.yml down" + "db:down": "docker compose -f ../../docker-compose.yml down", + "test": "vitest run" }, "dependencies": { "@vibestack/env": "workspace:*", @@ -32,6 +33,7 @@ "@types/pg": "catalog:", "drizzle-kit": "catalog:", "tsx": "catalog:", - "typescript": "catalog:" + "typescript": "catalog:", + "vitest": "catalog:" } } diff --git a/packages/db/src/audit.test.ts b/packages/db/src/audit.test.ts new file mode 100644 index 0000000..d4f416a --- /dev/null +++ b/packages/db/src/audit.test.ts @@ -0,0 +1,83 @@ +// recordAuditLog is the single sanctioned write path into the Audit +// context (CONTEXT.md invariant: audit rows are written server-side, never +// by clients, and are append-only). The interface is the test surface — we +// assert the seam generates an id, tolerates conflicts, and maps the +// optional fields without touching a live Postgres. The db module is mocked +// so importing this never constructs a real drizzle client. +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const capture = vi.hoisted(() => ({ + rows: [] as Array>, + conflictHandled: 0, +})); + +const fakeDb = vi.hoisted(() => ({ + insert: (_table: unknown) => ({ + values: (row: Record) => { + capture.rows.push(row); + return { + onConflictDoNothing: () => { + capture.conflictHandled += 1; + return Promise.resolve(); + }, + }; + }, + }), +})); + +vi.mock("./index", () => ({ db: fakeDb })); + +import { recordAuditLog } from "./audit"; + +beforeEach(() => { + capture.rows = []; + capture.conflictHandled = 0; +}); + +describe("recordAuditLog", () => { + it("generates an id and tolerates a conflict on every write", async () => { + await recordAuditLog({ action: "affiliate.enrolled" }); + await recordAuditLog({ action: "affiliate.enrolled" }); + + expect(capture.rows).toHaveLength(2); + expect(capture.conflictHandled).toBe(2); + const [a, b] = capture.rows; + expect(a.id).toBeTruthy(); + expect(b.id).toBeTruthy(); + expect(a.id).not.toBe(b.id); + }); + + it("maps a full event onto the audit row", async () => { + await recordAuditLog({ + action: "user.banned", + targetType: "user", + targetId: "u_123", + actorUserId: "admin_1", + organizationId: "org_1", + metadata: { reason: "spam" }, + request: { ipAddress: "1.2.3.4", userAgent: "curl/8" }, + }); + + const row = capture.rows[0]; + expect(row).toMatchObject({ + action: "user.banned", + targetType: "user", + targetId: "u_123", + actorUserId: "admin_1", + organizationId: "org_1", + metadata: { reason: "spam" }, + ipAddress: "1.2.3.4", + userAgent: "curl/8", + }); + }); + + it("defaults the optional actor/org/request fields to null when omitted", async () => { + await recordAuditLog({ action: "onboarding.completed" }); + + const row = capture.rows[0]; + expect(row.actorUserId).toBeNull(); + expect(row.organizationId).toBeNull(); + expect(row.ipAddress).toBeNull(); + expect(row.userAgent).toBeNull(); + }); +}); diff --git a/packages/db/src/audit.ts b/packages/db/src/audit.ts new file mode 100644 index 0000000..33fde6b --- /dev/null +++ b/packages/db/src/audit.ts @@ -0,0 +1,42 @@ +import { randomUUID } from "node:crypto"; + +import { db } from "./index"; +import { type AuditLogInsert, auditLog } from "./schema/audit"; + +/** + * Everything a caller must supply to record one consequential action. The id, + * conflict policy, and immutability invariant live behind the seam — callers + * never touch `randomUUID`, the `auditLog` schema, or `db` directly. + */ +export interface AuditEvent { + /** `.`, e.g. `org.create`, `user.banned`. */ + action: string; + targetType?: string; + targetId?: string; + actorUserId?: string | null; + organizationId?: string | null; + metadata?: Record; + /** Request context, when the action originates from an HTTP handler. */ + request?: { ipAddress?: string | null; userAgent?: string | null }; +} + +/** + * The single sanctioned write path into the Audit context. Server-side action + * handlers call this; clients never write audit rows directly (CONTEXT.md). + * Append-only: rows are immutable once written, and a duplicate id is tolerated + * rather than overwriting. + */ +export async function recordAuditLog(event: AuditEvent): Promise { + const row: AuditLogInsert = { + id: randomUUID(), + action: event.action, + targetType: event.targetType, + targetId: event.targetId, + actorUserId: event.actorUserId ?? null, + organizationId: event.organizationId ?? null, + metadata: event.metadata, + ipAddress: event.request?.ipAddress ?? null, + userAgent: event.request?.userAgent ?? null, + }; + await db.insert(auditLog).values(row).onConflictDoNothing(); +} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 75f40fa..f4337c4 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -8,3 +8,5 @@ export function createDb() { } export const db = createDb(); + +export { type AuditEvent, recordAuditLog } from "./audit"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a5b800..0b7750e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -708,6 +708,9 @@ importers: typescript: specifier: 'catalog:' version: 6.0.3 + vitest: + specifier: 'catalog:' + version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.8.0)(msw@2.14.6(@types/node@25.8.0)(typescript@6.0.3))(vite@8.0.16(@types/node@25.8.0)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.22.0)) packages/email: dependencies: