Verification
Once the FSD slices, the API adapter, and the RLS policies fixed in architecture have actual code in them, something has to ask whether that code does what the user wrote down as a scenario, and whether the service_role key leaked along the way. The base answers that question along two axes: static and behavioral.
| Tool | What it covers |
|---|---|
review-saas | Six static dimensions: security, secrets, a11y, SEO, types, build |
/test-saas | The behavioral side. Turns docs/design/saas-spec.md §2 scenarios into actual function calls, page entries, and RLS policy assertions |
The two look at the same code from different angles. Review asks “is the code clean?” and test asks “does the code do what the user described?” Passing only one of the two does not count as passing.
Where it sits
Section titled “Where it sits”implement-* (per-area builders) ↓/test-saas (spec → Vitest + Playwright + supabase test db → run) ↓review-saas (six static dimensions) ↓build → deploy-saasTest runs before review on purpose. A static check can pass even when behavior is broken, and a green review on a behaviorally broken implementation tells you nothing useful. Catching the heavier signal first, then layering the static one on top, beats checking the same code twice in the wrong order.
review-saas, six dimensions
Section titled “review-saas, six dimensions”The rubric lives in AI_AUTOMATION.md section 7 as the SSOT. Passing means every dimension scores 3 or higher.
| Dimension | 5-point seat | 1-point seat |
|---|---|---|
| Security | RLS on every table, guards on protected routes, webhook signature check, CSP connect-src declared | service_role exposed on the client or RLS disabled on a table |
| Secrets | Code, .env, bundle all clean. VITE_* on the client only. Only .env.example in git. gitleaks 0 hits | Real keys in git history |
| a11y | jsx-a11y 0 errors, axe-core 0 violations, full keyboard navigation, focus indicators visible | Semantic HTML violation or ARIA misuse |
| SEO | meta (title, description, og) plus sitemap.xml, robots.txt, JSON-LD, Lighthouse SEO 90+ | Empty title, no meta, robots missing |
| Types | tsc --noEmit 0 errors. Supabase generated types fresh | One or more type errors |
| Build | Build succeeds with each route under 200 KB gzip on the initial chunk, bundle analyzer clean, code splitting active | Build fails |
There are two iterations. Two changes_requested results in a row flip the stage to blocked and trigger /recover-from-blocked. That flow is covered under Recovery slash commands in architecture.
How to run the checks:
pnpm typecheck # tsc --noEmitpnpm lint # FSD boundaries + jsx-a11ypnpm build # production build + bundle size reportnpx gitleaks detect # secret grepnpx lhci autorun # Lighthouse CI (a11y + SEO + perf)supabase test db # RLS policy unit testsThe review-saas skill runs each of those in order and synthesizes the results into the six-dimension scoreboard. The output lands in .claude/state/review-{timestamp}.md as a single page, with either a pass or a per-dimension reason for falling short.
Security baseline S1 through S8
Section titled “Security baseline S1 through S8”The security dimension in review-saas works off AI_AUTOMATION.md section 4, S1 through S8. One line each.
- S1: RLS enabled with default deny on every Supabase table, and zero protected routes without
<RequireAuth>. Verified in migrations and by the reviewer. - S2:
SUPABASE_SERVICE_ROLE_KEY,STRIPE_SECRET_KEY, andSTRIPE_WEBHOOK_SECRETare server-only. The client only sees theVITE_*-prefixed anon key and publishable key..env*stays in.gitignore; only.env.examplelives in git. Verified by the zod check inenv.ts, by reviewer grep, and by gitleaks. - S3: Every user input passes through a zod schema before it reaches the DB or Stripe. URL params, query strings, form bodies, webhook payloads. Verified in
shared/lib/validationand by the reviewer. - S4:
dangerouslySetInnerHTMLrequires DOMPurify. User input must not reach SQL or HTML directly. Verified by the reviewer. - S5:
rm -rf,git push --force,DROP TABLE, remotesupabase db reset --linked, and productionstripe triggercannot run on their own. They need an explicit user confirmation. Verified at every entry gate. - S6: Commands only run inside the project directory. The Supabase production project is isolated through a separate set of environment variables. Verified across all work.
- S7: JWTs, session tokens, Stripe keys, customer IDs, and emails are masked to first four characters plus
***. Production logs never carry plaintext PII. Verified in the logging util and by the reviewer. - S8: CSP
connect-srconly allows declared domains — Supabase, Stripe, your measurement tools. Arbitrary external fetches are blocked. Verified in theindex.htmlmeta and by the reviewer.
S1 and S2 are immediate 1-point blocks at the reviewer. The rest tend to drop the score without quite reaching a block. The exact policy shape depends on your domain entities and tables, so the concrete RLS choices get fixed during the phase 4 data-schema decision. This is one of the spots that fills in when you fork.
SaaS forbidden patterns
Section titled “SaaS forbidden patterns”AI_AUTOMATION.md section 5 lists the patterns the reviewer blocks. They are all caught by static grep, so the same code never has to be written twice.
| Forbidden | Reason | Replacement |
|---|---|---|
Importing SUPABASE_SERVICE_ROLE_KEY inside src/ | If it lands in the client bundle, RLS is bypassed | Only Deno.env.get(...) inside an Edge Function |
| RLS-disabled public table | Anyone can read or write with the anon key | alter table ... enable row level security plus explicit policies |
Storing a JWT directly in localStorage | Stealable under XSS | Use the session management in supabase-js plus a protected-route guard |
| Missing Stripe webhook signature check | Forged webhook activates an arbitrary subscription | stripe.webhooks.constructEvent(body, sig, secret) required |
dangerouslySetInnerHTML without sanitization | XSS | DOMPurify, or decompose into components if possible |
| Protected route with no guard | Protected UI shows up while unauthenticated | Wrap with the <RequireAuth> element |
console.log(session), console.log(token) | Leaks through logs, screenshots, session replay | A mask(token) helper, or remove the log |
select * from users with no limit | Massive response, side-channel disclosure | Explicit columns, .limit(), and RLS to scope rows |
import.meta.env.SUPABASE_URL (missing VITE_ prefix) | Vite only exposes VITE_* to the client; comes up undefined otherwise | import.meta.env.VITE_SUPABASE_URL plus the zod check in env.ts |
/test-saas, spec-driven
Section titled “/test-saas, spec-driven”A typical boilerplate hands you a Vitest setup. You still have to write the test code yourself. /test-saas adds one step on top of that.
docs/design/saas-spec.md §2 scenarios (written by user in phase 3) ↓/test-saas parses and maps them ↓scenario unit → Vitest unit + Playwright e2e + supabase test db RLS (generated by AI) ↓lands in src/{slice}/__tests__/ and api/policies.test.sqlThe user only writes the scenario. “After the user picks the Pro plan and clears Stripe Checkout, they come back to /app with an active subscription.” One line. The skill breaks that into a verifiable set of units.
The decomposition follows FSD slices or RLS policies.
| Scenario fragment | Test unit | Location |
|---|---|---|
| Click the Pro plan card → Edge Function call | features/checkout action unit | src/features/checkout/__tests__/ (Vitest) |
| Edge Function returns the Stripe session URL | api/functions/checkout-session | api/functions/checkout-session/__tests__/ (Vitest, mocked Stripe) |
| Stripe completes → arrive at /checkout/success | Route entry + UI state | Playwright e2e |
webhook updates the subscriptions table | webhook signature check + DB upsert | api/functions/stripe-webhook/__tests__/ (Vitest) |
| Another user can only select their own subscription | RLS policy unit test | api/policies.test.sql (supabase test db) |
Four test patterns are baked in.
- Vitest units: in
src/{slice}/__tests__/, co-located. Bypass the FSD public API and import slice internals directly. The tests are part of the slice, not external consumers of it. - Edge Function units: in
api/functions/<name>/__tests__/. The Supabase client and the Stripe SDK get stubbed withvi.mock. For signature verification, a negative case that throws a forged signature ships alongside the happy path. - Playwright e2e: route entries, the auth flow, and the payment redirect actually run. They sit on top of a
supabase startthat brings up local Postgres and the Edge runtime. - RLS units: written into
api/policies.test.sqlas “this user sending this query should be rejected” statements.supabase test dbruns them against the local DB.
Where the test file lands depends on what the scenario touches. Anything touching a slice goes inside the slice; anything touching RLS goes into api/policies.test.sql. What the base ships is the directory skeleton for those four patterns and nothing more.
Reporting in scenario language
Section titled “Reporting in scenario language”Raw Vitest and Playwright output never reaches the user as-is. The skill rewrites it scenario by scenario.
Total scenarios: 5 — 4 passed, 1 failed
✓ Pro plan checkout start — Edge Function checkout-session called cleanly, Stripe URL returned✓ Stripe webhook signature check — forged signature rejected, valid signature passes✓ Land on /app after checkout — protected route cleared with an active subscription✗ Customer Portal entry — Edge Function customer-portal call succeeded, but no URL returned File: src/features/manage-subscription/__tests__/index.test.ts:24 Fix at: features/manage-subscription/model/openPortal.ts✓ Another user cannot select someone else's subscription — RLS policy holdsHiding the raw output is deliberate. For a PM or a non-developer, “this scenario passed and that scenario failed” lands closer to a decision than expected 'foo' to be 'bar' at line 32. The full log still lives in .claude/state/last-test.log for the moments when someone has to dig.
Quick reference
Section titled “Quick reference”# behavioral/test-saas # spec → generate tests → run → scenario reportpnpm test # Vitest directpnpm test:e2e # Playwright directsupabase test db # RLS policy unit tests
# static/review-saas # six-dimension synthesispnpm typecheckpnpm lintpnpm buildnpx gitleaks detectnpx lhci autorunBeginner pace
Section titled “Beginner pace”The same idea written at a beginner pace lives at the end of beginner chapter 04, real-backend. Same principle: the spec writer writes the scenario, the AI writes the verification code, the report comes back in scenario language.
Build-and-package comes next. How the Vite SPA build lands in dist/, the VITE_* exposure boundary in import.meta.env, how the zod check in env.ts doubles as a build-time gate, the 200 KB gzip target per route chunk, and how Edge Functions go out on a separate track via supabase functions deploy.