
- Claude Code
- Codex
- Copilot
- +2
Pre-Launch Security Review
An eight-point check for the mistakes that get small apps breached in month one, with PASS/FAIL per item.
8 results

An eight-point check for the mistakes that get small apps breached in month one, with PASS/FAIL per item.
Integrations
Auth — DONE. Supabase magic link; /app and /history require a session; /login and / are public.
Database — DONE. users(id, email, plan, trial_ends_at), courses(id, title), progress(id, user_id, course_id, lesson_no, completed_at). RLS: users read/write only rows where user_id = auth.uid().
Payments — NEEDS KEY. Stripe Checkout, $49/month, 5-day trial. Webhook /api/stripe/webhook verifies the signature with STRIPE_WEBHOOK_SECRET before any write. checkout.session.completed → users.plan = "pro"; invoice.payment_failed → plan = "past_due"; customer.subscription.deleted → plan = "free".
Email — NEEDS KEY. Resend: welcome on sign-up ("You're in — here's day 1"); trial-ending 2 days before trial_ends_at ("Your trial ends Thursday").
Source of truth for "paid": users.plan, written only by the webhook. The client never sets it.
Secrets expected in the environment panel: SUPABASE_URL, SUPABASE_ANON_KEY, SUPABASE_SERVICE_ROLE_KEY (server only), STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, RESEND_API_KEY.
Test before going live:
1. Sign up with a fresh email → magic link arrives → /app loads → one row in users with plan = "free".
2. Stripe test card 4242… → return to /app → plan = "pro" within 5 seconds → welcome email received.
3. Fire a test `customer.subscription.deleted` from the Stripe dashboard → plan = "free".One prompt that wires the real stack — secrets in the vault and a manual test for each integration.
Walkthrough Lines 1–8 pull `items` from the cache with no validation. Lines 10–18 loop and sum `item.price * item.qty`. Lines 20–22 return the total as a float. Assumptions 1. The cache always returns a list → None raises AttributeError at line 10. 2. Every item has `price` and `qty` → legacy rows without `qty` raise KeyError mid-loop; partial sums are lost. 3. `price` is numeric → a string price silently concatenates on some paths. Dangerous. 4. The list is not mutated during iteration → a concurrent cache refresh mutates it; totals come back inconsistent with no error. Dangerous. 5. An empty list means a zero total → returns 0 silently; callers cannot tell "no items" from "free". Dangerous. Verdict on your concerns: concurrency — unsafe; error handling — none; empty list — silent zero. Tests - items = None → a clear 500 with a logged cause - one item missing qty → validation error naming the item id - 10k items during a cache refresh → a consistent snapshot or a retry Confirm the diagnosis and I will propose a patch.
Line-by-line read of a function, every assumption it makes, what breaks when each fails — before any fix.
Built. Here is what exists now: - /today — the screen learners live on: today's lesson card, a 7-day streak strip, and a trial banner counting down from 5 days with one coral "Keep my streak" button that routes to /upgrade. - /login — magic-link sign-in by email, no password. - /settings — sign-out only. Data model (created before UI, as asked): users(id, email, created_at, trial_ends_at, plan) lessons(id, day_number, title, duration_min) completions(id, user_id, lesson_id, completed_at) Streak is computed from completions, not stored. Assumptions: one lesson per day; streak resets after a missed day; trial length fixed at 5 days. Left out on purpose: analytics, onboarding tour, dark mode, admin. One question before the next screen: should a learner on day 6 with no plan still see today's lesson, or only the upgrade page?
Who, the one job, the data, the feel — and an app that looks like yours on the first generation.
Node list
1. Stripe Trigger — fires on checkout.session.completed — reads nothing upstream.
2. Notion — Create database page in "Customers" — Email ← $json.customer_details.email; Plan ← $json.metadata.plan; Amount ← $json.amount_total / 100; Date ← $json.created.
3. Slack — Post to #revenue — "New paid signup: {email} on {plan} ({amount} USD)" built from the same fields.
Workflow JSON: attached below, importable; credentials referenced as "Stripe account", "Notion workspace", "Slack bot" — create those three in Credentials before activating.
Error path: Error Trigger workflow "Alerts" → Slack #ops with the failing workflow name, the item's customer email, and the error message.
Test plan: in the Stripe Trigger node, click "Listen for test event", then send a test checkout.session.completed from the Stripe dashboard → Notion shows one row with the four fields → Slack shows one message → execution list shows green.
Smallest v1: this is already the smallest version (3 nodes). Add the welcome email as node 4 next week, after you have watched this run for a few days.Five spec lines in; a node list, importable JSON, an error path and a test plan out.
Subject: Launch moves to the 28th — plan attached Jamie — I am moving the launch from the 14th to the 28th. Two reasons. The security review surfaced one issue we want to fix properly rather than patch, and the marketing site needs one more full week of build. The rest of the timeline holds; the partner webinar on the 30th is unaffected. Already handled: the fix is scoped and owned by Priya; the new site copy is in legal review. I need two things from you — approval of the new date, and a release of the email send that is booked for the 14th. I will post a five-minute pulse in #launch every morning until the 28th. — Alex Questions you will get back: - "Why didn't we catch the security issue earlier?" → It was found by the external review we scheduled for exactly this reason; that is the process working. - "What else could slip?" → Nothing on the critical path; the only open risk is legal turnaround on copy, which has a Friday deadline.
Deliver a delay, a miss or a no in under 100 words — decision first, reasons second, apology optional.
Done — one change. Files changed: - src/pages/Pricing.tsx — added a Monthly / Yearly segmented toggle above the plan cards; yearly shows the discounted price and a "save 20%" tag - src/lib/pricing.ts — added `yearlyPrice(monthly)`; no other exports touched Outside the pricing page: nothing. Layout, colour tokens, routes and the dashboard are untouched. I did not reformat Pricing.tsx beyond the inserted block. Acceptance: toggle defaults to Monthly; switching shows $190/yr under the Pro plan instead of $19/mo, and the tag appears. Verified in preview. Revert: delete the toggle block in Pricing.tsx (lines 41–58) and the `yearlyPrice` helper.
A targeted change request with an acceptance check and a keep-list, so the AI stops rebuilding what worked.
Restating: submitting the sign-up form throws `column "email" does not exist`, and you expected to be redirected to /dashboard. It worked before the profile table was added.
Ranked causes
1. The sign-up handler now inserts into `profiles`, which has no `email` column — the form was pointed at the new table, not `users`. Check: open the insert in `signup.ts`; if the table is `profiles`, confirmed.
2. A migration added `profiles` without `email` while the ORM model still declares it. Check: compare `schema.sql` to the model.
3. Two databases (local vs. hosted) with different schemas. Check: run the migration list against the hosted DB.
Checking #1: `signup.ts` line 22 inserts `{ email, name }` into `profiles`. Confirmed.
Fix (smallest): insert `email` into `users` as before, and only `{ user_id, name }` into `profiles`.
- await db.from("profiles").insert({ email, name })
+ const { data: u } = await db.from("users").insert({ email }).select("id").single()
+ await db.from("profiles").insert({ user_id: u.id, name })
Verify: sign up with a fresh address; you should land on /dashboard and see one row in each table.
Regression risk: any other code that assumed profiles.email — I found none.A bug report the AI can act on: what you did, what happened, what you expected — with a confirm-first rule.