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.
216 lines
10 KiB
Markdown
216 lines
10 KiB
Markdown
# 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 <access_token>`.
|
|
- 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 <access_token>` 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=<db-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).
|