Harden lifecycle flows and local verification#110
Conversation
There was a problem hiding this comment.
Pull request overview
This PR hardens key Supabase-backed lifecycle flows (request/package/payment/session) by tightening RLS, moving checkout into a transactional RPC, and adding local verification helpers/tests; it also updates Next tooling (proxy convention + dependency bumps) and aligns a few UI flows with the new server-side guards.
Changes:
- Add stricter RLS for initial request/package/payment states, plus a
checkout_packageRPC and a future-session completion guard intutor_update_session. - Move checkout + payment (re)submission behind validated server actions; add local Supabase env helper + integration/unit tests.
- Update landing pricing/lead capture UI and migrate to the Next “proxy” convention; bump Next/Supabase CLI/Vitest.
Reviewed changes
Copilot reviewed 20 out of 22 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| vitest.config.ts | Expands Vitest excludes to avoid picking up local/tool output directories. |
| supabase/migrations/20260408000001_harden_lifecycle_rls_and_checkout.sql | Adds hardened RLS policies, transactional checkout RPC, unique index guard, and session future-completion guard. |
| supabase/tests/lifecycle-rls.integration.test.ts | Adds local Supabase integration coverage for hardened RLS + checkout RPC behavior. |
| supabase/tests/lifecycle-hardening.test.ts | Adds migration “contract” assertions to ensure hardening primitives stay present. |
| scripts/with-local-supabase-env.mjs | Adds helper to run commands with env sourced from supabase status -o env. |
| proxy.ts | Migrates from middleware export to the new proxy convention. |
| package.json | Adds *:local scripts and bumps Next/eslint-config-next/supabase CLI/vitest. |
| package-lock.json | Locks updated dependency graph for the bumped tooling versions. |
| lib/supabase/database.types.ts | Updates generated types to include new RPC signatures. |
| lib/services/sessions.ts | Reworks session usage adjustment logic and adds tutor-side “future session” guard before RPC call. |
| lib/services/session-status-transitions.ts | Introduces shared status transition helpers for usage adjustment + completion eligibility. |
| lib/services/tests/session-status-transitions.test.ts | Adds unit tests for the new status transition helpers. |
| eslint.config.mjs | Ignores additional generated/tool state directories. |
| components/dashboards/SessionCompleteForm.tsx | Adds disabled messaging support for session completion UI. |
| app/tutor/sessions/page.tsx | Passes disabled reason for upcoming sessions to prevent early completion in UI. |
| app/page.tsx | Aligns landing pricing cards with shared pricing config and adds a lead form section. |
| app/dashboard/packages/new/page.tsx | Moves package checkout to a server action calling the checkout RPC. |
| app/dashboard/packages/actions.ts | Adds server actions for checkout + pending/rejected payment updates using validated ownership checks. |
| app/dashboard/packages/[id]/page.tsx | Routes pending payment updates through server actions and cleans up uploaded files on failure. |
| app/api/cron/expire-packages/route.ts | Fails closed when CRON_SECRET is missing. |
| app/api/cron/expire-packages/route.test.ts | Adds tests for missing/valid cron secret behavior. |
| .gitignore | Ignores additional local/tool state directories and Playwright outputs. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if not found then | ||
| raise exception 'request not available for checkout'; | ||
| end if; | ||
|
|
There was a problem hiding this comment.
checkout_package checks for an existing pending/active package before taking the FOR UPDATE lock on the request row. Under concurrent calls (e.g., double-submit/retry), both transactions can observe no package, then one inserts; the other will still attempt the insert and hit the partial unique index (packages_one_open_package_per_request) with a constraint violation instead of behaving idempotently. Consider moving/repeating the open-package lookup after the request row lock, or catching the unique-violation and returning the existing package id.
| select p.id, p.tier_sessions | |
| into v_package_id, v_existing_tier | |
| from public.packages p | |
| where p.request_id = p_request_id | |
| and p.status in ('pending', 'active') | |
| order by p.created_at desc, p.id desc | |
| limit 1; | |
| if v_package_id is not null then | |
| select case v_existing_tier | |
| when 8 then 8000 | |
| when 12 then 11000 | |
| when 20 then 16000 | |
| end into v_amount; | |
| insert into public.payments ( | |
| package_id, | |
| payer_user_id, | |
| amount_pkr, | |
| method, | |
| status | |
| ) | |
| select | |
| v_package_id, | |
| v_user_id, | |
| v_amount, | |
| 'bank_transfer', | |
| 'pending' | |
| where not exists ( | |
| select 1 from public.payments pay where pay.package_id = v_package_id | |
| ); | |
| return v_package_id; | |
| end if; |
| if v_package_id is not null then | ||
| select case v_existing_tier | ||
| when 8 then 8000 | ||
| when 12 then 11000 | ||
| when 20 then 16000 | ||
| end into v_amount; | ||
|
|
There was a problem hiding this comment.
When an open package already exists for the request, the function returns that package even if the caller passed a different p_tier_sessions, and it silently recalculates the amount from the existing tier. This can lead to a UI/user mismatch (user selects 12 sessions but is routed to an 8-session package). Consider explicitly rejecting tier mismatches (raise an exception) or documenting/encoding the intended behavior (e.g., require callers to pass the existing tier).
| insert into public.payments ( | ||
| package_id, | ||
| payer_user_id, | ||
| amount_pkr, | ||
| method, | ||
| status | ||
| ) | ||
| select | ||
| v_package_id, | ||
| v_user_id, | ||
| v_amount, | ||
| 'bank_transfer', | ||
| 'pending' | ||
| where not exists ( | ||
| select 1 from public.payments pay where pay.package_id = v_package_id | ||
| ); |
There was a problem hiding this comment.
The conditional payment insert for an existing package uses where not exists (select 1 from public.payments ... ) without a uniqueness constraint or lock. Two concurrent checkout_package calls can both pass the not exists check and insert duplicate payment rows for the same package_id, while the application code often assumes a single payment per package (e.g., .maybeSingle()/.single() by package_id). Consider enforcing uniqueness at the DB level (e.g., unique constraint on payments(package_id) or a partial unique index for the “current” payment), or otherwise making the RPC insert concurrency-safe.
Summary
Test Plan
npx supabase db resetnpm run typechecknpm run lint(0 errors, 5 existing warnings)npm testnode scripts/with-local-supabase-env.mjs npx vitest run supabase/__tests__/lifecycle-rls.integration.test.tsnpm run build:localnpm run test:e2e:localnpm audit --audit-level=high(passes; 1 moderate brace-expansion advisory remains)