From a08138a772dbb34b58220c0326d93200715b6d74 Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Sat, 15 Aug 2026 11:11:31 +0000 Subject: [PATCH] Add implementation plan for Supabase + .NET + React todo app 17 bite-sized tasks with manual verification (curl for backend, browser for frontend) since the spec excludes automated tests in the first pass. Uses marker-comment replacements in Program.cs so JWT auth and CORS drop in cleanly without depending on the exact template output. --- .../plans/2026-08-15-supabase-todo-app.md | 1476 +++++++++++++++++ 1 file changed, 1476 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-15-supabase-todo-app.md diff --git a/docs/superpowers/plans/2026-08-15-supabase-todo-app.md b/docs/superpowers/plans/2026-08-15-supabase-todo-app.md new file mode 100644 index 0000000..7c92115 --- /dev/null +++ b/docs/superpowers/plans/2026-08-15-supabase-todo-app.md @@ -0,0 +1,1476 @@ +# 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.