---
phase: 02-authentication
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - backend/migrations/0002_auth.sql
  - backend/sqlc.yaml
  - backend/internal/db/queries/users.sql
  - backend/internal/db/queries/sessions.sql
  - backend/internal/db/sqlc/
  - backend/internal/auth/doc.go
  - backend/internal/auth/types.go
  - backend/internal/auth/testdb_test.go
  - backend/.env.example
  - backend/go.mod
  - backend/go.sum
autonomous: true
requirements: [AUTH-01, AUTH-02]
tags: [go, postgres, sqlc, migration, auth-foundation]
must_haves:
  truths:
    - "users + sessions tables exist with citext email and SHA-256 session id columns (D-01, D-04)"
    - "users table has NO deleted_at column — hard-delete only, no soft-delete reserved (D-02)"
    - "users table has NO email_verified_at column — email verification deferred (D-03)"
    - "sqlc generates Go bindings for InsertUser, GetUserByEmail, InsertSession, GetSessionWithUser, DeleteSession, DeleteSessionsByUser, ExtendSession"
    - "TEST_DATABASE_URL-driven Postgres harness creates an isolated schema, runs migrations, and tears down cleanly"
    - "internal/auth package compiles with shared types (User, Session, Params)"
    - ".env.example documents TEST_DATABASE_URL and SESSION_SECRET (D-14, D-26)"
  artifacts:
    - path: backend/migrations/0002_auth.sql
      provides: "citext extension + users + sessions tables (D-01, D-04)"
      contains: "CREATE TABLE users"
    - path: backend/internal/db/queries/users.sql
      provides: "InsertUser, GetUserByEmail named queries"
    - path: backend/internal/db/queries/sessions.sql
      provides: "InsertSession, GetSessionWithUser (with expires_at gate), DeleteSession, DeleteSessionsByUser, ExtendSession"
    - path: backend/internal/db/sqlc/users.sql.go
      provides: "Generated Go bindings for users queries"
    - path: backend/internal/db/sqlc/sessions.sql.go
      provides: "Generated Go bindings for sessions queries"
    - path: backend/internal/auth/types.go
      provides: "User and Session struct definitions consumed by later plans"
    - path: backend/internal/auth/testdb_test.go
      provides: "setupTestDB(t) helper used by every DB-touching auth test"
  key_links:
    - from: backend/sqlc.yaml
      to: backend/internal/db/queries/{users,sessions}.sql
      via: "queries directory + citext/uuid overrides"
      pattern: "db_type: \"citext\""
    - from: backend/internal/auth/testdb_test.go
      to: backend/migrations/0002_auth.sql
      via: "harness runs goose up against a per-test schema"
      pattern: "goose.Up"
---

<objective>
Lay the Phase 2 substrate: schema migration, sqlc queries + overrides + generated bindings, the `internal/auth` package skeleton (types, doc, shared constants), and a real-Postgres test harness. No user-visible behavior yet — every later plan in this phase depends on these artifacts.

Purpose: avoid "scavenger hunt" downstream — Wave 2 plans implement password/session logic against contracts that already exist, not against ad-hoc interfaces invented at execution time.
Output: applied migration, generated sqlc code in `internal/db/sqlc/`, compiling `internal/auth` package, working `TEST_DATABASE_URL` harness.
</objective>

<execution_context>
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/workflows/execute-plan.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/02-authentication/02-CONTEXT.md
@.planning/phases/02-authentication/02-RESEARCH.md
@.planning/phases/01-foundation/01-SUMMARY.md
@backend/sqlc.yaml
@backend/migrations/0001_init.sql
@backend/internal/db/pool.go
@backend/internal/db/pool_test.go
@backend/justfile

<interfaces>
Generated code (after `sqlc generate`) will live in `backend/internal/db/sqlc/` and expose, at minimum:

```go
package sqlc

type Queries struct { /* ... */ }
func New(db DBTX) *Queries
func (q *Queries) WithTx(tx pgx.Tx) *Queries

// users.sql
type InsertUserParams struct { Email string; PasswordHash string }
type User struct { ID uuid.UUID; Email string; PasswordHash string; CreatedAt time.Time; UpdatedAt time.Time }
func (q *Queries) InsertUser(ctx context.Context, arg InsertUserParams) (User, error)
func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error)

// sessions.sql
type InsertSessionParams struct { ID string; UserID uuid.UUID; ExpiresAt time.Time }
func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) error
type GetSessionWithUserRow struct { /* session + joined user columns */ }
func (q *Queries) GetSessionWithUser(ctx context.Context, id string) (GetSessionWithUserRow, error)
func (q *Queries) DeleteSession(ctx context.Context, id string) error
func (q *Queries) DeleteSessionsByUser(ctx context.Context, userID uuid.UUID) error
type ExtendSessionParams struct { ID string; ExpiresAt time.Time }
func (q *Queries) ExtendSession(ctx context.Context, arg ExtendSessionParams) error
```

Hand-written types this plan introduces in `internal/auth/types.go`:

```go
package auth

type User struct { ID uuid.UUID; Email string; PasswordHash string; CreatedAt, UpdatedAt time.Time }
type Session struct { ID string; UserID uuid.UUID; CreatedAt, ExpiresAt time.Time }

// SessionCookieName is the cookie key used across the package (planner picks "xtablo_session" per D-12).
const SessionCookieName = "xtablo_session"
// SessionTTL is the sliding 30-day window (D-09).
const SessionTTL = 30 * 24 * time.Hour
// SessionExtendThreshold triggers extension when remaining < 7 days (D-09).
const SessionExtendThreshold = 7 * 24 * time.Hour
```
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Write migration 0002_auth.sql + Up/Down round-trip test</name>
  <read_first>
    backend/migrations/0001_init.sql
    .planning/phases/02-authentication/02-RESEARCH.md (Pattern 11)
    .planning/phases/02-authentication/02-CONTEXT.md (D-01, D-04)
  </read_first>
  <action>
    Create `backend/migrations/0002_auth.sql` with goose `-- +goose Up` / `-- +goose Down` annotations matching the style of `0001_init.sql`. Up: `CREATE EXTENSION IF NOT EXISTS citext;` then `CREATE EXTENSION IF NOT EXISTS pgcrypto;` then `users` table (per D-01: `id uuid PK default gen_random_uuid()`, `email citext NOT NULL UNIQUE`, `password_hash text NOT NULL`, `created_at`/`updated_at timestamptz NOT NULL default now()`) then `sessions` table (per D-04: `id text PK`, `user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE`, `created_at`/`expires_at timestamptz NOT NULL default now()/no default`) plus `CREATE INDEX sessions_user_id_idx ON sessions(user_id);` and `CREATE INDEX sessions_expires_at_idx ON sessions(expires_at);`. Down: `DROP TABLE IF EXISTS sessions; DROP TABLE IF EXISTS users;` (leave extensions). Do NOT add `deleted_at` (D-02), `email_verified_at` (D-03), or any `user_agent`/`ip_address`/`last_seen_at` columns (D-04). Then run `just migrate up` against the local compose Postgres followed by `just migrate down` then `just migrate up` again to prove the round-trip is clean.
  </action>
  <verify>
    <automated>cd backend &amp;&amp; just migrate up &amp;&amp; just migrate down &amp;&amp; just migrate up &amp;&amp; psql "$DATABASE_URL" -c "\d users" -c "\d sessions" | grep -E "(citext|uuid|sessions_user_id_idx|sessions_expires_at_idx)"</automated>
  </verify>
  <acceptance_criteria>
    - File `backend/migrations/0002_auth.sql` exists and contains both `-- +goose Up` and `-- +goose Down` markers.
    - `grep -v '^-' backend/migrations/0002_auth.sql | grep -c "CREATE TABLE users"` == 1.
    - `grep -v '^-' backend/migrations/0002_auth.sql | grep -c "CREATE TABLE sessions"` == 1.
    - `grep -c "citext" backend/migrations/0002_auth.sql` &gt;= 2 (extension + column type).
    - `grep -c "ON DELETE CASCADE" backend/migrations/0002_auth.sql` == 1.
    - `grep -c "sessions_user_id_idx" backend/migrations/0002_auth.sql` == 1; `grep -c "sessions_expires_at_idx" backend/migrations/0002_auth.sql` == 1.
    - `grep -c "deleted_at\|email_verified_at\|user_agent\|ip_address\|last_seen_at" backend/migrations/0002_auth.sql` == 0.
    - `just migrate up` exits 0; `psql "$DATABASE_URL" -c "SELECT 1 FROM users LIMIT 0; SELECT 1 FROM sessions LIMIT 0;"` exits 0.
    - `just migrate down` followed by `just migrate up` exits 0 (round-trip clean).
  </acceptance_criteria>
  <done>Migration applies and rolls back cleanly against the compose Postgres; tables and indexes exist as specified by D-01/D-04.</done>
</task>

<task type="auto">
  <name>Task 2: Update sqlc.yaml with citext+uuid overrides; author users.sql / sessions.sql; run sqlc generate</name>
  <read_first>
    backend/sqlc.yaml
    .planning/phases/02-authentication/02-RESEARCH.md (Pattern 10, Pitfall 3, "sqlc query file" examples)
    backend/internal/db/queries/ (currently empty)
    backend/internal/db/pool.go
  </read_first>
  <action>
    Edit `backend/sqlc.yaml`: add the `overrides:` block under `gen.go` mapping `db_type: "citext"` → `go_type: "string"` and `db_type: "uuid"` → `go_type: { import: "github.com/google/uuid", type: "UUID" }`. Per D-26 and Phase 1 sqlc-empty-queries guard pattern from `fix(01)`, ensure the empty-queries case no longer applies (these two files supply queries). Create `backend/internal/db/queries/users.sql` with two named queries: `-- name: InsertUser :one` (INSERT into users (email, password_hash) VALUES ($1, $2) RETURNING all columns) and `-- name: GetUserByEmail :one` (SELECT all columns WHERE email = $1). Create `backend/internal/db/queries/sessions.sql` with five named queries verbatim from RESEARCH "Code Examples" section: `InsertSession :exec`, `GetSessionWithUser :one` (JOIN users, WHERE s.id = $1 AND s.expires_at &gt; now() per D-07), `DeleteSession :exec`, `DeleteSessionsByUser :exec`, `ExtendSession :exec`. Run `cd backend &amp;&amp; sqlc generate`. Verify generated files contain `Email string` (not `pgtype.Text`) and `UserID uuid.UUID` (not `pgtype.UUID`). Add the `github.com/google/uuid` import — already in `go.mod` from Phase 1.
  </action>
  <verify>
    <automated>cd backend &amp;&amp; sqlc generate &amp;&amp; test -f internal/db/sqlc/users.sql.go &amp;&amp; test -f internal/db/sqlc/sessions.sql.go &amp;&amp; grep -q "Email string" internal/db/sqlc/users.sql.go &amp;&amp; grep -q "uuid.UUID" internal/db/sqlc/sessions.sql.go &amp;&amp; go build ./internal/db/...</automated>
  </verify>
  <acceptance_criteria>
    - `backend/sqlc.yaml` contains an `overrides:` list with one entry mapping `db_type: "citext"` to `go_type: "string"` and one mapping `db_type: "uuid"` to `{ import: "github.com/google/uuid", type: "UUID" }`.
    - `backend/internal/db/queries/users.sql` exists; `grep -c "^-- name:" backend/internal/db/queries/users.sql` == 2.
    - `backend/internal/db/queries/sessions.sql` exists; `grep -c "^-- name:" backend/internal/db/queries/sessions.sql` == 5.
    - `grep -c "expires_at > now()" backend/internal/db/queries/sessions.sql` &gt;= 1 (D-07 lazy expiry in GetSessionWithUser).
    - `cd backend &amp;&amp; sqlc generate` exits 0.
    - Generated `internal/db/sqlc/users.sql.go` contains the literal `Email string` and `InsertUser(ctx context.Context` substrings.
    - Generated `internal/db/sqlc/sessions.sql.go` contains `uuid.UUID` and references `expires_at`.
    - `cd backend &amp;&amp; go build ./internal/db/...` exits 0.
  </acceptance_criteria>
  <done>sqlc generates type-safe Go bindings using Go `string` for citext and `uuid.UUID` for uuid; package compiles.</done>
</task>

<task type="auto">
  <name>Task 3: Create internal/auth package skeleton + test-DB harness + env-var docs</name>
  <read_first>
    backend/internal/session/doc.go
    backend/internal/db/pool_test.go
    backend/.env.example
    .planning/phases/02-authentication/02-RESEARCH.md (Open Question 1 recommendation; Pattern 1 — TestParams; Testing Strategy)
    .planning/phases/02-authentication/02-CONTEXT.md (D-26)
  </read_first>
  <action>
    Create the `backend/internal/auth/` directory with:
    1. `doc.go` — package comment explaining the consolidated layout (per RESEARCH Open Question 3: password, session, ratelimit, cookie, csrf helpers all live here; the Phase 1 `internal/session/doc.go` placeholder is repurposed to a one-line forwarder or removed — planner's choice, document the choice in the doc comment).
    2. `types.go` — exported `User`, `Session` structs matching the sqlc generated row shapes (use `uuid.UUID` for IDs, `time.Time` for timestamps). Constants: `SessionCookieName = "xtablo_session"` (D-12), `SessionTTL = 30 * 24 * time.Hour` (D-09), `SessionExtendThreshold = 7 * 24 * time.Hour` (D-09). Sentinel errors: `ErrSessionNotFound`, `ErrInvalidHash`, `ErrIncompatibleVersion`. Document each constant with the D-XX decision ID that fixes its value.
    3. `testdb_test.go` — `setupTestDB(t *testing.T) (*pgxpool.Pool, func())` helper mirroring `backend/internal/db/pool_test.go`'s `os.Getenv("TEST_DATABASE_URL")` skip pattern (fall back to `DATABASE_URL` if unset is allowed). On setup: create a unique schema name (`test_<short-uuid>`), open a pool with `search_path=<schema>,public`, run goose Up against that schema using the `pressly/goose/v3` library API (import `github.com/pressly/goose/v3`), and return the pool plus a cleanup func that DROPs the schema and closes the pool. Per RESEARCH Open Question 1: this is the canonical test harness for the phase — every DB-touching auth test calls `setupTestDB(t)` and registers cleanup via `t.Cleanup`.
    4. Add `goose` library dependency: `cd backend &amp;&amp; go get github.com/pressly/goose/v3@v3.27.1` (version already used by the just-installed CLI per the justfile).
    5. Update `backend/.env.example` to add `TEST_DATABASE_URL=postgres://xtablo:xtablo@localhost:5432/xtablo?sslmode=disable` and `SESSION_SECRET=` (with a comment `# 32 random bytes hex — generate via: openssl rand -hex 32`).
  </action>
  <verify>
    <automated>cd backend &amp;&amp; go build ./internal/auth/... &amp;&amp; TEST_DATABASE_URL="$DATABASE_URL" go test ./internal/auth/ -run TestSetupTestDB_Roundtrip -count=1 2>&amp;1 | tail -20</automated>
  </verify>
  <acceptance_criteria>
    - Directory `backend/internal/auth/` exists with `doc.go` and `types.go`.
    - `grep -c "package auth" backend/internal/auth/doc.go` == 1.
    - `grep -E "SessionCookieName|SessionTTL|SessionExtendThreshold" backend/internal/auth/types.go | wc -l` &gt;= 3.
    - `grep -c "ErrSessionNotFound\|ErrInvalidHash\|ErrIncompatibleVersion" backend/internal/auth/types.go` &gt;= 3.
    - `backend/internal/auth/testdb_test.go` exists and contains `setupTestDB` and `os.Getenv("TEST_DATABASE_URL")`.
    - Include a smoke test `TestSetupTestDB_Roundtrip` in the same file that calls setupTestDB, verifies the pool pings, and asserts the `users` table is visible inside the per-test schema (`SELECT 1 FROM users LIMIT 0`).
    - `go.mod` contains `github.com/pressly/goose/v3 v3.27.1` after `go get`.
    - `cd backend &amp;&amp; go build ./internal/auth/...` exits 0.
    - With `TEST_DATABASE_URL` set: `go test ./internal/auth/ -run TestSetupTestDB_Roundtrip -count=1` exits 0.
    - Without `TEST_DATABASE_URL` set: the same test SKIPs (does not fail).
    - `grep -c "TEST_DATABASE_URL" backend/.env.example` == 1; `grep -c "SESSION_SECRET" backend/.env.example` == 1.
  </acceptance_criteria>
  <done>internal/auth package compiles; test harness creates+drops an isolated schema and applies migrations against it; .env.example documents both new env vars.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| App ↔ Postgres | sqlc-generated queries cross this boundary; SQL injection prevented by parameterized queries |
| Process env ↔ filesystem | `.env.example` (committed) vs `.env` (gitignored) — secrets never enter VCS |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-2-06 | Information disclosure | `sessions` table column design | mitigate | `id` column stores SHA-256(token) hex per D-05 — DB read leak does NOT yield live cookies. Verified by Task 2 sessions.sql schema and downstream Plan 03 session test. |
| T-2-11 | Tampering | Migration scripts | mitigate | goose Up/Down round-trip exercised by Task 1 acceptance criteria; deterministic schema; no DDL outside migrations. |
| T-2-12 | Information disclosure | Secrets in repo | mitigate | `.env.example` carries placeholder only; real `SESSION_SECRET` lives in `.env` (gitignored per Phase 1). Task 3 acceptance asserts the placeholder. |
</threat_model>

<verification>
- `just migrate up` then `just migrate down` then `just migrate up` against the local compose Postgres exits 0 for all three calls.
- `cd backend &amp;&amp; sqlc generate &amp;&amp; go build ./...` exits 0.
- `cd backend &amp;&amp; TEST_DATABASE_URL=$DATABASE_URL go test ./internal/auth/...` runs the roundtrip smoke test and exits 0.
- No new column outside the D-01 / D-04 lists is introduced (grep gate in acceptance).
</verification>

<success_criteria>
- Schema applied; sqlc bindings compile with Go `string` for citext and `uuid.UUID` for uuid; `internal/auth` package compiles and exports the constants + types that Plans 02–07 consume.
- Test harness is the only path DB tests in this phase use; per-test schema isolation prevents cross-test pollution.
</success_criteria>

<output>
After completion, create `.planning/phases/02-authentication/02-01-SUMMARY.md` per the GSD summary template, recording: migration filename, sqlc override decisions, package layout choice (consolidated `internal/auth` vs split — Open Question 3 outcome), goose version pinned in go.mod.
</output>
