Error-discipline pass
The fail-closed bypass (the swallowed role check) and the XSS sink (user content rendered as raw HTML).
The last two chapters taught you to read code against a rule. The error-discipline pass asked, at every gate, does this fail closed? The security-baseline pass walked a fixed list of categories — headers, rate limits, audit log, secrets, deletion, dependencies — checking each against a precise rule. This project is where you do that reading for real, against a running SaaS codebase one decision away from a launch review.
The codebase is a fork of the app this course has been building: invoices, auth, organizations, RBAC, the Stripe webhook, the durable export job. Someone has planted ten defects in it — eight one-per-category across the audit, and two bonus traps that only a thorough pass catches. Your job is not to fix them but to find and document them. What survives a launch review is not a patch but a written finding.
# 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 the audit target runs on your machine and the findings/ directory is scaffolded, ready to write the first finding next lesson.
You work with two parts: one you read, one you write.
findings/ directory is your editable deliverable, at the project root beside src/, 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, and the security-baseline pass carries the rules from the security chapter.
Error-discipline pass
The fail-closed bypass (the swallowed role check) and the XSS sink (user content rendered as raw HTML).
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 answer key in solution/findings/ writes out every finding in full. You leave it closed until your own findings are committed, an honor-system rule the Setup section explains.
The layout you are about to clone is mostly the application you built across this course.
The tree below annotates only two kinds of file: the seams a finding reads against — the well-built helpers a defect bypasses — and the files that carry a defect.
A finding always lives in the call site that goes around the seam, never in the seam itself.
The highlighted findings/ directory is the only thing you 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)Don’t open the defect files yet — each finding lesson opens its own when it surfaces the defect. The tree is here so the categories and seams are legible.
Each lesson surfaces and writes up one finding; the last commits the report and grades it.
Lesson 2 — Finding 1: the fail-closed bypass
Walks the audit method end to end, setting the shape every later finding copies.
Lesson 3 — Finding 2: the XSS HTML sink
User content rendered as raw HTML.
Lesson 4 — Finding 3: the missing audit-log write
An ownership transfer that leaves no operator record.
Lesson 5 — Finding 4: the CSP header omission
The missing browser-side defense header.
Lesson 6 — Finding 5: the secret in NEXT_PUBLIC_*
An API key shipped to the browser.
Lesson 7 — Finding 6: the missing rate limit on password-reset
An unthrottled email trigger.
Lesson 8 — Finding 7: the dep-hygiene gap
Disabled supply-chain defaults.
Lesson 9 — Finding 8: the GDPR deletion gap
A 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.
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 copies findings/template.md, 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 answer 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? Consequence is the one novices get wrong. It is read aloud by someone who has not seen the code, so “this is a code smell” tells the room nothing, while “an unauthorized user can take over an organization’s account” tells them everything.
The audit target runs entirely on your machine, no external accounts needed: .env.example ships dummy third-party keys, so environment validation passes without a network call. The app never reaches Resend, Stripe, Upstash, Trigger.dev, or PostHog. So the browser-invisible findings are confirmed by reading the source, and at most by a curl, a DevTools tab, or a repeated form submit. 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.
Every value in .env.example is a working local default or a dummy that satisfies validation; you obtain none 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. |
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. |
That is the finish line: the dashboard loads as the seeded admin, the findings/ directory holds the template and one empty placeholder per finding, and no finding is written yet.