Error-discipline pass
The fail-closed bypass (the swallowed role check) and the XSS sink (user content rendered as raw HTML). The rules come from the error-handling chapter.
You have spent the last two chapters learning a way of reading code. The error-discipline pass taught you to ask, at every gate, does this fail closed? The security-baseline pass taught you to walk a fixed list of categories — headers, rate-limit coverage, audit-log gaps, secrets, deletion, dependency hygiene — and check each one against a precise rule. Reading is the verb that ties them together. This project is where you do that reading for real, against a running SaaS codebase that is one decision away from a launch review.
The codebase is a fork of the app this course has been building all along: invoices, auth, organizations, RBAC, the Stripe webhook, the durable export job. Somebody has planted ten defects in it. Eight sit one-per-category across the audit, and two are bonus traps that only a thorough pass catches. Your job is not to fix them. Your job is to find and document them — because the deliverable that survives a launch review is not a patch, it is a written finding that names the rule, points at the location, spells out the consequence, and proposes the fix. The patch is next sprint’s work. The finding is what gets read aloud in the room.
# Finding 001 — Fail-closed bypass on the ownership-transfer role check
**Category:** Fail-closed checks (error discipline).**Severity:** critical — an owner-only mutation runs when the gate cannotprove the actor is an owner, reachable from a real admin Server Action.
## RuleAny check that gates access fails closed: a thrown access check is a refusal,6 collapsed lines
never a pass, and the action body never runs when the check threw(chapter 080, lesson 1 — Refuse by default).
## Location`src/lib/admin/transfer-ownership.ts`:- `transferOwnershipAction` — the `try { await requireRole('owner') } catch (error) { console.warn(...) }` at lines 29–35, then the update.13 collapsed lines
- `transferOwnership` (the direct variant) — the same swallowing `try/catch` at lines 64–68, then the update at lines 70–73.
## ConsequenceThe ownership transfer goes through when the role check cannot prove theactor is an owner. An account that should never have been allowed totransfer ownership transfers it, and the legitimate owner can be lockedout of their own organization.
## FixRemove the `try/catch` around `requireRole('owner')` at both call sites andlet the throw propagate to the `authedAction` boundary, which converts it tothe refusal branch of the carried-in `Result`.This first lesson builds nothing. By the end you will have the audit target running on your machine, signed in as the seeded admin, and the findings/ directory scaffolded with one empty placeholder per finding — ready to write the first one in the next lesson.
There are only two moving parts here, and the relationship between them is the whole point.
findings/ directory is your editable deliverable. It sits at the project root, beside src/. Everything you produce in this chapter lands here, one Markdown file per finding.The eight in-scope categories split across the two passes you already know. The error-discipline pass carries the rules from the error-handling chapter; the security-baseline pass carries the rules from the security chapter. One finding per category is the floor.
Error-discipline pass
The fail-closed bypass (the swallowed role check) and the XSS sink (user content rendered as raw HTML). The rules come from the error-handling chapter.
Security-baseline pass
The missing audit-log write, the absent CSP header, the secret shipped in a NEXT_PUBLIC_* var, the unthrottled password-reset endpoint, the disabled dependency-hygiene defaults, and the GDPR deletion that leaves data behind. The rules come from the security chapter.
The answer key lives in solution/findings/ — a complete, written-out version of every finding. In a real audit there is no answer key, so treat this one the way the profession does: you do not open it until your own findings are committed. More on that honor-system rule when we get to setup.
Here is the top-level layout you are about to clone. Most of it is the application you have already built across this course, so the tree below comments only two kinds of file: the ones a finding reads against — the canonical seams, the well-built helpers that the defects bypass — and the ones that carry a defect. Calibrate your eye on the seam first; the finding always lives in the call site that goes around the seam, never in the seam itself.
The highlighted directory, findings/, is the only thing you ever edit.
chapter-082-audit-target; pnpm verify passes with all ten defects live.env@/env schema — NEXT_PUBLIC_RESEND_API_KEY in the client partition (finding 5)requireRole(required) — throws; callers must not catchsafeLimit(limiter, prefix, key) — the single rate-limiter seamsignInLimiter, signUpLimiter, resetLimitertry/catch around requireRole (finding 1)logAudit (finding 3)logAudit writertenantDb(orgId) — the org-scoped Drizzle facadeopt_out_capturing_by_default: false, no consent gate (bonus finding 9).limit(), bypasses safeLimit (bonus finding 10)Do not go reading those defect files now. Each finding lesson opens its own file the moment it surfaces the defect — opening them all at once just spoils the hunt. The tree is here so the categories and seams are legible, not so you start auditing early.
Each lesson surfaces one finding and writes it up, except the last, which commits the report and grades it.
Lesson 2 — Finding 1: the fail-closed bypass
Models the audit method end to end and produces the first finding as the reference shape every later one copies.
Lesson 3 — Finding 2: the XSS HTML sink
Surfaces the user content rendered as raw HTML.
Lesson 4 — Finding 3: the missing audit-log write
Surfaces the silent ownership transfer that leaves no operator record.
Lesson 5 — Finding 4: the CSP header omission
Surfaces the missing defense-in-depth header.
Lesson 6 — Finding 5: the secret in NEXT_PUBLIC_*
Surfaces the API key shipped to the browser.
Lesson 7 — Finding 6: the missing rate limit on password-reset
Surfaces the unthrottled email trigger.
Lesson 8 — Finding 7: the dep-hygiene gap
Surfaces the disabled supply-chain defaults.
Lesson 9 — Finding 8: the GDPR deletion gap
Surfaces the deletion that leaves personal data behind.
Lesson 10 — Commit and self-grade
Commits the findings and scores them, clause by clause, against the answer key.
The brief names the bar: eight is the floor, ten is the experienced reach. Catch all eight in-scope findings and you have run the pass. Catch the two bonus traps as well and you have run it the way a careful engineer would.
Every finding you write copies one file: findings/template.md. It is worth seeing the shape now, because it is the contract the whole report holds to — a Category and Severity header, then four sections.
# Finding NNN — <short title>
**Category:** one of the eight audit categories.**Severity:** critical | high | medium | low (senior call, justified in two lines).
## RuleThe named rule from chapter 080 or chapter 081 this finding violates. One sentence; link the lesson section by ID.
## LocationFile path(s) and line range(s). For "missing-piece" findings, name the file where the piece should live.
## ConsequenceThe failure mode in user-visible or legal terms. Two to four sentences. No "could potentially" hedging.
## FixThe senior reach, named in terms of the helper / wrapper / config block it lives in. Five to ten lines.A short illustrative snippet is allowed when the fix is structural — no full diffs.The four sections map to the four questions a launch review asks of any finding: what rule does it break, where is it, what happens if we ship it, and how do we fix it? The Consequence section is the one inexperienced auditors get wrong — it gets read aloud by someone who has not seen the code, so “this is a code smell” tells the room nothing. “An unauthorized user can take over an organization’s account” tells them everything.
The audit target runs entirely on your machine. No external accounts are needed: the .env.example ships dummy third-party keys so environment validation passes with no network round-trip. The app never reaches Resend, Stripe, Upstash, Trigger.dev, or PostHog at build or render time — which is exactly why the browser-invisible findings (the missing security headers, the unthrottled endpoint, the leaked key, the ungated analytics request) are confirmed by reading the source and, where a fingerprint is visible, by a curl, a DevTools tab, or a repeated form submit, never by a live integration. Each finding lesson tells you which.
Get the starter codebase from the project repository, under Chapter 082/start/.
Copy the example environment file into place:
cp .env.example .envInstall dependencies. This completes clean — the disabled supply-chain flags in pnpm-workspace.yaml are finding 7, but they do not break the install.
pnpm installStart local Postgres 18 in Docker:
docker compose up -dApply the schema and load the deterministic seed — the admin Alice, a second organization, the invoice carrying the planted XSS note, the suppression rows, and the audit tail:
pnpm db:migrate && pnpm db:seedBoot the app:
pnpm devThe app comes up on http://localhost:3000. Sign in at /sign-in as alice@example.com with the password inspector-password-12 — Alice is the seeded owner of the Acme organization, the tenant the audit reads as. The seed prints the path of the invoice carrying the planted note (/invoices/00000000-0000-7000-8000-ace000000001); a couple of findings send you there.
Here is what each environment variable is for. Every value in .env.example is a working local default or a dummy that satisfies validation — you do not obtain any of them from an external service.
| Variable | Purpose |
| --- | --- |
| DATABASE_URL / DATABASE_URL_UNPOOLED | The local Postgres connection, both pointing at the Docker container. |
| BETTER_AUTH_SECRET / BETTER_AUTH_URL | Session signing secret and the app’s origin. Dummy local values. |
| RESEND_API_KEY | The legitimate server-side email key. Dummy re_*; never reached at run time. |
| NEXT_PUBLIC_RESEND_API_KEY | The seeded leaked key in the client partition — finding 5. Its presence is the defect, not a healthy setting. |
| STRIPE_SECRET_KEY / STRIPE_WEBHOOK_SECRET | Test-mode Stripe keys; satisfy validation only. |
| TRIGGER_SECRET_KEY / TRIGGER_PROJECT_REF | Trigger.dev credentials; dummy, no worker is reached. |
| UPSTASH_REDIS_REST_URL / UPSTASH_REDIS_REST_TOKEN | Rate-limiter store; dummy. The rate-limit findings are confirmed by reading the source, not a live limiter. |
| NEXT_PUBLIC_POSTHOG_KEY / NEXT_PUBLIC_POSTHOG_HOST | Analytics; dummy. The consent-gate finding is confirmed by reading the source. |
| SEED | The deterministic seed value, so every machine gets identical fixtures. |
When everything is wired, the dashboard loads on http://localhost:3000 as the seeded admin, and your findings/ directory holds template.md, the eight numbered placeholders — each a four-section skeleton with a TODO comment naming the work for its lesson — and the empty out-of-scope.md and SUMMARY.md. That is the finish line for this lesson: the target runs, the deliverable is scaffolded, and no finding is written yet. The first one is the next lesson’s work, and it doubles as the lesson where the audit method itself is set.