Skip to content
Chapter 82Lesson 1

Project overview

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.

findings/001-fail-closed.md
# 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 cannot
prove the actor is an owner, reachable from a real admin Server Action.
## Rule
Any 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.
## Consequence
The ownership transfer goes through when the role check cannot prove the
actor is an owner. An account that should never have been allowed to
transfer ownership transfers it, and the legitimate owner can be locked
out of their own organization.
## Fix
Remove the `try/catch` around `requireRole('owner')` at both call sites and
let the throw propagate to the `authedAction` boundary, which converts it to
the refusal branch of the carried-in `Result`.
`solution/findings/001-fail-closed.md` — one finding on the rule-location-consequence-fix template.

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.

  • Reading a running app against its source to surface defects.
  • Naming each defect against a precise rule.
  • Stating a defect’s consequence in user-visible or legal terms.
  • Reaching for the concrete fix, named by the helper or wrapper it ships in.
  • Working under a real audit’s constraint: coverage over depth, no answer key.

You work with two parts: one you read, one you write.

  • The audit target is the course’s running project with the ten defects seeded into it. It is read-only; you never edit it. It boots, builds, and renders with every defect live, because an audit reads a running target. A broken app is something you debug, not audit.
  • The 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.

  • next.config.ts five static security headers, no CSP (finding 4)
  • pnpm-workspace.yaml supply-chain defaults disabled (finding 7)
  • .npmrc engine-strict / auto-install-peers only — not where pnpm’s supply-chain settings live
  • package.json name chapter-082-audit-target; pnpm verify passes with all ten defects live
  • docker-compose.yml local Postgres 18
  • .env.example dummy third-party keys; copy to .env
  • Directoryfindings/ the deliverable you commit — the only directory you edit
    • template.md the rule-location-consequence-fix template, copied per finding
    • 001-fail-closed.md numbered placeholder — a 4-section skeleton + a per-lesson TODO
    • 002-xss-html-sink.md
    • 003-audit-log-ownership-transfer.md
    • 004-csp-header.md
    • 005-secret-next-public.md
    • 006-rate-limit-password-reset.md
    • 007-dep-hygiene.md
    • 008-gdpr-deletion.md
    • out-of-scope.md observations outside the eight categories
    • SUMMARY.md the coverage scorecard, filled at the end
  • Directorytrigger/
    • delete-user.ts a healthy async GDPR deletion job — the reference impl for finding 8
  • Directorysrc/
    • env.ts the typed @/env schema — NEXT_PUBLIC_RESEND_API_KEY in the client partition (finding 5)
    • proxy.ts presence-only cookie guard, no per-request nonce (finding 4)
    • Directorylib/
      • Directoryauth/
        • authed-action.ts the canonical Server-Action wrapper — the seam findings bypass
        • require-role.ts requireRole(required) — throws; callers must not catch
      • safe-limit.ts safeLimit(limiter, prefix, key) — the single rate-limiter seam
      • rate-limit.ts limiter declarations — signInLimiter, signUpLimiter, resetLimiter
      • admin/transfer-ownership.ts try/catch around requireRole (finding 1)
      • billing/transfer-ownership.ts ownership transfer with no logAudit (finding 3)
      • account/delete-account.ts deletes the user row only (finding 8)
    • Directorydb/
      • audit-log.ts the transaction-scoped logAudit writer
      • tenant.ts tenantDb(orgId) — the org-scoped Drizzle facade
    • Directoryapp/
      • _components/providers.tsx opt_out_capturing_by_default: false, no consent gate (bonus finding 9)
      • (protected)/invoices/[id]/notes.tsx renders the note body as raw HTML (finding 2)
      • (protected)/settings/resend-test.tsx Client Component reading the public key (finding 5)
      • api/auth/reset-password/route.ts Resend trigger, no limiter (finding 6)
      • api/exports/trigger/route.ts bare .limit(), bypasses safeLimit (bonus finding 10)
    • Directorycomponents/ui/ shadcn/ui primitives
    • Directoryemails/ React Email templates

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.

findings/template.md
# Finding NNN — <short title>
**Category:** one of the eight audit categories.
**Severity:** critical | high | medium | low (senior call, justified in two lines).
## Rule
The named rule from chapter 080 or chapter 081 this finding violates. One sentence; link the lesson section by ID.
## Location
File path(s) and line range(s). For "missing-piece" findings, name the file where the piece should live.
## Consequence
The failure mode in user-visible or legal terms. Two to four sentences. No "could potentially" hedging.
## Fix
The 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.

  1. Get the starter codebase from the project repository, under Chapter 082/start/.

  2. Copy the example environment file into place:

    Terminal window
    cp .env.example .env
  3. Install dependencies. This completes clean — the disabled supply-chain flags in pnpm-workspace.yaml are finding 7, but they do not break the install.

    Terminal window
    pnpm install
  4. Start local Postgres 18 in Docker:

    Terminal window
    docker compose up -d
  5. Apply 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:

    Terminal window
    pnpm db:migrate && pnpm db:seed
  6. Boot the app:

    Terminal window
    pnpm dev

The 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.

VariablePurpose
DATABASE_URL / DATABASE_URL_UNPOOLEDThe local Postgres connection, both pointing at the Docker container.
BETTER_AUTH_SECRET / BETTER_AUTH_URLSession signing secret and the app’s origin. Dummy local values.
RESEND_API_KEYThe legitimate server-side email key. Dummy re_*, never reached at run time.
NEXT_PUBLIC_RESEND_API_KEYThe seeded leaked key in the client partition — finding 5. Its presence is the defect.
STRIPE_SECRET_KEY / STRIPE_WEBHOOK_SECRETTest-mode Stripe keys; satisfy validation only.
TRIGGER_SECRET_KEY / TRIGGER_PROJECT_REFTrigger.dev credentials; dummy, no worker is reached.
UPSTASH_REDIS_REST_URL / UPSTASH_REDIS_REST_TOKENRate-limiter store; dummy. The rate-limit findings are confirmed by reading the source, not a live limiter.
NEXT_PUBLIC_POSTHOG_KEY / NEXT_PUBLIC_POSTHOG_HOSTAnalytics; dummy. The consent-gate finding is confirmed by reading the source.
SEEDThe 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.