Skip to content

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.

ToolWhat it covers
review-saasSix static dimensions: security, secrets, a11y, SEO, types, build
/test-saasThe 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.

implement-* (per-area builders)
/test-saas (spec → Vitest + Playwright + supabase test db → run)
review-saas (six static dimensions)
build → deploy-saas

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

The rubric lives in AI_AUTOMATION.md section 7 as the SSOT. Passing means every dimension scores 3 or higher.

Dimension5-point seat1-point seat
SecurityRLS on every table, guards on protected routes, webhook signature check, CSP connect-src declaredservice_role exposed on the client or RLS disabled on a table
SecretsCode, .env, bundle all clean. VITE_* on the client only. Only .env.example in git. gitleaks 0 hitsReal keys in git history
a11yjsx-a11y 0 errors, axe-core 0 violations, full keyboard navigation, focus indicators visibleSemantic HTML violation or ARIA misuse
SEOmeta (title, description, og) plus sitemap.xml, robots.txt, JSON-LD, Lighthouse SEO 90+Empty title, no meta, robots missing
Typestsc --noEmit 0 errors. Supabase generated types freshOne or more type errors
BuildBuild succeeds with each route under 200 KB gzip on the initial chunk, bundle analyzer clean, code splitting activeBuild 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:

Terminal window
pnpm typecheck # tsc --noEmit
pnpm lint # FSD boundaries + jsx-a11y
pnpm build # production build + bundle size report
npx gitleaks detect # secret grep
npx lhci autorun # Lighthouse CI (a11y + SEO + perf)
supabase test db # RLS policy unit tests

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

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, and STRIPE_WEBHOOK_SECRET are server-only. The client only sees the VITE_*-prefixed anon key and publishable key. .env* stays in .gitignore; only .env.example lives in git. Verified by the zod check in env.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/validation and by the reviewer.
  • S4: dangerouslySetInnerHTML requires DOMPurify. User input must not reach SQL or HTML directly. Verified by the reviewer.
  • S5: rm -rf, git push --force, DROP TABLE, remote supabase db reset --linked, and production stripe trigger cannot 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-src only allows declared domains — Supabase, Stripe, your measurement tools. Arbitrary external fetches are blocked. Verified in the index.html meta 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.

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.

ForbiddenReasonReplacement
Importing SUPABASE_SERVICE_ROLE_KEY inside src/If it lands in the client bundle, RLS is bypassedOnly Deno.env.get(...) inside an Edge Function
RLS-disabled public tableAnyone can read or write with the anon keyalter table ... enable row level security plus explicit policies
Storing a JWT directly in localStorageStealable under XSSUse the session management in supabase-js plus a protected-route guard
Missing Stripe webhook signature checkForged webhook activates an arbitrary subscriptionstripe.webhooks.constructEvent(body, sig, secret) required
dangerouslySetInnerHTML without sanitizationXSSDOMPurify, or decompose into components if possible
Protected route with no guardProtected UI shows up while unauthenticatedWrap with the <RequireAuth> element
console.log(session), console.log(token)Leaks through logs, screenshots, session replayA mask(token) helper, or remove the log
select * from users with no limitMassive response, side-channel disclosureExplicit 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 otherwiseimport.meta.env.VITE_SUPABASE_URL plus the zod check in env.ts

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

The 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 fragmentTest unitLocation
Click the Pro plan card → Edge Function callfeatures/checkout action unitsrc/features/checkout/__tests__/ (Vitest)
Edge Function returns the Stripe session URLapi/functions/checkout-sessionapi/functions/checkout-session/__tests__/ (Vitest, mocked Stripe)
Stripe completes → arrive at /checkout/successRoute entry + UI statePlaywright e2e
webhook updates the subscriptions tablewebhook signature check + DB upsertapi/functions/stripe-webhook/__tests__/ (Vitest)
Another user can only select their own subscriptionRLS policy unit testapi/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 with vi.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 start that brings up local Postgres and the Edge runtime.
  • RLS units: written into api/policies.test.sql as “this user sending this query should be rejected” statements. supabase test db runs 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.

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 holds

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

Terminal window
# behavioral
/test-saas # spec → generate tests → run → scenario report
pnpm test # Vitest direct
pnpm test:e2e # Playwright direct
supabase test db # RLS policy unit tests
# static
/review-saas # six-dimension synthesis
pnpm typecheck
pnpm lint
pnpm build
npx gitleaks detect
npx lhci autorun

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.