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

40 KiB

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

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:

# .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:

# 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
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:

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 EditorNew query.
  2. Paste the contents of migrations/001_create_todos.sql.
  3. Click Run. Expect "Success. No rows returned."

Option B — psql locally:

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):

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
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:

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:

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
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:

{
  "$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:

{
  "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:

{
  "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:

{
  "Logging": {
    "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" }
  },
  "AllowedHosts": "*"
}
  • Step 7: Verify the project still builds and runs
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
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

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.0NU1202 error.

  • Step 2: Create the Todo entity

Create backend/Models/Todo.cs:

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:

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:

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
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
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

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):

using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;

Replace the line // MARKER: AUTH SERVICES with:

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:

app.UseAuthentication();
app.UseAuthorization();
  • Step 3: Verify startup fetches the JWKS
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
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:

namespace Backend.Dtos;

public record TodoDto(long Id, string Title, bool Completed, DateTimeOffset CreatedAt);

Create backend/Dtos/CreateTodoRequest.cs:

using System.ComponentModel.DataAnnotations;

namespace Backend.Dtos;

public record CreateTodoRequest([Required, StringLength(500, MinimumLength = 1)] string Title);

Create backend/Dtos/UpdateTodoRequest.cs:

namespace Backend.Dtos;

public record UpdateTodoRequest(bool? Completed);
  • Step 2: Create TodosController

Create backend/Controllers/TodosController.cs:

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
cd backend
dotnet build

Expected: Build succeeded. 0 Warning(s). 0 Error(s).

  • Step 4: Commit
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:

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:

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
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
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
cd backend
dotnet run &
sleep 5
  • Step 2: Confirm unauthenticated requests are rejected
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.

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)
curl -s -w "\nHTTP %{http_code}\n" -H "Authorization: Bearer $TOKEN" http://localhost:5000/api/todos

Expected: [] then HTTP 200.

  • Step 5: POST (create)
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)
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
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
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:

npm create vite@latest frontend -- --template react-ts
cd frontend
npm install
  • Step 2: Install runtime dependencies
cd frontend
npm install @supabase/supabase-js
  • Step 3: Verify the scaffold builds and dev server runs
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
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:

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
cd frontend
npm run build

Expected: success.

  • Step 5: Confirm .env.local is gitignored
git status --short frontend/.env.local

Expected: no output (ignored).

  • Step 6: Commit
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:

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

cd frontend
npm run build

Expected: success (the file is unused so far, but TypeScript compiles it).

  • Step 3: Commit
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:

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
cd frontend
npm run build

Expected: success.

  • Step 3: Commit
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:

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
cd frontend
npm run build

Expected: success.

  • Step 3: Commit
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:

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
cd frontend
npm run build

Expected: success.

  • Step 3: Commit
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:

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:

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
cd frontend
npm run build

Expected: success, no TS errors.

  • Step 4: Commit
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:

# 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:

# 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.
  1. Frontend config:
    cp frontend/.env.example frontend/.env.local
    # Fill in VITE_SUPABASE_URL, VITE_SUPABASE_PUBLISHABLE_KEY, VITE_API_URL.
    
  2. Install frontend deps:
    cd frontend && npm install
    

Run it

Two terminals:

# 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.