# Supabase + .NET + React To-Do App Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build a working minimal per-user to-do list where a React (Vite+TS) SPA authenticates against Supabase (email + password) and does all CRUD through an ASP.NET Core Web API that validates Supabase JWTs and talks directly to Supabase Postgres. **Architecture:** React SPA uses `@supabase/supabase-js` only for auth; every `/api/todos*` call goes to the .NET backend with `Authorization: Bearer `. The backend validates JWTs against Supabase's OpenID discovery document (which itself points at the JWKS), extracts the `sub` claim, and uses it as `user_id` in every query against a single `public.todos` table. Row Level Security stays off — .NET is the only writer. **Tech Stack:** ASP.NET Core 9.0 Web API, EF Core 9 with Npgsql, React 18 + TypeScript, Vite 5, `@supabase/supabase-js` v2. **Spec:** [`docs/superpowers/specs/2026-08-15-supabase-todo-app-design.md`](../specs/2026-08-15-supabase-todo-app-design.md) **Note on testing:** The spec deliberately excludes automated tests in this first pass. Each task uses **manual verification** (curl for backend endpoints, the browser for frontend UI) instead. Every task ends with a commit. --- ## Task 1: Repo baseline (.gitignore + README skeleton) **Files:** - Create: `.gitignore` - Create: `README.md` (overwrites the placeholder from the initial commit) - [ ] **Step 1: Write `.gitignore` covering .NET, Node, and local env files** Create `.gitignore`: ```gitignore # .NET bin/ obj/ *.user *.suo .vs/ # ASP.NET local secrets backend/appsettings.Development.json backend/appsettings.Local.json # Node node_modules/ dist/ .vite/ npm-debug.log* yarn-debug.log* yarn-error.log* # Editor / OS .idea/ .vscode/ .DS_Store # Frontend env frontend/.env frontend/.env.local frontend/.env.*.local ``` - [ ] **Step 2: Write `README.md` skeleton** Overwrite `README.md`: ```markdown # supabase_test Minimal to-do list. React (Vite+TypeScript) frontend, ASP.NET Core Web API backend, Supabase for auth and Postgres. See [`docs/superpowers/specs/2026-08-15-supabase-todo-app-design.md`](docs/superpowers/specs/2026-08-15-supabase-todo-app-design.md) for the design. Setup instructions land here after the app is wired up. ``` - [ ] **Step 3: Commit** ```bash git add .gitignore README.md git commit -m "Add gitignore and README skeleton" ``` --- ## Task 2: Database migration for `todos` table **Files:** - Create: `migrations/001_create_todos.sql` - [ ] **Step 1: Write the migration SQL** Create `migrations/001_create_todos.sql`: ```sql create table if not exists public.todos ( id bigserial primary key, user_id uuid not null, 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 if not exists todos_user_id_created_at_idx on public.todos (user_id, created_at desc); ``` - [ ] **Step 2: Apply the migration to Supabase** The engineer running this plan needs to apply the SQL against the Supabase project. Two options: **Option A — Supabase SQL editor (no local tooling needed):** 1. Open the Supabase dashboard → **SQL Editor** → **New query**. 2. Paste the contents of `migrations/001_create_todos.sql`. 3. Click **Run**. Expect "Success. No rows returned." **Option B — `psql` locally:** ```bash sudo apt-get install -y postgresql-client PGPASSWORD='' psql \ "host=db.jrbqfctqhjttxobtoqts.supabase.co port=5432 dbname=postgres user=postgres sslmode=require" \ -f migrations/001_create_todos.sql ``` Expected: `CREATE TABLE` then `CREATE INDEX`. - [ ] **Step 3: Verify the table exists** Run this one-liner in the SQL editor (or via `psql -c`): ```sql select column_name, data_type from information_schema.columns where table_schema = 'public' and table_name = 'todos' order by ordinal_position; ``` Expected columns: `id (bigint)`, `user_id (uuid)`, `title (text)`, `completed (boolean)`, `created_at (timestamp with time zone)`. - [ ] **Step 4: Commit** ```bash git add migrations/001_create_todos.sql git commit -m "Add todos table migration" ``` --- ## Task 3: Scaffold the ASP.NET Core Web API **Files:** - Create: `backend/backend.csproj` - Create: `backend/Program.cs` - Create: `backend/appsettings.json` - Create: `backend/appsettings.Development.example.json` - Create: `backend/appsettings.Development.json` (gitignored) - Create: `backend/Properties/launchSettings.json` - [ ] **Step 1: Create the project** From the repo root: ```bash dotnet new webapi --name backend --framework net9.0 --no-https --use-controllers --output backend ``` `--no-https` keeps local dev on plain `http://localhost:5000` (matches CORS config in Task 7). `--use-controllers` gives us `Controllers/`-based routing instead of minimal APIs. Delete the sample files the template generates: ```bash rm -f backend/Controllers/WeatherForecastController.cs backend/WeatherForecast.cs ``` If `--use-controllers` is rejected on your SDK version, run without it and then create `backend/Controllers/` manually — the rest of the plan uses controller classes regardless. - [ ] **Step 2: Verify the scaffold builds** ```bash cd backend dotnet build ``` Expected: `Build succeeded. 0 Warning(s). 0 Error(s).` - [ ] **Step 3: Configure ports and env in `Properties/launchSettings.json`** Replace `backend/Properties/launchSettings.json` with: ```json { "$schema": "https://json.schemastore.org/launchsettings.json", "profiles": { "backend": { "commandName": "Project", "launchBrowser": false, "applicationUrl": "http://localhost:5000", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } } } } ``` - [ ] **Step 4: Create `appsettings.Development.example.json` (committed template)** Create `backend/appsettings.Development.example.json`: ```json { "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } }, "ConnectionStrings": { "Postgres": "Host=db.YOURPROJECT.supabase.co;Port=5432;Database=postgres;Username=postgres;Password=REPLACE_ME;SSL Mode=Require;Trust Server Certificate=true" }, "Supabase": { "MetadataAddress": "https://YOURPROJECT.supabase.co/auth/v1/.well-known/openid-configuration", "Issuer": "https://YOURPROJECT.supabase.co/auth/v1", "Audience": "authenticated" } } ``` - [ ] **Step 5: Create `appsettings.Development.json` (gitignored, real values)** Create `backend/appsettings.Development.json`. Substitute the DB password provided out of band (the credentials were given in the brainstorming session — the executor has them). Do **not** paste the DB password into any committed file: ```json { "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } }, "ConnectionStrings": { "Postgres": "Host=db.jrbqfctqhjttxobtoqts.supabase.co;Port=5432;Database=postgres;Username=postgres;Password=REPLACE_WITH_DB_PASSWORD;SSL Mode=Require;Trust Server Certificate=true" }, "Supabase": { "MetadataAddress": "https://jrbqfctqhjttxobtoqts.supabase.co/auth/v1/.well-known/openid-configuration", "Issuer": "https://jrbqfctqhjttxobtoqts.supabase.co/auth/v1", "Audience": "authenticated" } } ``` - [ ] **Step 6: Trim `appsettings.json`** (leave only shared, non-secret defaults) Replace `backend/appsettings.json`: ```json { "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } }, "AllowedHosts": "*" } ``` - [ ] **Step 7: Verify the project still builds and runs** ```bash cd backend dotnet build dotnet run & sleep 3 curl -s -o /dev/null -w "%{http_code}\n" http://localhost:5000/openapi/v1.json # 200 if webapi template's OpenAPI is on kill %1 ``` The exact status may be 200 or 404 depending on template; the important check is that the server started without an unhandled exception. - [ ] **Step 8: Commit** ```bash git add backend/ .gitignore # .gitignore already covers appsettings.Development.json git commit -m "Scaffold ASP.NET Core Web API backend" ``` Confirm `git status` does NOT list `backend/appsettings.Development.json`. --- ## Task 4: EF Core + Npgsql + `Todo` entity + `AppDbContext` **Files:** - Modify: `backend/backend.csproj` (add EF Core + Npgsql packages) - Create: `backend/Models/Todo.cs` - Create: `backend/Data/AppDbContext.cs` - Modify: `backend/Program.cs` (register DbContext) - [ ] **Step 1: Add EF Core + Npgsql packages** ```bash cd backend dotnet add package Microsoft.EntityFrameworkCore dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL ``` No `--version` — the CLI resolves the latest stable compatible with the SDK (9.x on this project). - [ ] **Step 2: Create the `Todo` entity** Create `backend/Models/Todo.cs`: ```csharp namespace Backend.Models; public class Todo { public long Id { get; set; } public Guid UserId { get; set; } public string Title { get; set; } = string.Empty; public bool Completed { get; set; } public DateTimeOffset CreatedAt { get; set; } } ``` - [ ] **Step 3: Create `AppDbContext`** Create `backend/Data/AppDbContext.cs`: ```csharp using Backend.Models; using Microsoft.EntityFrameworkCore; namespace Backend.Data; public class AppDbContext(DbContextOptions options) : DbContext(options) { public DbSet Todos => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity(e => { e.ToTable("todos", schema: "public"); e.HasKey(x => x.Id); e.Property(x => x.Id).HasColumnName("id").ValueGeneratedOnAdd(); e.Property(x => x.UserId).HasColumnName("user_id").IsRequired(); e.Property(x => x.Title).HasColumnName("title").IsRequired().HasMaxLength(500); e.Property(x => x.Completed).HasColumnName("completed").IsRequired(); e.Property(x => x.CreatedAt).HasColumnName("created_at").IsRequired(); e.HasIndex(x => new { x.UserId, x.CreatedAt }) .HasDatabaseName("todos_user_id_created_at_idx"); }); } } ``` - [ ] **Step 4: Replace `Program.cs` with a known-good baseline** Overwrite `backend/Program.cs` entirely (the template output varies between SDK versions, so we replace it wholesale). Marker comments here are load-bearing — later tasks replace them with real code: ```csharp using Backend.Data; using Microsoft.EntityFrameworkCore; var builder = WebApplication.CreateBuilder(args); builder.Services.AddDbContext(options => options.UseNpgsql(builder.Configuration.GetConnectionString("Postgres"))); // MARKER: CORS SERVICES // MARKER: AUTH SERVICES builder.Services.AddControllers(); var app = builder.Build(); // MARKER: CORS MIDDLEWARE // MARKER: AUTH MIDDLEWARE app.MapControllers(); app.Run(); ``` - [ ] **Step 5: Verify build + startup does not throw when connecting** ```bash cd backend dotnet build dotnet run & sleep 4 kill %1 2>/dev/null ``` Expected: no exception in the startup log about Postgres. DbContext is registered but nothing queries it yet, so a connection isn't actually opened at startup. - [ ] **Step 6: Commit** ```bash git add backend/ git commit -m "Add EF Core, Npgsql, Todo entity, and AppDbContext" ``` --- ## Task 5: JWT authentication (JWKS via OpenID metadata) **Files:** - Modify: `backend/backend.csproj` (add JwtBearer package) - Modify: `backend/Program.cs` (add auth services + middleware) - [ ] **Step 1: Add the JwtBearer package** ```bash cd backend dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer ``` - [ ] **Step 2: Wire authentication in `Program.cs`** Open `backend/Program.cs`. Add these two `using` directives at the top (below the existing `using` lines): ```csharp using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.IdentityModel.Tokens; ``` Replace the line `// MARKER: AUTH SERVICES` with: ```csharp builder.Services .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.MetadataAddress = builder.Configuration["Supabase:MetadataAddress"] ?? throw new InvalidOperationException("Supabase:MetadataAddress not configured"); options.RequireHttpsMetadata = true; options.MapInboundClaims = false; options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidIssuer = builder.Configuration["Supabase:Issuer"], ValidateAudience = true, ValidAudience = builder.Configuration["Supabase:Audience"], ValidateIssuerSigningKey = true, ValidateLifetime = true, ClockSkew = TimeSpan.FromSeconds(30), NameClaimType = "sub" }; }); builder.Services.AddAuthorization(); ``` Replace the line `// MARKER: AUTH MIDDLEWARE` with: ```csharp app.UseAuthentication(); app.UseAuthorization(); ``` - [ ] **Step 3: Verify startup fetches the JWKS** ```bash cd backend dotnet run & sleep 5 kill %1 2>/dev/null ``` Expected: no exceptions. The JwtBearer middleware defers metadata fetching until first request, so absence of errors is enough. - [ ] **Step 4: Commit** ```bash git add backend/ git commit -m "Configure JWT bearer auth against Supabase JWKS via OpenID metadata" ``` --- ## Task 6: TodosController — GET, POST, PATCH, DELETE **Files:** - Create: `backend/Controllers/TodosController.cs` - Create: `backend/Dtos/TodoDto.cs` - Create: `backend/Dtos/CreateTodoRequest.cs` - Create: `backend/Dtos/UpdateTodoRequest.cs` - [ ] **Step 1: Create DTOs** Create `backend/Dtos/TodoDto.cs`: ```csharp namespace Backend.Dtos; public record TodoDto(long Id, string Title, bool Completed, DateTimeOffset CreatedAt); ``` Create `backend/Dtos/CreateTodoRequest.cs`: ```csharp using System.ComponentModel.DataAnnotations; namespace Backend.Dtos; public record CreateTodoRequest([Required, StringLength(500, MinimumLength = 1)] string Title); ``` Create `backend/Dtos/UpdateTodoRequest.cs`: ```csharp namespace Backend.Dtos; public record UpdateTodoRequest(bool? Completed); ``` - [ ] **Step 2: Create `TodosController`** Create `backend/Controllers/TodosController.cs`: ```csharp using System.Security.Claims; using Backend.Data; using Backend.Dtos; using Backend.Models; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; namespace Backend.Controllers; [ApiController] [Route("api/todos")] [Authorize] public class TodosController(AppDbContext db) : ControllerBase { private Guid CurrentUserId() { var sub = User.FindFirstValue("sub") ?? throw new InvalidOperationException("Authenticated request missing 'sub' claim"); return Guid.Parse(sub); } [HttpGet] public async Task>> List() { var userId = CurrentUserId(); var todos = await db.Todos .Where(t => t.UserId == userId) .OrderByDescending(t => t.CreatedAt) .Select(t => new TodoDto(t.Id, t.Title, t.Completed, t.CreatedAt)) .ToListAsync(); return Ok(todos); } [HttpPost] public async Task> Create([FromBody] CreateTodoRequest body) { if (!ModelState.IsValid) return ValidationProblem(ModelState); var userId = CurrentUserId(); var todo = new Todo { UserId = userId, Title = body.Title, Completed = false, CreatedAt = DateTimeOffset.UtcNow }; db.Todos.Add(todo); await db.SaveChangesAsync(); var dto = new TodoDto(todo.Id, todo.Title, todo.Completed, todo.CreatedAt); return CreatedAtAction(nameof(List), new { id = todo.Id }, dto); } [HttpPatch("{id:long}")] public async Task> Update(long id, [FromBody] UpdateTodoRequest body) { var userId = CurrentUserId(); var todo = await db.Todos.FirstOrDefaultAsync(t => t.Id == id && t.UserId == userId); if (todo is null) return NotFound(); if (body.Completed.HasValue) todo.Completed = body.Completed.Value; await db.SaveChangesAsync(); return Ok(new TodoDto(todo.Id, todo.Title, todo.Completed, todo.CreatedAt)); } [HttpDelete("{id:long}")] public async Task Delete(long id) { var userId = CurrentUserId(); var affected = await db.Todos .Where(t => t.Id == id && t.UserId == userId) .ExecuteDeleteAsync(); return affected == 0 ? NotFound() : NoContent(); } } ``` Note: `CreatedAt` is stamped by the app (not the DB default) so the returned DTO matches what's stored without a re-read. - [ ] **Step 3: Verify build** ```bash cd backend dotnet build ``` Expected: `Build succeeded. 0 Warning(s). 0 Error(s).` - [ ] **Step 4: Commit** ```bash git add backend/ git commit -m "Add TodosController with GET/POST/PATCH/DELETE endpoints" ``` --- ## Task 7: CORS for the Vite dev server **Files:** - Modify: `backend/Program.cs` - [ ] **Step 1: Register and apply the CORS policy** Open `backend/Program.cs`. Replace the line `// MARKER: CORS SERVICES` with: ```csharp const string DevCorsPolicy = "DevCors"; builder.Services.AddCors(options => { options.AddPolicy(DevCorsPolicy, policy => policy .WithOrigins("http://localhost:5173") .AllowAnyHeader() .WithMethods("GET", "POST", "PATCH", "DELETE")); }); ``` Replace the line `// MARKER: CORS MIDDLEWARE` with: ```csharp if (app.Environment.IsDevelopment()) { app.UseCors(DevCorsPolicy); } ``` `UseCors` must come before `UseAuthentication` in the middleware chain, which the marker order in Task 4 guarantees. - [ ] **Step 2: Verify startup** ```bash cd backend dotnet run & sleep 4 kill %1 2>/dev/null ``` No exceptions. Full CORS behavior gets verified during end-to-end testing. - [ ] **Step 3: Commit** ```bash git add backend/ git commit -m "Enable CORS for the Vite dev origin" ``` --- ## Task 8: End-to-end verify the backend with a real Supabase token **Files:** none This task uses only shell commands to prove the backend works. Do not commit anything here. - [ ] **Step 1: Start the backend** ```bash cd backend dotnet run & sleep 5 ``` - [ ] **Step 2: Confirm unauthenticated requests are rejected** ```bash curl -s -o /dev/null -w "%{http_code}\n" http://localhost:5000/api/todos ``` Expected: `401`. - [ ] **Step 3: Create a Supabase user + get an access token via the Supabase auth REST API** Use a throwaway email (e.g. `dev+$(date +%s)@example.com`). The `apikey` header is the **publishable** key. ```bash SB_URL="https://jrbqfctqhjttxobtoqts.supabase.co" SB_PUB="sb_publishable_QajbbpfedzxWEhCPBKTZqg_M-8qyCUy" EMAIL="dev+$(date +%s)@example.com" PASS="testpassword123" # Sign up (returns a session if email confirmation is off) SIGNUP=$(curl -s -X POST "$SB_URL/auth/v1/signup" \ -H "apikey: $SB_PUB" -H "Content-Type: application/json" \ -d "{\"email\":\"$EMAIL\",\"password\":\"$PASS\"}") echo "$SIGNUP" | head -c 200; echo # Extract access_token (grep-based; no jq assumed) TOKEN=$(echo "$SIGNUP" | sed -E 's/.*"access_token":"([^"]+)".*/\1/') echo "TOKEN length: ${#TOKEN}" ``` If `TOKEN` length is under 100 chars, sign-up did NOT return a session — email confirmation is on. Fix: in the Supabase dashboard, Authentication → Providers → Email, toggle **Confirm email** off, then re-run this step with a new email. - [ ] **Step 4: GET (empty list)** ```bash curl -s -w "\nHTTP %{http_code}\n" -H "Authorization: Bearer $TOKEN" http://localhost:5000/api/todos ``` Expected: `[]` then `HTTP 200`. - [ ] **Step 5: POST (create)** ```bash curl -s -w "\nHTTP %{http_code}\n" -X POST http://localhost:5000/api/todos \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{"title":"buy milk"}' ``` Expected: a JSON body with `id`, `title:"buy milk"`, `completed:false`, `createdAt`, then `HTTP 201`. Note the `id` for the next step (call it `TID`). - [ ] **Step 6: PATCH (mark complete)** ```bash TID= curl -s -w "\nHTTP %{http_code}\n" -X PATCH "http://localhost:5000/api/todos/$TID" \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{"completed":true}' ``` Expected: JSON with `completed:true`, `HTTP 200`. - [ ] **Step 7: DELETE** ```bash curl -s -w "\nHTTP %{http_code}\n" -X DELETE "http://localhost:5000/api/todos/$TID" \ -H "Authorization: Bearer $TOKEN" ``` Expected: empty body, `HTTP 204`. - [ ] **Step 8: Confirm cross-user isolation** (optional but recommended) Sign up a second user (repeat Step 3 with a different email), grab their token, try `PATCH`/`DELETE` on `TID`. Expect `HTTP 404` for both — no leakage. - [ ] **Step 9: Stop the backend** ```bash kill %1 2>/dev/null ``` If any step fails, stop and debug before proceeding to the frontend. --- ## Task 9: Scaffold the Vite + React + TypeScript frontend **Files:** - Create: `frontend/package.json` - Create: `frontend/vite.config.ts` - Create: `frontend/tsconfig.json` - Create: `frontend/index.html` - Create: `frontend/src/main.tsx` - Create: `frontend/src/App.tsx` - Create: `frontend/src/index.css` - [ ] **Step 1: Scaffold with `create-vite`** From the repo root: ```bash npm create vite@latest frontend -- --template react-ts cd frontend npm install ``` - [ ] **Step 2: Install runtime dependencies** ```bash cd frontend npm install @supabase/supabase-js ``` - [ ] **Step 3: Verify the scaffold builds and dev server runs** ```bash cd frontend npm run build # tsc + vite build npm run dev & sleep 3 curl -s -o /dev/null -w "%{http_code}\n" http://localhost:5173/ kill %1 2>/dev/null ``` Expected: build succeeds; `curl` returns `200`. - [ ] **Step 4: Commit** ```bash git add frontend/ git commit -m "Scaffold Vite + React + TypeScript frontend" ``` --- ## Task 10: Frontend env + Supabase client **Files:** - Create: `frontend/.env.example` - Create: `frontend/.env.local` (gitignored) - Create: `frontend/src/lib/supabase.ts` - [ ] **Step 1: Create the env template** Create `frontend/.env.example`: ``` VITE_SUPABASE_URL=https://YOURPROJECT.supabase.co VITE_SUPABASE_PUBLISHABLE_KEY=sb_publishable_YOURKEY VITE_API_URL=http://localhost:5000 ``` - [ ] **Step 2: Create `frontend/.env.local` with real values** ``` VITE_SUPABASE_URL=https://jrbqfctqhjttxobtoqts.supabase.co VITE_SUPABASE_PUBLISHABLE_KEY=sb_publishable_QajbbpfedzxWEhCPBKTZqg_M-8qyCUy VITE_API_URL=http://localhost:5000 ``` - [ ] **Step 3: Create the Supabase client module** Create `frontend/src/lib/supabase.ts`: ```typescript import { createClient } from '@supabase/supabase-js'; const url = import.meta.env.VITE_SUPABASE_URL; const publishableKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY; if (!url || !publishableKey) { throw new Error( 'Missing VITE_SUPABASE_URL or VITE_SUPABASE_PUBLISHABLE_KEY. Copy .env.example to .env.local and fill it in.' ); } export const supabase = createClient(url, publishableKey); ``` - [ ] **Step 4: Verify build still passes** ```bash cd frontend npm run build ``` Expected: success. - [ ] **Step 5: Confirm `.env.local` is gitignored** ```bash git status --short frontend/.env.local ``` Expected: no output (ignored). - [ ] **Step 6: Commit** ```bash git add frontend/.env.example frontend/src/lib/supabase.ts git commit -m "Add Supabase client and env template" ``` --- ## Task 11: `AuthProvider` (session state via `onAuthStateChange`) **Files:** - Create: `frontend/src/auth/AuthProvider.tsx` - [ ] **Step 1: Create the provider** Create `frontend/src/auth/AuthProvider.tsx`: ```tsx import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'; import type { Session } from '@supabase/supabase-js'; import { supabase } from '../lib/supabase'; type AuthContextValue = { session: Session | null; loading: boolean; }; const AuthContext = createContext({ session: null, loading: true }); export function AuthProvider({ children }: { children: ReactNode }) { const [session, setSession] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { const { data } = supabase.auth.onAuthStateChange((_event, s) => { setSession(s); setLoading(false); }); return () => { data.subscription.unsubscribe(); }; }, []); return {children}; } export function useAuth() { return useContext(AuthContext); } ``` Notes: - `onAuthStateChange` fires an `INITIAL_SESSION` event immediately after subscribing, so a separate `getSession()` call is redundant. - Supabase JS auto-refreshes the access token before expiry; the callback re-fires with the fresh session. - [ ] **Step 2: Verify build** ```bash cd frontend npm run build ``` Expected: success (the file is unused so far, but TypeScript compiles it). - [ ] **Step 3: Commit** ```bash git add frontend/src/auth/AuthProvider.tsx git commit -m "Add AuthProvider tracking Supabase session" ``` --- ## Task 12: `SignIn` — sign-in / sign-up form **Files:** - Create: `frontend/src/auth/SignIn.tsx` - [ ] **Step 1: Create the component** Create `frontend/src/auth/SignIn.tsx`: ```tsx import { useState, type FormEvent } from 'react'; import { supabase } from '../lib/supabase'; type Mode = 'signIn' | 'signUp'; export function SignIn() { const [mode, setMode] = useState('signIn'); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); async function onSubmit(e: FormEvent) { e.preventDefault(); setError(null); setBusy(true); try { const { error } = mode === 'signIn' ? await supabase.auth.signInWithPassword({ email, password }) : await supabase.auth.signUp({ email, password }); if (error) setError(error.message); // On success, AuthProvider's onAuthStateChange updates session and the UI switches. } finally { setBusy(false); } } return (

{mode === 'signIn' ? 'Sign in' : 'Sign up'}

{error &&

{error}

}

); } ``` - [ ] **Step 2: Verify build** ```bash cd frontend npm run build ``` Expected: success. - [ ] **Step 3: Commit** ```bash git add frontend/src/auth/SignIn.tsx git commit -m "Add sign-in / sign-up form" ``` --- ## Task 13: API fetch wrapper (attaches Bearer token) **Files:** - Create: `frontend/src/lib/api.ts` - [ ] **Step 1: Create the API module** Create `frontend/src/lib/api.ts`: ```typescript import { supabase } from './supabase'; const API_URL = import.meta.env.VITE_API_URL; if (!API_URL) throw new Error('Missing VITE_API_URL'); export type Todo = { id: number; title: string; completed: boolean; createdAt: string; }; async function authedFetch(path: string, init: RequestInit = {}): Promise { const { data } = await supabase.auth.getSession(); const token = data.session?.access_token; if (!token) throw new Error('Not signed in'); const headers = new Headers(init.headers); headers.set('Authorization', `Bearer ${token}`); if (init.body && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json'); return fetch(`${API_URL}${path}`, { ...init, headers }); } async function assertOk(res: Response): Promise { if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error(`API ${res.status}: ${text || res.statusText}`); } return res; } export const api = { async list(): Promise { const res = await assertOk(await authedFetch('/api/todos')); return res.json(); }, async create(title: string): Promise { const res = await assertOk( await authedFetch('/api/todos', { method: 'POST', body: JSON.stringify({ title }) }) ); return res.json(); }, async setCompleted(id: number, completed: boolean): Promise { const res = await assertOk( await authedFetch(`/api/todos/${id}`, { method: 'PATCH', body: JSON.stringify({ completed }) }) ); return res.json(); }, async remove(id: number): Promise { await assertOk(await authedFetch(`/api/todos/${id}`, { method: 'DELETE' })); } }; ``` - [ ] **Step 2: Verify build** ```bash cd frontend npm run build ``` Expected: success. - [ ] **Step 3: Commit** ```bash git add frontend/src/lib/api.ts git commit -m "Add authenticated fetch wrapper for the todos API" ``` --- ## Task 14: `TodoList` component (list + add + toggle + delete) **Files:** - Create: `frontend/src/todos/TodoList.tsx` - [ ] **Step 1: Create the component** Create `frontend/src/todos/TodoList.tsx`: ```tsx import { useEffect, useState, type FormEvent } from 'react'; import { api, type Todo } from '../lib/api'; import { supabase } from '../lib/supabase'; export function TodoList({ userEmail }: { userEmail: string }) { const [todos, setTodos] = useState([]); const [newTitle, setNewTitle] = useState(''); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); async function refresh() { try { setTodos(await api.list()); } catch (e) { setError(String(e)); } } useEffect(() => { refresh().finally(() => setLoading(false)); }, []); async function onAdd(e: FormEvent) { e.preventDefault(); const title = newTitle.trim(); if (!title) return; setError(null); try { const created = await api.create(title); setTodos((prev) => [created, ...prev]); setNewTitle(''); } catch (e) { setError(String(e)); } } async function onToggle(todo: Todo) { setError(null); try { const updated = await api.setCompleted(todo.id, !todo.completed); setTodos((prev) => prev.map((t) => (t.id === updated.id ? updated : t))); } catch (e) { setError(String(e)); } } async function onDelete(todo: Todo) { setError(null); try { await api.remove(todo.id); setTodos((prev) => prev.filter((t) => t.id !== todo.id)); } catch (e) { setError(String(e)); } } async function onSignOut() { await supabase.auth.signOut(); } return (

Todos

{userEmail}{' '}
setNewTitle(e.target.value)} placeholder="What needs doing?" maxLength={500} style={{ flex: 1, padding: 6 }} />
{error &&

{error}

} {loading ? (

Loading…

) : todos.length === 0 ? (

No todos yet.

) : (
    {todos.map((t) => (
  • onToggle(t)} /> {t.title}
  • ))}
)}
); } ``` - [ ] **Step 2: Verify build** ```bash cd frontend npm run build ``` Expected: success. - [ ] **Step 3: Commit** ```bash git add frontend/src/todos/TodoList.tsx git commit -m "Add TodoList component" ``` --- ## Task 15: Wire it all together in `App.tsx` and `main.tsx` **Files:** - Modify: `frontend/src/main.tsx` - Modify: `frontend/src/App.tsx` - [ ] **Step 1: Wrap the app in `AuthProvider`** Replace `frontend/src/main.tsx` with: ```tsx import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { AuthProvider } from './auth/AuthProvider'; import { App } from './App'; import './index.css'; createRoot(document.getElementById('root')!).render( ); ``` - [ ] **Step 2: Route between `SignIn` and `TodoList` based on session** Replace `frontend/src/App.tsx` with: ```tsx import { useAuth } from './auth/AuthProvider'; import { SignIn } from './auth/SignIn'; import { TodoList } from './todos/TodoList'; export function App() { const { session, loading } = useAuth(); if (loading) return

Loading…

; if (!session) return ; return ; } ``` - [ ] **Step 3: Verify build** ```bash cd frontend npm run build ``` Expected: success, no TS errors. - [ ] **Step 4: Commit** ```bash git add frontend/src/main.tsx frontend/src/App.tsx git commit -m "Route between sign-in and todo list based on session" ``` --- ## Task 16: End-to-end verification in the browser **Files:** none This is a manual smoke test. Do not commit anything here. - [ ] **Step 1: Start both processes** Two terminals from the repo root: ```bash # Terminal 1 cd backend && dotnet run # Terminal 2 cd frontend && npm run dev ``` Expected: backend on `http://localhost:5000`, Vite on `http://localhost:5173`. - [ ] **Step 2: Sign up** Open `http://localhost:5173`. Click "Don't have an account? Sign up". Enter an email like `dev+$(date +%s)@example.com` and a password ≥ 6 chars. Submit. Expected: the view switches to the todo list (empty). If instead you see an error like "email not confirmed" or the view doesn't switch, email confirmation is on for this Supabase project. Turn it off (Dashboard → Authentication → Providers → Email → "Confirm email"), sign up with a new email, and continue. - [ ] **Step 3: Add a todo** Type "buy milk", click Add. Expected: the item appears at the top of the list, `[ ] buy milk [Delete]`. The Network tab shows `POST /api/todos` → 201. - [ ] **Step 4: Toggle complete** Click the checkbox next to "buy milk". Expected: the text gets strikethrough, checkbox stays ticked. Network shows `PATCH /api/todos/{id}` → 200. - [ ] **Step 5: Delete** Click Delete. Expected: the item disappears. Network shows `DELETE /api/todos/{id}` → 204. - [ ] **Step 6: Sign out + sign back in** Click Sign out. Sign back in with the same email + password. Add another todo. Reload the page. Expected: the todo persists across reloads; the session is restored automatically from `localStorage`. - [ ] **Step 7: Cross-user isolation (optional)** Sign out. Sign up as a second user with a different email. Confirm the todo list is empty (the first user's todos are not visible). - [ ] **Step 8: Stop both processes** `Ctrl+C` in each terminal. If any step fails, debug in the browser DevTools (Console + Network) and in the backend log before continuing. --- ## Task 17: Finalize the README **Files:** - Modify: `README.md` - [ ] **Step 1: Rewrite `README.md` with real setup instructions** Replace `README.md`: ```markdown # supabase_test Minimal per-user to-do list. - **Frontend:** React 18 + TypeScript, built with Vite. Uses `@supabase/supabase-js` only for authentication. - **Backend:** ASP.NET Core 9 Web API. Validates Supabase-issued JWTs against the project's JWKS (via OpenID discovery), talks to Postgres directly with EF Core + Npgsql. - **Auth + DB:** Supabase. See [the design doc](docs/superpowers/specs/2026-08-15-supabase-todo-app-design.md) for the architecture and rationale. ## Prerequisites - .NET SDK 9.0 - Node.js 20+ - A Supabase project (URL, publishable key, and Postgres password to hand) ## One-time setup 1. Apply the database migration to your Supabase project. Either: - Open the Supabase dashboard → SQL Editor → paste `migrations/001_create_todos.sql` → Run, or - `psql "host=db..supabase.co port=5432 dbname=postgres user=postgres sslmode=require" -f migrations/001_create_todos.sql` 2. In the Supabase dashboard, **Authentication → Providers → Email → toggle "Confirm email" off** for local dev, so sign-up returns a session immediately. 3. Backend config: ```bash cp backend/appsettings.Development.example.json backend/appsettings.Development.json # Fill in the DB password and (if different) the project URL. ``` 4. Frontend config: ```bash cp frontend/.env.example frontend/.env.local # Fill in VITE_SUPABASE_URL, VITE_SUPABASE_PUBLISHABLE_KEY, VITE_API_URL. ``` 5. Install frontend deps: ```bash cd frontend && npm install ``` ## Run it Two terminals: ```bash # Terminal 1 cd backend && dotnet run # http://localhost:5000 # Terminal 2 cd frontend && npm run dev # http://localhost:5173 ``` Open `http://localhost:5173`, sign up, add todos. ## Layout ``` backend/ ASP.NET Core Web API frontend/ Vite + React + TypeScript SPA migrations/ Plain SQL files applied to Supabase Postgres docs/ Design and implementation-plan docs ``` ``` - [ ] **Step 2: Commit and push** ```bash git add README.md git commit -m "Update README with real setup instructions" git push ``` --- ## Done criteria - All 17 tasks completed with commits. - The end-to-end verification (Task 16) passes every step. - `backend/appsettings.Development.json` and `frontend/.env.local` are not tracked by git (`git status` clean, `git ls-files` does not list them). - A fresh clone + the "One-time setup" steps in the README + `dotnet run` + `npm run dev` yields a working app.