Compliance moat
Audit chain (WORM), RGPD export & deletion, idempotent webhooks, CSP. The compliance primitives that turn a SaaS demo into a SaaS your CFO will sign off on — with code examples.
Compliance moat
The marketing site at /security lists the compliance primitives. This page
shows the code. Every claim below maps to a file path you can grep, a
migration you can roll back, and a server action you can call.
1. Tamper-evident audit chain (WORM)
A SHA-256-linked, append-only hash chain in audit_chain (migration
0016-audit-chain), strictly separate from the operational audit_log
table.
When to use which
| Event class | Table | Why |
|---|---|---|
| Operational ("user signed in", "preferences updated") | audit_log | High volume, no compliance review, no chain integrity required. |
| Compliance-grade ("subscription upgraded", "deletion scheduled") | audit_chain | SOC 2 / HIPAA / SEC reviewer expects an immutable, hash-linked trail. |
When in doubt, emit to both.
Append a row
import { AuditChain } from '@/libs/AuditChain';
try {
await AuditChain.append({
orgId: 'org_…',
userId: 'usr_…', // optional — null for system events
kind: 'subscription.upgraded',
payload: { tier: 'pro', priceId: 'price_…' },
worm: true, // legally retention-bound → DB-level no-delete
});
}
catch (err) {
logger.warn(`audit-chain append failed: ${err}`);
}The append is wrapped in try / catch because the chain is best-effort
— it must never block the parent business flow. The CI verifier
(npx tsx src/scripts/audit-verify.ts --org <slug>) catches any tampering
that silently bypasses both the trigger fallback and the RLS layer.
Verify a chain end-to-end
const verdict = await AuditChain.verify('org_…');
// → { valid: true, totalRows: 42 }
// or { valid: false, brokenAtId: 17, totalRows: 42 }CLI form, suitable for a daily cron in any environment that handles compliance data :
npx tsx src/scripts/audit-verify.ts --org acme-inc
# PASS — 1 247 rows verified for org acme-inc
npx tsx src/scripts/audit-verify.ts --org-id org_01HXYZ
# FAIL — chain broken at id 178Exit codes : 0 PASS, 1 FAIL, 2 org not found.
Tamper-evidence enforcement (4 independent safeguards)
Migration 0016-audit-chain ships :
ALTER TABLE audit_chain ENABLE ROW LEVEL SECURITYplusFOR UPDATE,FOR DELETEpolicies that always evaluatefalse.- A
BEFORE UPDATEtrigger thatRAISE EXCEPTIONs on every UPDATE. - A
BEFORE DELETEtrigger thatRAISE EXCEPTIONs on every DELETE. - The unique
(org_id, id)index — concurrent appenders serialise via thefor('update')lock on the previous row, so two writers can never share the sameprev_hash.
If a future PGlite build rejects RLS policy syntax, the trigger fallback is the load-bearing safeguard.
WORM (write-once-read-many)
Set worm: true on append for events that are legally or contractually
retention-bound : RGPD data export, account deletion confirmation, Stripe
payment lifecycle (PSP-mandated retention), KYC checkpoints. The
audit_chain.worm column flips a Postgres trigger that refuses
DELETE on the row at the database level — even a superuser would have
to disable the trigger first, which is itself an audit event you'd have
to defend.
Promote at emission time. Promoting after the fact would defeat the tamper-evidence guarantee and is intentionally not supported.
Currently emitting
src/app/api/stripe/webhook/route.ts—subscription.upgraded,subscription.downgraded,subscription.canceled,invoice.paid,invoice.failed.src/actions/invites.ts—invite.sent,invite.revoked.src/actions/account.ts—account.deletion_scheduled,account.deletion_cancelled,data_export.requested.src/actions/suppression.ts—suppression.created,suppression.revoked.
2. RGPD data export
"Download my data" lives at /dashboard/settings?tab=security.
Trigger
// src/actions/account.ts
export async function requestDataExport() {
const exportId = nanoid();
await db.insert(schema.dataExports).values({
id: exportId,
userId: user.id,
status: 'pending',
});
await getJobsAdapter().enqueue('account/export.requested', {
userId: user.id,
exportId,
});
await AuditChain.append({
orgId: org.id,
userId: user.id,
kind: 'data_export.requested',
payload: { exportId },
worm: true,
});
}Job
src/jobs/account.ts:exportUserData collects every user-scoped row
(profile, memberships, audit_log, credits_ledger, payments, subscriptions,
preferences), gzips a JSON blob, uploads it to
exports/{userId}/{exportId}.json.gz via Storage.put(), marks the row
ready (24h TTL), and emails a signed download link via
Email.sendDataExportReady().
Rollback
Drop migration 0014-rgpd-and-deletion, remove src/jobs/account.ts,
src/actions/account.ts, the two email templates, and the
data_exports table. Existing users keep working.
3. 30-day account deletion
// src/actions/account.ts
export async function requestAccountDeletion(typedEmail: string) {
if (typedEmail !== user.email) {
return { ok: false, error: 'email_mismatch' };
}
const token = crypto.randomBytes(24).toString('base64url');
const scheduled = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
await db.update(schema.users).set({
scheduledDeletionAt: scheduled,
deletionToken: token,
}).where(eq(schema.users.id, user.id));
await Email.sendAccountDeletionScheduled(user.email, cancelUrl(token), locale);
await AuditChain.append({
orgId: org.id,
userId: user.id,
kind: 'account.deletion_scheduled',
payload: { scheduledAt: scheduled.toISOString() },
worm: true,
});
}The user can sign in any time during the 30 days and click the cancel
link /cancel-deletion/{token} to revert. After the deadline, the daily
cron process-scheduled-deletions (registered in
src/jobs/registry.ts, 0 3 * * * UTC) hard-deletes every user past the
deadline. FK onDelete: cascade covers memberships, sessions, ledger,
payments, subscriptions, data exports.
4. Idempotent webhooks
Every API route or webhook that mutates state can opt into the standard
Idempotency-Key contract. The library lives at src/libs/Idempotency.ts
and the backing table comes from migration 0019-idempotency.
Contract
- No
Idempotency-Keyheader → handler runs unchanged. - Same key + matching
request_hash→ cached response replayed (Idempotency-Replayed: true). - Same key + different
request_hash→409 Conflictwith body{ "error": "idempotency_key_request_mismatch" }. - New key → handler runs, response captured + persisted with a 24h TTL.
request_hash is sha256(method | path | canonicalBody). JSON bodies
are key-sorted before hashing so semantically equal payloads share the
same hash. Only 2xx and 4xx responses are cached — 5xx replays
would mask real outages.
Wire a handler
import { Idempotency } from '@/libs/Idempotency';
export async function POST(request: Request) {
return Idempotency.handle(request, async () => {
// your handler — returns NextResponse / Response
return NextResponse.json({ ok: true });
});
}Currently wired
GET /api/v1/me— universal opt-in for any caller passing the header.POST /api/stripe/webhook— keyed byStripe-Event-Idso retried events replay instead of re-firing audit-chain rows + notifications.
Cleanup
The daily cron idempotency-cleanup (30 4 * * * UTC) deletes rows
whose expires_at is in the past. The job is itself idempotent and safe
to run repeatedly.
5. Content Security Policy + security.txt
Production hardening defaults shipped in next.config.ts and
src/app/.well-known/security.txt/route.ts.
Headers applied on every route in production
// next.config.ts (excerpt)
const cspDirectives = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' https://*.posthog.com",
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
"connect-src 'self' https://*.sentry.io https://*.posthog.com https://api.stripe.com",
"frame-src 'self' https://js.stripe.com https://hooks.stripe.com",
"frame-ancestors 'none'",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
'upgrade-insecure-requests',
].join('; ');
export const securityHeaders = [
{ key: 'Content-Security-Policy', value: cspDirectives },
{ key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=(), payment=(self), interest-cohort=()' },
{ key: 'X-DNS-Prefetch-Control', value: 'on' },
];CSP is skipped in development so HMR + the Next.js dev overlay keep
working. Forks adding a new third-party host (chat widget, analytics CDN)
edit cspDirectives — 'unsafe-eval' is permanently banned.
security.txt
GET /.well-known/security.txt returns an RFC 9116 payload assembled
from env vars :
Contact: mailto:security@yourdomain.example
Expires: 2027-04-30T00:00:00.000Z
Preferred-Languages: en, fr
Canonical: https://yourdomain.example/.well-known/security.txt
Policy: https://yourdomain.example/security-policyExpires is computed at request time as now() + 12 months, so the file
never goes stale. Configure via :
SECURITY_CONTACT_EMAIL=security@yourdomain.example
SECURITY_POLICY_URL=https://yourdomain.example/security6. Suppression — reversible "noise reduction"
Use the reusable primitives, never inline a bespoke flow :
<SuppressFindingButton>— row-action button (entity-kind agnostic).<SuppressionReasonDialog>— pre-built reason picker (5 codes + note- optional expiry).
<SuppressedRowBadge>— popover showing actor / reason / when.Suppression.activeSuppressionsFor(...)— single-batch lookup for table renderers (no N+1).
Owner / admin gated server-side. Revocation emits its own audit-chain event — no record is ever destroyed. There is no delete server action and no UI affordance, on purpose.
7. Evidence completeness
Generic red / amber / green bars for any audit / compliance entity.
Mounted on /dashboard/activity/[id] as the working example. Three
sample checklists ship in devSeed.ts : chargeback (8 items),
leakage (6 items), compliance (10 items).
import { EvidenceCompletenessBar } from '@/components/dashboard/EvidenceCompletenessBar';
<EvidenceCompletenessBar
entityKind="audit_log"
entityId={row.id}
checklistKind="audit"
canEdit={role === 'owner' || role === 'admin'}
/>The score is 0..100, mapped to red < 50, amber 50-79, green >= 80
by the pure helper Evidence.scoreBand(score). Items + records are read
in a single batch — no N+1.
What's next
- Email layer — the per-category preferences and HMAC unsubscribe links that keep CASL / CAN-SPAM regulators happy.
- Customizing the boilerplate — extend or replace any of the primitives above without breaking the rest.
Email layer
Resend setup, env vars, the shipped templates, per-category notification preferences, HMAC unsubscribe links, and the dev no-op behavior that keeps local dev frictionless.
Customizing the boilerplate
Design tokens (Tailwind v4), adding a locale to next-intl, swapping the payment provider (Stripe → Polar / Paddle), and the branding placeholders to swap on day one.