---
phase: 02-authentication
plan: 04
type: execute
wave: 3
depends_on: [01, 02, 03]
files_modified:
  - backend/templates/auth_signup.templ
  - backend/templates/auth_form_errors.templ
  - backend/internal/web/handlers_auth.go
  - backend/internal/web/handlers_auth_test.go
  - backend/internal/web/router.go
  - backend/cmd/web/main.go
autonomous: true
requirements: [AUTH-01, AUTH-03, AUTH-05]
tags: [go, htmx, signup, vertical-slice, templ]
must_haves:
  truths:
    - "GET /signup renders a full page with an email + password form (D-19)"
    - "POST /signup with valid input inserts a user with argon2id-hashed password, creates a session, sets the session cookie, and redirects to / (D-11)"
    - "POST /signup with invalid input renders field-specific errors (email parse failure, password length 12-128) as an HTMX fragment when HX-Request: true, full page otherwise (D-19, D-25)"
    - "POST /signup with a duplicate email renders 'Email is already in use' (specific is OK on signup per CONTEXT.md specifics)"
    - "Authed users hitting GET /signup are redirected to / (D-23 RedirectIfAuthed)"
    - "Router wires ResolveSession into the chi middleware stack in the locked order: RequestID -> RealIP -> SlogLogger -> Recoverer -> ResolveSession (D-24)"
  artifacts:
    - path: backend/templates/auth_signup.templ
      provides: "SignupPage full templ and SignupFormFragment HTMX fragment"
      contains: "templ SignupPage"
    - path: backend/templates/auth_form_errors.templ
      provides: "Shared form-error rendering primitives used by signup + login"
    - path: backend/internal/web/handlers_auth.go
      provides: "SignupPageHandler + SignupPostHandler (auto-login + redirect)"
    - path: backend/internal/web/router.go
      provides: "Updated NewRouter signature accepting *auth.Store and env; mounts ResolveSession + RedirectIfAuthed + signup routes"
  key_links:
    - from: backend/internal/web/handlers_auth.go
      to: backend/internal/auth/session.go
      via: "auth.Store.Create on successful signup"
      pattern: "store\\.Create\\("
    - from: backend/internal/web/handlers_auth.go
      to: backend/internal/db/sqlc/users.sql.go
      via: "Queries.InsertUser with normalized email + argon2id hash"
      pattern: "InsertUser"
    - from: backend/internal/web/router.go
      to: backend/internal/auth/middleware.go
      via: "r.Use(auth.ResolveSession(store)) before route groups (D-24)"
      pattern: "ResolveSession"
---

<objective>
Deliver the SIGNUP vertical slice end-to-end: templ form -> POST handler -> validate -> argon2 hash -> InsertUser -> Store.Create -> set cookie -> redirect to /. Wire ResolveSession + the signup routes into the chi router. A user can now create an account and is logged in afterwards.

Purpose: first user-visible behavior of Phase 2. After this plan ships, a real human can sign up via http://localhost:8080/signup. Login is still missing (Plan 05) and home page is still public (Plan 06), but signup -> session -> redirect works.
Output: working signup flow with real Postgres tests.
</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/phases/02-authentication/02-CONTEXT.md
@.planning/phases/02-authentication/02-RESEARCH.md
@.planning/phases/02-authentication/02-01-PLAN.md
@.planning/phases/02-authentication/02-02-PLAN.md
@.planning/phases/02-authentication/02-03-PLAN.md
@backend/templates/layout.templ
@backend/internal/web/ui/button.templ
@backend/internal/web/ui/card.templ
@backend/internal/web/router.go
@backend/internal/web/handlers.go
@backend/cmd/web/main.go

<interfaces>
Templates (new):
- SignupPage(formValues SignupForm, errors SignupErrors) — full page wrapped in Layout("Sign up").
- SignupFormFragment(formValues SignupForm, errors SignupErrors) — just the form, used for HTMX swaps with `hx-target="#signup-form" hx-swap="outerHTML"`.

Handler types (in handlers_auth.go):
```
type SignupForm struct { Email, Password string }
type SignupErrors struct { Email, Password, General string }
```

Handler constructors:
- SignupPageHandler() http.HandlerFunc — renders SignupPage with empty form.
- SignupPostHandler(deps AuthDeps) http.HandlerFunc — validates, hashes, inserts, creates session, redirects.

AuthDeps (defined in handlers_auth.go):
```
type AuthDeps struct {
    Queries *sqlc.Queries
    Store   *auth.Store
    Secure  bool   // env != "dev"
}
```

Router signature change:
- NewRouter(pinger Pinger, staticDir string, deps AuthDeps) http.Handler.
  Phase 1 callers in cmd/web/main.go and existing tests must be updated to pass deps; in tests, a zero AuthDeps is acceptable because Phase 1 routes do not touch it.
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Signup templates (full page + HTMX fragment) and a render smoke test</name>
  <read_first>
    backend/templates/layout.templ
    backend/templates/index.templ
    backend/internal/web/ui/button.templ
    backend/internal/web/ui/card.templ
    .planning/phases/02-authentication/02-RESEARCH.md (Pattern 9; "Templ helpers" example)
    .planning/phases/02-authentication/02-CONTEXT.md (D-19, D-25)
  </read_first>
  <action>
    Create backend/templates/auth_form_errors.templ exporting:
    - templ FieldError(msg string) — renders nothing if msg == "", else a small &lt;p class="mt-1 text-sm text-red-700"&gt;{msg}&lt;/p&gt;.
    - templ GeneralError(msg string) — renders nothing if msg == "", else a banner above the form.

    Create backend/templates/auth_signup.templ:
    - Package templates. Import backend/internal/web/ui.
    - templ SignupPage(form SignupForm, errs SignupErrors) wrapped in @Layout("Sign up"). Use @ui.Card(nil) container. Heading "Create your account".
    - templ SignupFormFragment(form SignupForm, errs SignupErrors) — the &lt;form id="signup-form" method="POST" action="/signup" hx-post="/signup" hx-target="#signup-form" hx-swap="outerHTML"&gt; with two labeled inputs (email, password) and an @ui.Button submit. Email input value preserves form.Email on re-render; password is never echoed back. Each field's @FieldError is the next sibling.
    - Forms include a placeholder comment `<!-- CSRF field added in Plan 07 -->` (no actual csrf rendering yet; Plan 07 wires gorilla/csrf).
    - SignupPage delegates the inner form section to @SignupFormFragment(form, errs) so the same component is reused for HTMX swaps.

    Define SignupForm and SignupErrors in backend/internal/web/handlers_auth.go (created in Task 2) so the templ files can import them via type aliases — OR define them in a new package backend/internal/web/forms/signup.go. Recommendation: define in templates package itself (backend/templates/auth_signup.templ can declare the types in a sibling Go file backend/templates/auth_forms.go) to avoid an import cycle between templates and internal/web. Document the choice in the SUMMARY.

    Run `cd backend &amp;&amp; templ generate` and `go build ./templates/...`.

    Add a render smoke test backend/templates/auth_signup_test.go:
    - TestSignupPage_RendersForm: Render SignupPage(SignupForm{Email:"x@y.z"}, SignupErrors{}) to a bytes.Buffer; assert body contains name="email", name="password", action="/signup", hx-post="/signup", and value="x@y.z" (email roundtrips).
    - TestSignupFormFragment_RendersErrors: Render with errs.Password="Password must be 12-128 characters"; assert body contains the literal error message and does NOT contain a &lt;html&gt; tag (fragment, not full page).
    - TestSignupPage_DoesNotEchoPassword: Render with form.Password="hunter2hunter2"; assert body does NOT contain "hunter2".
  </action>
  <verify>
    <automated>cd backend &amp;&amp; templ generate &amp;&amp; go build ./templates/... &amp;&amp; go test ./templates/ -run TestSignup -count=1 -v 2>&amp;1 | tail -20</automated>
  </verify>
  <acceptance_criteria>
    - Files auth_signup.templ, auth_form_errors.templ exist in backend/templates/.
    - SignupForm and SignupErrors types are defined exactly once (in templates package or in a forms package referenced by both templ and handler — planner's call; SUMMARY records it).
    - grep -c "templ SignupPage\|templ SignupFormFragment" backend/templates/auth_signup.templ == 2.
    - grep -c "hx-post=\"/signup\"" backend/templates/auth_signup.templ &gt;= 1.
    - grep -c "hx-target=\"#signup-form\"" backend/templates/auth_signup.templ &gt;= 1.
    - cd backend &amp;&amp; templ generate exits 0; auth_signup_templ.go is produced.
    - cd backend &amp;&amp; go test ./templates/ -run TestSignup -count=1 exits 0 with the three smoke tests PASS.
    - Password roundtrip test confirms password is NOT echoed back in any rendered HTML.
  </acceptance_criteria>
  <done>templ pages compile and render; types are agreed across templ + handler boundary; password never leaks back to the client on re-render.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: SignupPostHandler + router wiring + cmd/web main update + integration tests</name>
  <read_first>
    backend/templates/auth_signup.templ (and generated _templ.go)
    backend/internal/auth/password.go
    backend/internal/auth/session.go
    backend/internal/auth/middleware.go
    backend/internal/auth/cookie.go
    backend/internal/db/sqlc/users.sql.go
    backend/internal/web/router.go
    backend/internal/web/handlers.go
    backend/cmd/web/main.go
    .planning/phases/02-authentication/02-RESEARCH.md (Pattern 6, Pattern 9, Handler skeleton; Pitfall 1 already mitigated since CSRF is Plan 07; Pitfall 4 — argon2 cost in tests)
    .planning/phases/02-authentication/02-CONTEXT.md (D-11, D-19, D-20, D-23, D-24, D-25)
    backend/internal/auth/password.go (auth.TestParams — reduced-cost params for pre-seeded rows)
  </read_first>
  <behavior>
    Handler tests in backend/internal/web/handlers_auth_test.go (use setupTestDB pattern from Plan 01 — copy a thin wrapper into internal/web/testdb_test.go that delegates to the auth helper or imports it):
    - TestSignup_Success: POST /signup with email=alice@example.com password=correct-horse-12 -> 303 with Location: / (or 200 with HX-Redirect: / when HX-Request: true). Set-Cookie header present with name = SessionCookieName, HttpOnly, SameSite=Lax. SELECT FROM users WHERE email='alice@example.com' returns one row; password_hash starts with "$argon2id$". SELECT COUNT(*) FROM sessions WHERE user_id = users.id == 1.
    - TestSignup_Success_HTMX: same with HX-Request: true header -> 200 + HX-Redirect: /.
    - TestSignup_InvalidEmail: POST with email="not-an-email" -> 422 (or 200 — planner picks; document) with body containing "valid email" error string; no user row inserted.
    - TestSignup_PasswordTooShort: password length 11 -> 422 with body containing "12" (the length boundary); no user row inserted.
    - TestSignup_PasswordTooLong: password length 129 -> 422 with body referencing "128"; no user row inserted.
    - TestSignup_DuplicateEmail: pre-insert a user; POST with same email -> 200/422 with body containing "already in use" or equivalent; no second user inserted; pgx unique-violation correctly trapped via errors.As against *pgconn.PgError code "23505".
    - TestSignup_EmailNormalized: POST email="  Alice@Example.COM  " -> user row stored email is the original case (citext handles case), and downstream lookup via "alice@example.com" finds it. Verify email is trimmed (no leading/trailing whitespace) in the stored value.
    - TestSignup_AlreadyAuthedBouncesHome: GET /signup with a valid session cookie set -> 303 Location: / (RedirectIfAuthed). Real-DB test.

    **Argon2 wall-time mitigation (W4):** Production code paths in this handler call `auth.Hash` with `DefaultParams` (64 MiB, ~250ms per call). Tests that require a pre-existing user row (TestSignup_DuplicateEmail, TestSignup_AlreadyAuthedBouncesHome, and any future test pre-seeding users) MUST pre-seed via a direct sqlc `InsertUser` call using a hash precomputed once with `auth.Hash(pw, auth.TestParams)` — NOT by exercising the production handler twice. The handler's hot path is unchanged; only the test setup avoids redundant 250ms hashes. Do NOT introduce an `AuthDeps.HashParams` knob — production code stays on `DefaultParams` unconditionally. Target wall time: `go test ./internal/web -run TestSignup_` completes in ≤30s.
  </behavior>
  <action>
    Create backend/internal/web/handlers_auth.go:
    - package web. Imports: net/http, net/mail, strings, errors, backend/internal/auth, backend/internal/db/sqlc, backend/templates, github.com/jackc/pgx/v5/pgconn.
    - AuthDeps struct { Queries *sqlc.Queries; Store *auth.Store; Secure bool }.
    - SignupPageHandler() http.HandlerFunc: renders templates.SignupPage(empty form, empty errors).
    - SignupPostHandler(deps AuthDeps) http.HandlerFunc:
      1. email := strings.TrimSpace(r.PostFormValue("email")); password := r.PostFormValue("password"). Do NOT lowercase before display, but DO normalize (lowercase) before insert per RESEARCH "Email normalization".
      2. validate: if _, err := mail.ParseAddress(email); err != nil -> errs.Email = "Enter a valid email address". if len(password) < 12 -> errs.Password = "Password must be at least 12 characters". if len(password) > 128 -> errs.Password = "Password must be at most 128 characters". (D-25, V5)
      3. if any err: renderSignupError(w, r, form, errs, http.StatusUnprocessableEntity); return.
      4. normalized := strings.ToLower(email). hash, err := auth.Hash(password, auth.DefaultParams); if err != nil -> http.Error(w, "internal", 500); return.
      5. user, err := deps.Queries.InsertUser(ctx, sqlc.InsertUserParams{Email: normalized, PasswordHash: hash}). If err: check errors.As(&pgErr) && pgErr.Code == "23505" -> errs.Email = "That email is already in use." -> renderSignupError 422 (specific error OK per CONTEXT.md "Specific Ideas"). Other errors -> 500.
      6. cookieValue, expiresAt, err := deps.Store.Create(ctx, user.ID). 500 on error.
      7. auth.SetSessionCookie(w, cookieValue, expiresAt, deps.Secure) — the cookie helpers are exported by Plan 03 (SetSessionCookie / ClearSessionCookie); no rename needed in this plan.
      8. if r.Header.Get("HX-Request") == "true" -> w.Header().Set("HX-Redirect", "/"); w.WriteHeader(http.StatusOK); return. Else http.Redirect(w, r, "/", http.StatusSeeOther).
    - renderSignupError(w, r, form, errs, status int) helper: w.WriteHeader(status); if HX-Request: render SignupFormFragment, else render SignupPage.

    Update backend/internal/web/router.go:
    - Change signature: NewRouter(pinger Pinger, staticDir string, deps AuthDeps) http.Handler.
    - Insert r.Use(auth.ResolveSession(deps.Store)) AFTER chimw.Recoverer and BEFORE any route declarations. Order is now: RequestIDMiddleware -> RealIP -> SlogLogger -> Recoverer -> ResolveSession (D-24). NOTE: csrf.Protect is added in Plan 07.
    - Mount /signup routes:
      r.Group(func(r chi.Router) {
          r.Use(auth.RedirectIfAuthed)
          r.Get("/signup", SignupPageHandler())
      })
      r.Post("/signup", SignupPostHandler(deps))
    - Keep existing public routes (/, /healthz, /demo/time, /static/*) unchanged. (/ becomes protected in Plan 06.)

    Update backend/cmd/web/main.go:
    - After db.NewPool, build deps: q := sqlc.New(pool); store := auth.NewStore(q); secure := env != "development" &amp;&amp; env != "dev". Pass AuthDeps{Queries: q, Store: store, Secure: secure} to NewRouter.

    Update backend/internal/web/handlers_test.go (Phase 1 tests):
    - The four existing tests pass through NewRouter with the new signature. Construct AuthDeps{} (zero value — Queries/Store nil) for Phase 1 route tests; those routes don't touch deps. If ResolveSession is unconditionally added, ensure it tolerates a nil Store on the "no cookie present" path (which it does — Lookup is only called when a cookie exists). Add a guard `if store == nil { return func(next http.Handler) http.Handler { return next } }` at the top of ResolveSession to keep the Phase 1 tests working without a real store. Document this fallback in the Plan 03 doc.go (or extend it now).

    Write backend/internal/web/handlers_auth_test.go with the eight tests above. Reuse setupTestDB from auth package by exposing it as a small test-only helper or duplicating ~20 LOC; planner chooses (record in SUMMARY). For each test build NewRouter(stubPinger{}, "./static", AuthDeps{Queries: q, Store: store, Secure: false}) and exercise httptest.NewServer / httptest.NewRecorder.

    Anti-pattern guards: do NOT log the email on validation errors (V7 / Pitfall: avoid email-in-logs). Do NOT echo the password back to templates. Do NOT use 302 for the success redirect (Pitfall 9). Do NOT call Hash before length validation (T-2-14 DoS guard).
  </action>
  <verify>
    <automated>cd backend &amp;&amp; templ generate &amp;&amp; TEST_DATABASE_URL="$DATABASE_URL" go test ./internal/web/... ./internal/auth/... ./templates/... -count=1 2>&amp;1 | tee /tmp/p04t2.log | tail -30 &amp;&amp; grep -q "ok " /tmp/p04t2.log &amp;&amp; ! grep -E "^FAIL|^--- FAIL" /tmp/p04t2.log</automated>
  </verify>
  <acceptance_criteria>
    - handlers_auth.go exists; grep -c "func SignupPostHandler\|func SignupPageHandler" == 2.
    - handlers_auth.go contains the literal substring `mail.ParseAddress` and `auth.Hash` and `deps.Store.Create` and `auth.SetSessionCookie`.
    - handlers_auth.go does NOT contain `slog.*email` patterns referencing the request email (grep gate: grep -E "slog.*\"email\".*email|Info.*email" backend/internal/web/handlers_auth.go | grep -v "// " | wc -l == 0).
    - router.go: grep -c "auth.ResolveSession" == 1; grep -c "auth.RedirectIfAuthed" == 1; grep -c "/signup" &gt;= 2 (GET and POST).
    - The middleware order in router.go (top-to-bottom r.Use calls) is exactly: RequestIDMiddleware, chimw.RealIP, SlogLoggerMiddleware, chimw.Recoverer, auth.ResolveSession. Verified by inspection (acceptance criterion documented for checker).
    - cmd/web/main.go builds AuthDeps and passes it to NewRouter; grep -c "AuthDeps{" backend/cmd/web/main.go &gt;= 1.
    - cd backend &amp;&amp; templ generate exits 0.
    - cd backend &amp;&amp; go build ./... exits 0.
    - cd backend &amp;&amp; TEST_DATABASE_URL=$DATABASE_URL go test ./internal/web/ -run TestSignup -count=1 exits 0 with all eight TestSignup_* tests PASS.
    - cd backend &amp;&amp; go test ./internal/web/ -run "TestHealthz|TestIndex|TestDemoTime|TestRequestID|TestSlog" -count=1 exits 0 (Phase 1 tests still pass with new NewRouter signature).
    - No Verify(false) hash compare anywhere (anti-pattern guard from Plan 02 still holds).
  </acceptance_criteria>
  <done>Signup works end-to-end against a real Postgres; an authed user bouncing off /signup is verified; Phase 1 tests still pass; cmd/web builds and runs.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| Client form -> handler | POST body crosses into the trusted zone; validated by mail.ParseAddress + length check BEFORE argon2 (DoS guard) |
| Handler -> Postgres | InsertUser uses parameterized sqlc query; unique constraint enforces email uniqueness atomically |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-2-01 | Information disclosure | Password storage on signup | mitigate | auth.Hash(password, DefaultParams) before InsertUser; raw password never touches DB. Verified by TestSignup_Success grepping password_hash for "$argon2id$". |
| T-2-04 | Spoofing | Session fixation post-signup | mitigate | Store.Create issues a fresh token; user has no prior session at signup. Verified by TestSignup_Success asserting cookie present + session row exists. |
| T-2-14 | DoS | Long-password argon2 abuse | mitigate | Length ceiling 128 enforced BEFORE auth.Hash. Verified by TestSignup_PasswordTooLong. |
| T-2-18 | Information disclosure | Email leaked via slog | mitigate | Handler logs event="signup_failed" with no email field. Acceptance grep gate. |
| T-2-19 | Tampering | Race on duplicate signup | mitigate | DB UNIQUE constraint + pgconn.PgError code 23505 mapped to user-facing error. Verified by TestSignup_DuplicateEmail. |
| T-2-20 | Spoofing | XSS via reflected email | mitigate | templ auto-escapes all expressions; no templ.Raw on user input. Verified by Task 1 render smoke test (no script-injection vector). |
</threat_model>

<verification>
- All eight TestSignup_* tests pass against a real Postgres.
- Phase 1 tests still pass with the updated NewRouter signature.
- `just dev` boots; opening http://localhost:8080/signup renders the page (manual UAT — recorded in HUMAN-UAT.md at end of phase).
- Middleware order in router.go matches D-24 (RequestID -> RealIP -> Slog -> Recoverer -> ResolveSession).
- Wall-time budget: `cd backend && TEST_DATABASE_URL=$DATABASE_URL go test ./internal/web -run "TestSignup_" -count=1` completes in ≤30s. Pre-seed test users via direct sqlc `InsertUser` using a hash computed with `auth.TestParams` to avoid redundant argon2 `DefaultParams` calls in setup.
</verification>

<success_criteria>
- A new user can hit /signup, submit valid creds, and end up at / with a valid session cookie.
- Duplicate email and validation errors render inline (HTMX) or full-page (no-JS) as appropriate.
- No regressions in Phase 1 routes.
</success_criteria>

<output>
Create .planning/phases/02-authentication/02-04-SUMMARY.md recording: where SignupForm/SignupErrors types live (templates pkg vs forms pkg), test-helper duplication strategy (shared vs duplicated setupTestDB), pgconn unique-violation code path, manual UAT screenshot/notes if captured.
</output>
