Files
SupabaseTest/docs/superpowers/plans/2026-08-15-supabase-todo-app.md

1487 lines
40 KiB
Markdown

# 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 <access_token>`. 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='<db-password>' 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)**
The connection string uses Supabase's **session pooler** (Supavisor), not the direct DB host. Rationale: the direct host (`db.<ref>.supabase.co`) is IPv6-only for new Supabase projects; the pooler is IPv4-reachable and is Supabase's recommended entry point. Session mode (port `5432`) keeps prepared statements working with EF Core; transaction mode (`6543`) would break them.
Pooler URL shape:
```
Host=aws-<n>-<region>.pooler.supabase.com;Port=5432;Username=postgres.<project-ref>;Password=…
```
Get the exact `n` and `region` from your Supabase dashboard → Project Settings → Database → Connection string → "Session pooler" tab.
Create `backend/appsettings.Development.example.json`:
```json
{
"Logging": {
"LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" }
},
"ConnectionStrings": {
"Postgres": "Host=aws-N-REGION.pooler.supabase.com;Port=5432;Database=postgres;Username=postgres.YOURPROJECTREF;Password=REPLACE_ME;SSL Mode=Require;Trust Server Certificate=true"
},
"Supabase": {
"MetadataAddress": "https://YOURPROJECTREF.supabase.co/auth/v1/.well-known/openid-configuration",
"Issuer": "https://YOURPROJECTREF.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. Do **not** paste the DB password into any committed file:
```json
{
"Logging": {
"LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" }
},
"ConnectionStrings": {
"Postgres": "Host=aws-1-eu-west-1.pooler.supabase.com;Port=5432;Database=postgres;Username=postgres.jrbqfctqhjttxobtoqts;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 --version 9.*
dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL --version 9.*
```
`--version 9.*` pins to the 9.x major line. Without it, `dotnet add package` picks the absolute-latest stable (e.g. 10.x once released) which will not resolve against `net9.0``NU1202` error.
- [ ] **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<AppDbContext> options) : DbContext(options)
{
public DbSet<Todo> Todos => Set<Todo>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Todo>(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<AppDbContext>(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 --version 9.*
```
Same reason as Task 4: pin the major to avoid picking a version incompatible with `net9.0`.
- [ ] **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<ActionResult<IEnumerable<TodoDto>>> 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<ActionResult<TodoDto>> 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<ActionResult<TodoDto>> 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<IActionResult> 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=<id from previous step>
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<AuthContextValue>({ session: null, loading: true });
export function AuthProvider({ children }: { children: ReactNode }) {
const [session, setSession] = useState<Session | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const { data } = supabase.auth.onAuthStateChange((_event, s) => {
setSession(s);
setLoading(false);
});
return () => {
data.subscription.unsubscribe();
};
}, []);
return <AuthContext.Provider value={{ session, loading }}>{children}</AuthContext.Provider>;
}
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<Mode>('signIn');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(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 (
<div style={{ maxWidth: 320, margin: '4rem auto', fontFamily: 'sans-serif' }}>
<h1>{mode === 'signIn' ? 'Sign in' : 'Sign up'}</h1>
<form onSubmit={onSubmit}>
<label style={{ display: 'block', marginBottom: 8 }}>
Email
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
style={{ width: '100%', padding: 6 }}
/>
</label>
<label style={{ display: 'block', marginBottom: 8 }}>
Password
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
style={{ width: '100%', padding: 6 }}
/>
</label>
<button type="submit" disabled={busy} style={{ width: '100%', padding: 8 }}>
{busy ? 'Working…' : mode === 'signIn' ? 'Sign in' : 'Sign up'}
</button>
</form>
{error && <p style={{ color: 'crimson' }}>{error}</p>}
<p>
<button
type="button"
onClick={() => setMode(mode === 'signIn' ? 'signUp' : 'signIn')}
style={{ background: 'none', border: 'none', color: '#06f', cursor: 'pointer' }}
>
{mode === 'signIn' ? "Don't have an account? Sign up" : 'Have an account? Sign in'}
</button>
</p>
</div>
);
}
```
- [ ] **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<Response> {
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<Response> {
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<Todo[]> {
const res = await assertOk(await authedFetch('/api/todos'));
return res.json();
},
async create(title: string): Promise<Todo> {
const res = await assertOk(
await authedFetch('/api/todos', { method: 'POST', body: JSON.stringify({ title }) })
);
return res.json();
},
async setCompleted(id: number, completed: boolean): Promise<Todo> {
const res = await assertOk(
await authedFetch(`/api/todos/${id}`, {
method: 'PATCH',
body: JSON.stringify({ completed })
})
);
return res.json();
},
async remove(id: number): Promise<void> {
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<Todo[]>([]);
const [newTitle, setNewTitle] = useState('');
const [error, setError] = useState<string | null>(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 (
<div style={{ maxWidth: 520, margin: '2rem auto', fontFamily: 'sans-serif' }}>
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<h1>Todos</h1>
<span>
{userEmail}{' '}
<button type="button" onClick={onSignOut}>Sign out</button>
</span>
</header>
<form onSubmit={onAdd} style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
<input
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder="What needs doing?"
maxLength={500}
style={{ flex: 1, padding: 6 }}
/>
<button type="submit">Add</button>
</form>
{error && <p style={{ color: 'crimson' }}>{error}</p>}
{loading ? (
<p>Loading</p>
) : todos.length === 0 ? (
<p>No todos yet.</p>
) : (
<ul style={{ listStyle: 'none', padding: 0 }}>
{todos.map((t) => (
<li
key={t.id}
style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0' }}
>
<input type="checkbox" checked={t.completed} onChange={() => onToggle(t)} />
<span style={{ flex: 1, textDecoration: t.completed ? 'line-through' : 'none' }}>
{t.title}
</span>
<button type="button" onClick={() => onDelete(t)}>Delete</button>
</li>
))}
</ul>
)}
</div>
);
}
```
- [ ] **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(
<StrictMode>
<AuthProvider>
<App />
</AuthProvider>
</StrictMode>
);
```
- [ ] **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 <p style={{ textAlign: 'center', marginTop: '4rem' }}>Loading</p>;
if (!session) return <SignIn />;
return <TodoList userEmail={session.user.email ?? '(no email)'} />;
}
```
- [ ] **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.<PROJECT>.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.