From 3dc90f41b24d10b7ffbddb0c9f3e62c7d00510a0 Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Sat, 15 Aug 2026 11:02:54 +0000 Subject: [PATCH] Add design doc for Supabase + .NET + React todo app Captures the approved brainstorming design: React SPA (Vite+TS) talks only to the ASP.NET Core Web API; the API validates Supabase JWTs against the JWKS URL and owns the Postgres connection. Minimal per-user todo CRUD, local dev only. --- .../2026-08-15-supabase-todo-app-design.md | 215 ++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-15-supabase-todo-app-design.md diff --git a/docs/superpowers/specs/2026-08-15-supabase-todo-app-design.md b/docs/superpowers/specs/2026-08-15-supabase-todo-app-design.md new file mode 100644 index 0000000..fe3495e --- /dev/null +++ b/docs/superpowers/specs/2026-08-15-supabase-todo-app-design.md @@ -0,0 +1,215 @@ +# Supabase + .NET + React To-Do App — Design + +**Status:** Approved +**Date:** 2026-08-15 + +## Purpose + +Build a minimal per-user to-do list that exercises the full stack: + +- React (Vite + TypeScript) SPA in the browser. +- ASP.NET Core Web API as the sole gateway to application data. +- Supabase for **authentication** (email + password) and **hosted Postgres**. + +The .NET backend validates Supabase-issued JWTs and owns the database connection. Supabase's PostgREST and Row Level Security are not used; the backend enforces ownership in SQL. + +## Success criteria + +A signed-in user can: + +1. Sign up with email + password, then sign in. +2. See only their own todos. +3. Add a todo (text only). +4. Toggle a todo between complete and incomplete. +5. Delete a todo. +6. Sign out. + +An unauthenticated caller of any `/api/todos*` endpoint receives HTTP 401. + +## Architecture + +``` +[React SPA (Vite+TS)] ──(HTTPS, Bearer JWT)──▶ [ASP.NET Core Web API] ──(Npgsql)──▶ [Supabase Postgres] + │ ▲ + └──(Supabase JS: sign-up / sign-in)───────────────┘ (auth handshake + JWKS fetch by API) +``` + +- The React app uses `@supabase/supabase-js` **only for auth** (sign up, sign in, get session, refresh, sign out). It does **not** call PostgREST and does **not** talk to the database directly. +- All CRUD requests go to the .NET API with `Authorization: Bearer `. +- The .NET API validates the JWT against the Supabase JWKS URL (asymmetric verification), extracts the `sub` claim, and uses it as `user_id` in every query. +- The .NET API owns the Npgsql connection. Row Level Security stays **off** on the `todos` table because .NET is the only writer and enforces ownership in `WHERE` clauses. + +## Repository layout + +``` +supabase_test/ +├── backend/ # ASP.NET Core Web API (net9.0) +│ ├── Program.cs +│ ├── Data/AppDbContext.cs +│ ├── Models/Todo.cs +│ ├── Controllers/TodosController.cs +│ ├── appsettings.json +│ ├── appsettings.Development.json # not committed; template committed +│ └── backend.csproj +├── frontend/ # Vite + React + TypeScript +│ ├── src/ +│ │ ├── main.tsx +│ │ ├── App.tsx +│ │ ├── lib/supabase.ts # createClient(url, publishable_key) +│ │ ├── lib/api.ts # fetch wrapper that attaches Bearer token +│ │ ├── auth/AuthProvider.tsx # session state via onAuthStateChange +│ │ ├── auth/SignIn.tsx # sign in / sign up forms +│ │ └── todos/TodoList.tsx # list + add + toggle + delete +│ ├── .env.local # not committed; .env.example committed +│ └── package.json +├── migrations/ +│ └── 001_create_todos.sql +├── docs/superpowers/specs/ # this file +└── README.md +``` + +Two independent processes in local dev: + +- `dotnet run --project backend` — listens on `http://localhost:5000`. +- `npm run dev` in `frontend/` — Vite dev server on `http://localhost:5173`. + +## Data model + +Single table in the `public` schema: + +```sql +create table public.todos ( + id bigserial primary key, + user_id uuid not null, -- Supabase auth.users.id (JWT sub claim) + title text not null check (length(title) between 1 and 500), + completed boolean not null default false, + created_at timestamptz not null default now() +); + +create index todos_user_id_created_at_idx on public.todos (user_id, created_at desc); +``` + +Design notes: + +- No foreign key to `auth.users`. Keeps the `public` schema decoupled from Supabase's internal schema and avoids ownership/permissions headaches at migration time. +- No `updated_at` — the only mutation in scope (toggling `completed`) is not something the UI needs to display a timestamp for. +- No soft delete. Delete is a hard delete. +- RLS is deliberately not enabled. If in a later iteration the React app is ever allowed to talk to PostgREST directly, RLS must be turned on and policies written first. + +## Auth flow + +**Sign up / sign in (browser only):** + +- React calls `supabase.auth.signUp({ email, password })` or `signInWithPassword({ email, password })`. +- Supabase returns a session `{ access_token, refresh_token, expires_at, user }`. +- The Supabase JS client persists the session in `localStorage` and auto-refreshes the access token before expiry. React subscribes via `supabase.auth.onAuthStateChange`. + +**Every API request (browser → backend):** + +- React calls `supabase.auth.getSession()` to get the current access token. +- Attaches `Authorization: Bearer ` to every `fetch` to `/api/todos*`. +- If a 401 comes back (e.g. token was revoked), React clears the session and shows the sign-in view. + +**JWT validation (backend):** + +- `AddAuthentication().AddJwtBearer(...)` verifies each incoming JWT against the Supabase JWKS URL (asymmetric, no shared secret). Keys are fetched at startup and cached with periodic refresh. +- Required claims: + - `iss = https://jrbqfctqhjttxobtoqts.supabase.co/auth/v1` + - `aud = authenticated` + - Signature valid against the JWKS + - Not expired +- `[Authorize]` on `TodosController`. +- User id extracted from the `sub` claim, parsed as `Guid`, used as `user_id` in every query. + +The exact `AddJwtBearer` wiring (metadata address vs `IssuerSigningKeyResolver`, cache TTL) is an implementation detail for the plan; the design commitment is "asymmetric JWKS validation, no shared secret." + +**Sign out:** React calls `supabase.auth.signOut()`, which clears the local session. No backend call needed (the JWT will simply expire). + +## HTTP API + +All endpoints require a valid Bearer token. Ownership is implicit: every query filters by the caller's `user_id`. + +| Method | Path | Body | Response | +|--------|-------------------------|--------------------------|--------------------------------------| +| GET | `/api/todos` | — | `200` — array of todos, newest first | +| POST | `/api/todos` | `{ "title": string }` | `201` + created todo | +| PATCH | `/api/todos/{id}` | `{ "completed": bool }` | `200` + updated todo, or `404` | +| DELETE | `/api/todos/{id}` | — | `204`, or `404` | + +The `PATCH` endpoint accepts only `completed` in this iteration; the wire format allows adding editable fields (e.g. `title`) later without a new route. + +Todo JSON shape: + +```json +{ "id": 123, "title": "Buy milk", "completed": false, "createdAt": "2026-08-15T10:00:00Z" } +``` + +`user_id` is never returned — the client already knows who it is, and exposing it invites confusion. + +For any mutation, a `WHERE user_id = @currentUser AND id = @id` clause guards against ID guessing; mismatches return 404 (not 403 — do not leak existence of other users' rows). + +## CORS + +- Development: allow origin `http://localhost:5173`, methods `GET, POST, PATCH, DELETE`, header `Authorization`, `Content-Type`. +- Not applicable in production because deployment is out of scope for this iteration. + +## Configuration + +**Backend `appsettings.Development.json`** (not committed; a `.example` template is committed): + +```json +{ + "ConnectionStrings": { + "Postgres": "Host=db.jrbqfctqhjttxobtoqts.supabase.co;Port=5432;Database=postgres;Username=postgres;Password=;SSL Mode=Require;Trust Server Certificate=true" + }, + "Supabase": { + "JwksUrl": "https://jrbqfctqhjttxobtoqts.supabase.co/auth/v1/.well-known/jwks.json", + "Issuer": "https://jrbqfctqhjttxobtoqts.supabase.co/auth/v1", + "Audience": "authenticated" + } +} +``` + +**Frontend `.env.local`** (not committed; `.env.example` committed): + +``` +VITE_SUPABASE_URL=https://jrbqfctqhjttxobtoqts.supabase.co +VITE_SUPABASE_PUBLISHABLE_KEY=sb_publishable_QajbbpfedzxWEhCPBKTZqg_M-8qyCUy +VITE_API_URL=http://localhost:5000 +``` + +The Supabase **secret** key is never used in this project; the backend uses direct SQL, not the Supabase REST API. It is captured in the repo only in `.env.example` as a documented "not used" marker so it does not get pasted into code by mistake. + +## Error handling + +- **Validation:** empty `title`, `title` > 500 chars → `400` with `{ "error": "..." }`. +- **Not found / not owned:** any mutation targeting an id the caller doesn't own → `404`. +- **Unauthenticated:** framework returns `401` before hitting the controller. +- **Database down:** unhandled → `500`. Not worth custom handling in an MVP. +- **Frontend:** any non-2xx surfaces as an inline error message on the affected control; the app does not crash. + +## Local dev workflow + +One-time setup: + +1. Run `migrations/001_create_todos.sql` against the Supabase database (via `psql` with the connection string, or the SQL editor in the Supabase dashboard). +2. Copy `backend/appsettings.Development.example.json` → `backend/appsettings.Development.json` and fill in the DB password. +3. Copy `frontend/.env.example` → `frontend/.env.local` and fill in the Supabase URL + publishable key + API URL. +4. Confirm Supabase Auth email confirmation is **off** for the dev project (Dashboard → Authentication → Providers → Email → toggle "Confirm email" off), so sign-up hands back a session immediately. + +Every run: + +- Terminal 1: `dotnet run --project backend` +- Terminal 2: `cd frontend && npm run dev` +- Open `http://localhost:5173`, sign up, add todos. + +## Out of scope (explicit) + +- Editing a todo's text after creation. +- Due dates, priorities, tags, categories, sharing. +- Automated tests (unit or integration). The .NET code will be structured so a `TodosController` test using `WebApplicationFactory` is a straightforward follow-up. +- Password reset / email confirmation / OAuth providers / magic links. +- Dockerfile, docker-compose, Coolify deployment. +- Observability (logs beyond ASP.NET defaults, metrics, tracing). +- Rate limiting. +- Client-side routing (a single view toggles between "sign in" and "todo list" based on session state).