TWiT-Ads v2 Rebuild

A2 — Auth, Users, Roles 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: Local-login authentication with DB-backed sessions, the three roles (admin/sales/continuity) + ratecard flag, a user-admin page, and the audit-log foundation every later stage writes to.

Architecture: Password auth (bcrypt) against a users table; opaque session tokens in a sessions table, delivered as an HttpOnly cookie. Middleware resolves the session into a auth.User in the request context; role gates wrap route groups. The auth entry point is deliberately pluggable — production may later swap in Cloudflare Access trusted-header identity (spec §6) without touching handlers. First server-rendered templates land here (layout, login, users admin) — plain forms, no htmx yet.

Tech Stack: adds golang.org/x/crypto (bcrypt) only. Everything else is A1's stack.

Global Constraints (same as A1 — and read ~/Projects/twit-ads/CLAUDE.md first)


Task 1: Migration 00002 + password hashing

Files:

Interfaces:

-- +goose Up
CREATE TYPE user_role AS ENUM ('admin', 'sales', 'continuity');

CREATE TABLE users (
    id                bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    username          text NOT NULL UNIQUE,
    display_name      text NOT NULL,
    password_hash     text NOT NULL,
    role              user_role NOT NULL,
    can_edit_ratecard boolean NOT NULL DEFAULT false,
    active            boolean NOT NULL DEFAULT true,
    created_at        timestamptz NOT NULL DEFAULT now(),
    updated_at        timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE sessions (
    token      text PRIMARY KEY,
    user_id    bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    created_at timestamptz NOT NULL DEFAULT now(),
    expires_at timestamptz NOT NULL
);
CREATE INDEX sessions_expires_idx ON sessions (expires_at);

CREATE TABLE audit_log (
    id         bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    at         timestamptz NOT NULL DEFAULT now(),
    actor_id   bigint REFERENCES users(id),
    actor_name text NOT NULL DEFAULT '',
    entity     text NOT NULL,
    entity_id  text NOT NULL DEFAULT '',
    action     text NOT NULL,
    detail     jsonb NOT NULL DEFAULT '{}'::jsonb
);
CREATE INDEX audit_log_entity_idx ON audit_log (entity, entity_id);
CREATE INDEX audit_log_at_idx ON audit_log (at);

-- +goose Down
DROP TABLE audit_log;
DROP TABLE sessions;
DROP TABLE users;
DROP TYPE user_role;
package auth

import "testing"

func TestHashAndVerify(t *testing.T) {
	h, err := HashPassword("correct horse battery staple")
	if err != nil {
		t.Fatal(err)
	}
	if h == "correct horse battery staple" {
		t.Fatal("hash must not equal password")
	}
	if !VerifyPassword(h, "correct horse battery staple") {
		t.Error("correct password rejected")
	}
	if VerifyPassword(h, "wrong") {
		t.Error("wrong password accepted")
	}
}

func TestEmptyPasswordRejected(t *testing.T) {
	if _, err := HashPassword(""); err == nil {
		t.Error("empty password must error")
	}
}

Run: cd ~/Projects/twit-ads/app && go test ./internal/auth/ Expected: FAIL — undefined: HashPassword.

go get golang.org/x/crypto@latest
go list -m -json golang.org/x/crypto | grep -E '"(Version|Time)"'   # if Time < 14 days ago: pin previous
// Package auth: identity, passwords, sessions, and role checks.
package auth

import (
	"errors"

	"golang.org/x/crypto/bcrypt"
)

func HashPassword(pw string) (string, error) {
	if pw == "" {
		return "", errors.New("password must not be empty")
	}
	h, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
	return string(h), err
}

func VerifyPassword(hash, pw string) bool {
	return bcrypt.CompareHashAndPassword([]byte(hash), []byte(pw)) == nil
}

Run: go test ./internal/auth/ -v → 2 PASS.

cd ~/Projects/twit-ads
git add app/internal/db/migrations/00002_users_sessions_audit.sql app/internal/auth/ app/go.mod app/go.sum
git commit -m "feat(auth): users/sessions/audit schema + bcrypt password hashing"

Task 2: Users store (integration-tested)

Files:

Interfaces:

type Role string // RoleAdmin Role = "admin"; RoleSales = "sales"; RoleContinuity = "continuity"
type User struct {
    ID              int64
    Username        string
    DisplayName     string
    Role            Role
    CanEditRatecard bool
    Active          bool
}
type Users struct{ Pool *pgxpool.Pool }
func (u *Users) Create(ctx, username, displayName, password string, role Role, ratecard bool) (User, error)
func (u *Users) GetByUsername(ctx, username string) (User, string /*passwordHash*/, error)
func (u *Users) GetByID(ctx, id int64) (User, error)
func (u *Users) List(ctx) ([]User, error)                    // username order, inactive included
func (u *Users) Update(ctx, id int64, displayName string, role Role, ratecard, active bool) error
func (u *Users) ResetPassword(ctx, id int64, newPassword string) error
func (u *Users) Count(ctx) (int, error)

All errors wrapped; Create validates role via the enum (DB rejects bad values) and hashes with HashPassword.

In app/justfile, change the last line of the test-db recipe to:

    TEST_DATABASE_URL="postgres://twitads:twitads@127.0.0.1:5445/twitads_test" go test ./... -count=1
package auth

import (
	"context"
	"os"
	"testing"
	"time"

	"github.com/jackc/pgx/v5/pgxpool"
	"twit.tv/twitads/internal/db"
)

// testPool migrates the test database, truncates auth tables, and returns a pool.
func testPool(t *testing.T) *pgxpool.Pool {
	t.Helper()
	url := os.Getenv("TEST_DATABASE_URL")
	if url == "" {
		t.Skip("TEST_DATABASE_URL not set; run via `just test-db`")
	}
	if err := db.Migrate(url); err != nil {
		t.Fatalf("migrate: %v", err)
	}
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	pool, err := db.Connect(ctx, url)
	if err != nil {
		t.Fatalf("connect: %v", err)
	}
	t.Cleanup(pool.Close)
	if _, err := pool.Exec(ctx, "TRUNCATE users, sessions, audit_log RESTART IDENTITY CASCADE"); err != nil {
		t.Fatalf("truncate: %v", err)
	}
	return pool
}
package auth

import (
	"context"
	"testing"
)

func TestUserLifecycle(t *testing.T) {
	pool := testPool(t)
	ctx := context.Background()
	users := &Users{Pool: pool}

	u, err := users.Create(ctx, "lisa", "Lisa Laporte", "s3cret-pw", RoleSales, true)
	if err != nil {
		t.Fatalf("create: %v", err)
	}
	if u.ID == 0 || u.Role != RoleSales || !u.CanEditRatecard || !u.Active {
		t.Errorf("bad user: %+v", u)
	}

	got, hash, err := users.GetByUsername(ctx, "lisa")
	if err != nil {
		t.Fatalf("get: %v", err)
	}
	if !VerifyPassword(hash, "s3cret-pw") {
		t.Error("stored hash does not verify")
	}
	if got.ID != u.ID {
		t.Errorf("id mismatch")
	}

	if err := users.Update(ctx, u.ID, "Lisa L.", RoleAdmin, false, false); err != nil {
		t.Fatalf("update: %v", err)
	}
	got2, err := users.GetByID(ctx, u.ID)
	if err != nil {
		t.Fatal(err)
	}
	if got2.Role != RoleAdmin || got2.CanEditRatecard || got2.Active || got2.DisplayName != "Lisa L." {
		t.Errorf("update not applied: %+v", got2)
	}

	if err := users.ResetPassword(ctx, u.ID, "new-pw"); err != nil {
		t.Fatal(err)
	}
	_, hash2, _ := users.GetByUsername(ctx, "lisa")
	if !VerifyPassword(hash2, "new-pw") {
		t.Error("password reset did not take")
	}

	n, err := users.Count(ctx)
	if err != nil || n != 1 {
		t.Errorf("count = %d (%v), want 1", n, err)
	}
}

func TestDuplicateUsernameRejected(t *testing.T) {
	pool := testPool(t)
	ctx := context.Background()
	users := &Users{Pool: pool}
	if _, err := users.Create(ctx, "debi", "Debi", "pw1", RoleContinuity, false); err != nil {
		t.Fatal(err)
	}
	if _, err := users.Create(ctx, "debi", "Debi 2", "pw2", RoleContinuity, false); err == nil {
		t.Error("duplicate username must error")
	}
}

func TestListOrdersByUsername(t *testing.T) {
	pool := testPool(t)
	ctx := context.Background()
	users := &Users{Pool: pool}
	for _, name := range []string{"veva", "debi", "sebastian"} {
		if _, err := users.Create(ctx, name, name, "pw", RoleContinuity, false); err != nil {
			t.Fatal(err)
		}
	}
	list, err := users.List(ctx)
	if err != nil {
		t.Fatal(err)
	}
	if len(list) != 3 || list[0].Username != "debi" || list[2].Username != "veva" {
		t.Errorf("bad list order: %+v", list)
	}
}

Run: go vet ./internal/auth/undefined: Users (et al.).

package auth

import (
	"context"
	"fmt"

	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgxpool"
)

type Role string

const (
	RoleAdmin      Role = "admin"
	RoleSales      Role = "sales"
	RoleContinuity Role = "continuity"
)

type User struct {
	ID              int64
	Username        string
	DisplayName     string
	Role            Role
	CanEditRatecard bool
	Active          bool
}

type Users struct{ Pool *pgxpool.Pool }

const userCols = "id, username, display_name, role, can_edit_ratecard, active"

func scanUser(row pgx.Row) (User, error) {
	var u User
	err := row.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Role, &u.CanEditRatecard, &u.Active)
	return u, err
}

func (s *Users) Create(ctx context.Context, username, displayName, password string, role Role, ratecard bool) (User, error) {
	hash, err := HashPassword(password)
	if err != nil {
		return User{}, err
	}
	row := s.Pool.QueryRow(ctx,
		`INSERT INTO users (username, display_name, password_hash, role, can_edit_ratecard)
		 VALUES ($1, $2, $3, $4, $5) RETURNING `+userCols,
		username, displayName, hash, role, ratecard)
	u, err := scanUser(row)
	if err != nil {
		return User{}, fmt.Errorf("create user: %w", err)
	}
	return u, nil
}

func (s *Users) GetByUsername(ctx context.Context, username string) (User, string, error) {
	var u User
	var hash string
	err := s.Pool.QueryRow(ctx,
		`SELECT `+userCols+`, password_hash FROM users WHERE username = $1`, username).
		Scan(&u.ID, &u.Username, &u.DisplayName, &u.Role, &u.CanEditRatecard, &u.Active, &hash)
	if err != nil {
		return User{}, "", fmt.Errorf("get user %q: %w", username, err)
	}
	return u, hash, nil
}

func (s *Users) GetByID(ctx context.Context, id int64) (User, error) {
	u, err := scanUser(s.Pool.QueryRow(ctx, `SELECT `+userCols+` FROM users WHERE id = $1`, id))
	if err != nil {
		return User{}, fmt.Errorf("get user %d: %w", id, err)
	}
	return u, nil
}

func (s *Users) List(ctx context.Context) ([]User, error) {
	rows, err := s.Pool.Query(ctx, `SELECT `+userCols+` FROM users ORDER BY username`)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []User
	for rows.Next() {
		u, err := scanUser(rows)
		if err != nil {
			return nil, err
		}
		out = append(out, u)
	}
	return out, rows.Err()
}

func (s *Users) Update(ctx context.Context, id int64, displayName string, role Role, ratecard, active bool) error {
	tag, err := s.Pool.Exec(ctx,
		`UPDATE users SET display_name=$2, role=$3, can_edit_ratecard=$4, active=$5, updated_at=now() WHERE id=$1`,
		id, displayName, role, ratecard, active)
	if err != nil {
		return fmt.Errorf("update user %d: %w", id, err)
	}
	if tag.RowsAffected() == 0 {
		return fmt.Errorf("update user %d: not found", id)
	}
	return nil
}

func (s *Users) ResetPassword(ctx context.Context, id int64, newPassword string) error {
	hash, err := HashPassword(newPassword)
	if err != nil {
		return err
	}
	tag, err := s.Pool.Exec(ctx, `UPDATE users SET password_hash=$2, updated_at=now() WHERE id=$1`, id, hash)
	if err != nil {
		return fmt.Errorf("reset password %d: %w", id, err)
	}
	if tag.RowsAffected() == 0 {
		return fmt.Errorf("reset password %d: not found", id)
	}
	return nil
}

func (s *Users) Count(ctx context.Context) (int, error) {
	var n int
	err := s.Pool.QueryRow(ctx, `SELECT count(*) FROM users`).Scan(&n)
	return n, err
}

Run: just test-db → all auth tests PASS (db tests too).

cd ~/Projects/twit-ads
git add app/internal/auth/ app/justfile
git commit -m "feat(auth): users store with lifecycle integration tests"

Task 3: Sessions store

Files:

Interfaces:

type Sessions struct{ Pool *pgxpool.Pool }
func (s *Sessions) Create(ctx, userID int64) (token string, err error)   // 32 random bytes hex; 30-day expiry; purges expired rows opportunistically
func (s *Sessions) Lookup(ctx, token string) (User, error)               // joins users; errors if expired, missing, or user inactive
func (s *Sessions) Delete(ctx, token string) error
var ErrNoSession = errors.New("no valid session")
package auth

import (
	"context"
	"errors"
	"testing"
)

func TestSessionRoundtrip(t *testing.T) {
	pool := testPool(t)
	ctx := context.Background()
	users := &Users{Pool: pool}
	sessions := &Sessions{Pool: pool}

	u, err := users.Create(ctx, "ty", "Ty", "pw", RoleSales, false)
	if err != nil {
		t.Fatal(err)
	}
	tok, err := sessions.Create(ctx, u.ID)
	if err != nil {
		t.Fatal(err)
	}
	if len(tok) < 32 {
		t.Errorf("token too short: %q", tok)
	}
	got, err := sessions.Lookup(ctx, tok)
	if err != nil {
		t.Fatalf("lookup: %v", err)
	}
	if got.ID != u.ID || got.Role != RoleSales {
		t.Errorf("wrong user: %+v", got)
	}

	if err := sessions.Delete(ctx, tok); err != nil {
		t.Fatal(err)
	}
	if _, err := sessions.Lookup(ctx, tok); !errors.Is(err, ErrNoSession) {
		t.Errorf("deleted session should be ErrNoSession, got %v", err)
	}
}

func TestExpiredSessionRejected(t *testing.T) {
	pool := testPool(t)
	ctx := context.Background()
	users := &Users{Pool: pool}
	sessions := &Sessions{Pool: pool}
	u, _ := users.Create(ctx, "x", "X", "pw", RoleSales, false)
	tok, _ := sessions.Create(ctx, u.ID)
	if _, err := pool.Exec(ctx, "UPDATE sessions SET expires_at = now() - interval '1 minute' WHERE token=$1", tok); err != nil {
		t.Fatal(err)
	}
	if _, err := sessions.Lookup(ctx, tok); !errors.Is(err, ErrNoSession) {
		t.Errorf("expired session should be ErrNoSession, got %v", err)
	}
}

func TestInactiveUserSessionRejected(t *testing.T) {
	pool := testPool(t)
	ctx := context.Background()
	users := &Users{Pool: pool}
	sessions := &Sessions{Pool: pool}
	u, _ := users.Create(ctx, "gone", "Gone", "pw", RoleSales, false)
	tok, _ := sessions.Create(ctx, u.ID)
	if err := users.Update(ctx, u.ID, "Gone", RoleSales, false, false); err != nil {
		t.Fatal(err)
	}
	if _, err := sessions.Lookup(ctx, tok); !errors.Is(err, ErrNoSession) {
		t.Errorf("inactive user's session should be ErrNoSession, got %v", err)
	}
}
package auth

import (
	"context"
	"crypto/rand"
	"encoding/hex"
	"errors"
	"fmt"

	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgxpool"
)

var ErrNoSession = errors.New("no valid session")

const sessionTTL = "30 days"

type Sessions struct{ Pool *pgxpool.Pool }

func (s *Sessions) Create(ctx context.Context, userID int64) (string, error) {
	raw := make([]byte, 32)
	if _, err := rand.Read(raw); err != nil {
		return "", err
	}
	token := hex.EncodeToString(raw)
	// opportunistic purge keeps the table tiny without a background job
	if _, err := s.Pool.Exec(ctx, `DELETE FROM sessions WHERE expires_at < now()`); err != nil {
		return "", fmt.Errorf("purge sessions: %w", err)
	}
	_, err := s.Pool.Exec(ctx,
		`INSERT INTO sessions (token, user_id, expires_at) VALUES ($1, $2, now() + $3::interval)`,
		token, userID, sessionTTL)
	if err != nil {
		return "", fmt.Errorf("create session: %w", err)
	}
	return token, nil
}

func (s *Sessions) Lookup(ctx context.Context, token string) (User, error) {
	row := s.Pool.QueryRow(ctx,
		`SELECT u.id, u.username, u.display_name, u.role, u.can_edit_ratecard, u.active
		 FROM sessions s JOIN users u ON u.id = s.user_id
		 WHERE s.token = $1 AND s.expires_at > now() AND u.active`, token)
	u, err := scanUser(row)
	if errors.Is(err, pgx.ErrNoRows) {
		return User{}, ErrNoSession
	}
	if err != nil {
		return User{}, fmt.Errorf("lookup session: %w", err)
	}
	return u, nil
}

func (s *Sessions) Delete(ctx context.Context, token string) error {
	_, err := s.Pool.Exec(ctx, `DELETE FROM sessions WHERE token = $1`, token)
	return err
}
cd ~/Projects/twit-ads
git add app/internal/auth/sessions.go app/internal/auth/sessions_test.go
git commit -m "feat(auth): DB-backed sessions with expiry and inactive-user rejection"

Task 4: Audit log foundation

Files:

Interfaces:

type Log struct{ Pool *pgxpool.Pool }
// actor may be nil (e.g. failed logins). detail must marshal to JSON.
func (l *Log) Record(ctx, actor *auth.User, entity, entityID, action string, detail any) error

Every later stage calls Record for money-bearing/state mutations — this signature is load-bearing.

package audit

import (
	"context"
	"os"
	"testing"
	"time"

	"twit.tv/twitads/internal/auth"
	"twit.tv/twitads/internal/db"
)

func TestRecord(t *testing.T) {
	url := os.Getenv("TEST_DATABASE_URL")
	if url == "" {
		t.Skip("TEST_DATABASE_URL not set; run via `just test-db`")
	}
	if err := db.Migrate(url); err != nil {
		t.Fatal(err)
	}
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	pool, err := db.Connect(ctx, url)
	if err != nil {
		t.Fatal(err)
	}
	defer pool.Close()
	if _, err := pool.Exec(ctx, "TRUNCATE users, sessions, audit_log RESTART IDENTITY CASCADE"); err != nil {
		t.Fatal(err)
	}

	l := &Log{Pool: pool}
	users := &auth.Users{Pool: pool}
	u, err := users.Create(ctx, "leo", "Leo", "pw", auth.RoleAdmin, false)
	if err != nil {
		t.Fatal(err)
	}

	if err := l.Record(ctx, &u, "user", "42", "update", map[string]any{"active": false}); err != nil {
		t.Fatalf("record with actor: %v", err)
	}
	if err := l.Record(ctx, nil, "auth", "nobody", "login_failed", nil); err != nil {
		t.Fatalf("record without actor: %v", err)
	}

	var n int
	if err := pool.QueryRow(ctx, "SELECT count(*) FROM audit_log").Scan(&n); err != nil {
		t.Fatal(err)
	}
	if n != 2 {
		t.Errorf("audit rows = %d, want 2", n)
	}
	var actorName, detail string
	if err := pool.QueryRow(ctx,
		"SELECT actor_name, detail::text FROM audit_log WHERE entity='user'").Scan(&actorName, &detail); err != nil {
		t.Fatal(err)
	}
	if actorName != "leo" || detail != `{"active": false}` {
		t.Errorf("actor=%q detail=%s", actorName, detail)
	}
}
// Package audit records every state-changing mutation. House rule:
// orders, placements, rate cards, episodes, and users all write here.
package audit

import (
	"context"
	"encoding/json"
	"fmt"

	"github.com/jackc/pgx/v5/pgxpool"
	"twit.tv/twitads/internal/auth"
)

type Log struct{ Pool *pgxpool.Pool }

// Record writes one audit row. actor may be nil (unauthenticated events).
func (l *Log) Record(ctx context.Context, actor *auth.User, entity, entityID, action string, detail any) error {
	if detail == nil {
		detail = map[string]any{}
	}
	blob, err := json.Marshal(detail)
	if err != nil {
		return fmt.Errorf("audit detail: %w", err)
	}
	var actorID *int64
	actorName := ""
	if actor != nil {
		actorID = &actor.ID
		actorName = actor.Username
	}
	_, err = l.Pool.Exec(ctx,
		`INSERT INTO audit_log (actor_id, actor_name, entity, entity_id, action, detail)
		 VALUES ($1, $2, $3, $4, $5, $6)`,
		actorID, actorName, entity, entityID, action, blob)
	if err != nil {
		return fmt.Errorf("audit record: %w", err)
	}
	return nil
}
cd ~/Projects/twit-ads
git add app/internal/audit/
git commit -m "feat(audit): Record() foundation — every mutation from here on writes a row"

Task 5: Bootstrap CLI (-adduser)

Files:

Interfaces:

Add to the flag block:

	addUser := flag.String("adduser", "", "create a user and exit (username)")
	display := flag.String("display", "", "display name for -adduser")
	role := flag.String("role", "", "role for -adduser: admin|sales|continuity")
	ratecard := flag.Bool("ratecard", false, "grant rate-card edit for -adduser")

After the db.Connect block (pool exists), insert:

	if *addUser != "" {
		pw := os.Getenv("TWITADS_PASSWORD")
		if pw == "" {
			log.Error("adduser", "err", "TWITADS_PASSWORD env var required")
			os.Exit(1)
		}
		if *display == "" {
			*display = *addUser
		}
		users := &auth.Users{Pool: pool}
		u, err := users.Create(context.Background(), *addUser, *display, pw, auth.Role(*role), *ratecard)
		if err != nil {
			log.Error("adduser", "err", err)
			os.Exit(1)
		}
		aud := &audit.Log{Pool: pool}
		_ = aud.Record(context.Background(), &u, "user", fmt.Sprint(u.ID), "create", map[string]any{"role": u.Role, "via": "cli"})
		log.Info("user created", "id", u.ID, "username", u.Username, "role", u.Role)
		return
	}

Add imports: "fmt", "twit.tv/twitads/internal/auth", "twit.tv/twitads/internal/audit".

# create a user against the local compose DB (prompts for password)
adduser username role display: up
    #!/usr/bin/env bash
    set -euo pipefail
    read -rsp "Password for {{username}}: " pw; echo
    TWITADS_PASSWORD="$pw" go run ./cmd/twitads -config config.yaml -adduser "{{username}}" -role "{{role}}" -display "{{display}}"
cd ~/Projects/twit-ads/app
just adduser leo admin "Leo Laporte"        # enter a password at the prompt
just psql                                    # then: SELECT username, role, active FROM users; SELECT action FROM audit_log;

Expected: leo | admin | t and an audit row create. Bad role errors cleanly (enum rejects).

cd ~/Projects/twit-ads
git add app/cmd/twitads/main.go app/justfile
git commit -m "feat(cli): -adduser bootstrap with audit trail"

Task 6: Auth middleware (unit-tested with fakes)

Files:

Interfaces:

type SessionReader interface { Lookup(ctx context.Context, token string) (auth.User, error) }
const SessionCookie = "twitads_session"
func CurrentUser(ctx context.Context) (auth.User, bool)
// requireUser: no valid session → 303 redirect to /login. Valid → user in context.
// requireAdmin: wraps requireUser; non-admin → 403.
package server

import (
	"context"
	"net/http"
	"net/http/httptest"
	"testing"

	"twit.tv/twitads/internal/auth"
)

type fakeSessions struct{ users map[string]auth.User }

func (f fakeSessions) Lookup(ctx context.Context, token string) (auth.User, error) {
	u, ok := f.users[token]
	if !ok {
		return auth.User{}, auth.ErrNoSession
	}
	return u, nil
}

func echoUser(w http.ResponseWriter, r *http.Request) {
	u, _ := CurrentUser(r.Context())
	w.Write([]byte(u.Username))
}

func request(h http.Handler, token string) *httptest.ResponseRecorder {
	req := httptest.NewRequest(http.MethodGet, "/x", nil)
	if token != "" {
		req.AddCookie(&http.Cookie{Name: SessionCookie, Value: token})
	}
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, req)
	return rec
}

func TestRequireUserRedirectsAnonymous(t *testing.T) {
	m := &authMiddleware{sessions: fakeSessions{}}
	rec := request(m.requireUser(http.HandlerFunc(echoUser)), "")
	if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/login" {
		t.Errorf("got %d → %q, want 303 → /login", rec.Code, rec.Header().Get("Location"))
	}
}

func TestRequireUserPassesSession(t *testing.T) {
	m := &authMiddleware{sessions: fakeSessions{users: map[string]auth.User{
		"tok1": {ID: 1, Username: "debi", Role: auth.RoleContinuity},
	}}}
	rec := request(m.requireUser(http.HandlerFunc(echoUser)), "tok1")
	if rec.Code != http.StatusOK || rec.Body.String() != "debi" {
		t.Errorf("got %d %q", rec.Code, rec.Body.String())
	}
}

func TestRequireAdminForbidsSales(t *testing.T) {
	m := &authMiddleware{sessions: fakeSessions{users: map[string]auth.User{
		"tok2": {ID: 2, Username: "ty", Role: auth.RoleSales},
	}}}
	rec := request(m.requireAdmin(http.HandlerFunc(echoUser)), "tok2")
	if rec.Code != http.StatusForbidden {
		t.Errorf("got %d, want 403", rec.Code)
	}
}

func TestRequireAdminAllowsAdmin(t *testing.T) {
	m := &authMiddleware{sessions: fakeSessions{users: map[string]auth.User{
		"tok3": {ID: 3, Username: "leo", Role: auth.RoleAdmin},
	}}}
	rec := request(m.requireAdmin(http.HandlerFunc(echoUser)), "tok3")
	if rec.Code != http.StatusOK {
		t.Errorf("got %d, want 200", rec.Code)
	}
}
package server

import (
	"context"
	"net/http"

	"twit.tv/twitads/internal/auth"
)

const SessionCookie = "twitads_session"

type SessionReader interface {
	Lookup(ctx context.Context, token string) (auth.User, error)
}

type ctxKey int

const userKey ctxKey = 0

func CurrentUser(ctx context.Context) (auth.User, bool) {
	u, ok := ctx.Value(userKey).(auth.User)
	return u, ok
}

type authMiddleware struct {
	sessions SessionReader
}

func (m *authMiddleware) requireUser(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		c, err := r.Cookie(SessionCookie)
		if err != nil {
			http.Redirect(w, r, "/login", http.StatusSeeOther)
			return
		}
		u, err := m.sessions.Lookup(r.Context(), c.Value)
		if err != nil {
			http.Redirect(w, r, "/login", http.StatusSeeOther)
			return
		}
		next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), userKey, u)))
	})
}

func (m *authMiddleware) requireAdmin(next http.Handler) http.Handler {
	return m.requireUser(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		u, _ := CurrentUser(r.Context())
		if u.Role != auth.RoleAdmin {
			http.Error(w, "forbidden", http.StatusForbidden)
			return
		}
		next.ServeHTTP(w, r)
	}))
}
cd ~/Projects/twit-ads
git add app/internal/server/middleware.go app/internal/server/middleware_test.go
git commit -m "feat(server): session middleware with role gates"

Task 7: Templates + login/logout + home

Files:

Interfaces:

type Deps struct {
    Env      string            // from config: dev|test|prod (Secure cookie when prod)
    DB       Pinger
    Sessions SessionStore      // interface { Lookup; Create(ctx, userID int64) (string, error); Delete(ctx, token string) error }
    Users    UserStore         // interface { GetByUsername(ctx, username string) (auth.User, string, error) } — widened in Task 8
    Audit    Auditor           // interface { Record(ctx, actor *auth.User, entity, entityID, action string, detail any) error }
}
func New(d Deps) http.Handler

Routes this task: GET /healthz (public), GET /login, POST /login, POST /logout, GET / (requireUser → home page: "signed in as X (role)", nav, logout button).

{{define "layout"}}<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{block "title" .}}TWiT Ads{{end}}</title>
<style>
  body { font-family: system-ui, sans-serif; margin: 2rem auto; max-width: 60rem; padding: 0 1rem; color: #222; }
  header { display: flex; justify-content: space-between; align-items: baseline; border-bottom: 2px solid #16537e; padding-bottom: .5rem; margin-bottom: 1.5rem; }
  header h1 { font-size: 1.2rem; margin: 0; color: #16537e; }
  nav a { margin-right: 1rem; }
  table { border-collapse: collapse; width: 100%; }
  th, td { text-align: left; padding: .4rem .6rem; border-bottom: 1px solid #ddd; }
  .error { color: #b00020; }
  form.inline { display: inline; }
  label { display: block; margin: .5rem 0 .1rem; }
  input, select { padding: .3rem; }
  button { padding: .35rem .9rem; cursor: pointer; }
</style>
</head>
<body>
<header>
  <h1>TWiT Ads</h1>
  {{if .User.Username}}<div>
    {{.User.DisplayName}} ({{.User.Role}})
    <form class="inline" method="post" action="/logout"><button>Log out</button></form>
  </div>{{end}}
</header>
{{block "content" .}}{{end}}
</body>
</html>{{end}}
{{define "title"}}Log in — TWiT Ads{{end}}
{{define "content"}}
<h2>Log in</h2>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<form method="post" action="/login">
  <label for="username">Username</label>
  <input id="username" name="username" autocomplete="username" required autofocus>
  <label for="password">Password</label>
  <input id="password" name="password" type="password" autocomplete="current-password" required>
  <p><button>Log in</button></p>
</form>
{{end}}
{{define "title"}}Home — TWiT Ads{{end}}
{{define "content"}}
<nav>
  {{if eq .User.Role "admin"}}<a href="/admin/users">Users</a>{{end}}
  <!-- orders / inventory / schedule links land in B/C stages -->
</nav>
<p>Signed in as <strong>{{.User.DisplayName}}</strong> — role <strong>{{.User.Role}}</strong>{{if .User.CanEditRatecard}}, rate-card editor{{end}}.</p>
{{end}}
package server

import (
	"embed"
	"html/template"
	"log/slog"
	"net/http"

	"twit.tv/twitads/internal/auth"
)

//go:embed templates/*.html
var templateFS embed.FS

// page data passed to every template render. TODO(D3): csrf token field.
type pageData struct {
	User  auth.User
	Error string
	Data  any
}

func render(w http.ResponseWriter, name string, data pageData) {
	t, err := template.ParseFS(templateFS, "templates/layout.html", "templates/"+name)
	if err != nil {
		slog.Error("template parse", "name", name, "err", err)
		http.Error(w, "template error", http.StatusInternalServerError)
		return
	}
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	if err := t.ExecuteTemplate(w, "layout", data); err != nil {
		slog.Error("template exec", "name", name, "err", err)
	}
}
package server

import (
	"context"
	"errors"
	"net/http"
	"net/http/httptest"
	"net/url"
	"strings"
	"testing"

	"twit.tv/twitads/internal/auth"
)

type fakeUserStore struct{ u auth.User; hash string }

func (f fakeUserStore) GetByUsername(ctx context.Context, username string) (auth.User, string, error) {
	if username != f.u.Username {
		return auth.User{}, "", errors.New("not found")
	}
	return f.u, f.hash, nil
}

type fakeSessionStore struct {
	fakeSessions
	created map[int64]string
}

func (f *fakeSessionStore) Create(ctx context.Context, userID int64) (string, error) {
	f.created[userID] = "newtok"
	return "newtok", nil
}
func (f *fakeSessionStore) Delete(ctx context.Context, token string) error { return nil }

type fakeAudit struct{ actions []string }

func (f *fakeAudit) Record(ctx context.Context, actor *auth.User, entity, entityID, action string, detail any) error {
	f.actions = append(f.actions, action)
	return nil
}

func testServer(t *testing.T) (http.Handler, *fakeAudit) {
	t.Helper()
	hash, err := auth.HashPassword("goodpw")
	if err != nil {
		t.Fatal(err)
	}
	aud := &fakeAudit{}
	h := New(Deps{
		Env:      "test",
		DB:       fakePinger{},
		Users:    fakeUserStore{u: auth.User{ID: 7, Username: "lisa", DisplayName: "Lisa", Role: auth.RoleSales, Active: true}, hash: hash},
		Sessions: &fakeSessionStore{fakeSessions: fakeSessions{users: map[string]auth.User{}}, created: map[int64]string{}},
		Audit:    aud,
	})
	return h, aud
}

func postForm(h http.Handler, path string, vals url.Values) *httptest.ResponseRecorder {
	req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(vals.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, req)
	return rec
}

func TestLoginSuccessSetsCookieAndRedirects(t *testing.T) {
	h, aud := testServer(t)
	rec := postForm(h, "/login", url.Values{"username": {"lisa"}, "password": {"goodpw"}})
	if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/" {
		t.Fatalf("got %d → %q", rec.Code, rec.Header().Get("Location"))
	}
	found := false
	for _, c := range rec.Result().Cookies() {
		if c.Name == SessionCookie && c.Value == "newtok" && c.HttpOnly {
			found = true
		}
	}
	if !found {
		t.Error("session cookie not set correctly")
	}
	if len(aud.actions) == 0 || aud.actions[len(aud.actions)-1] != "login" {
		t.Errorf("audit actions = %v, want login recorded", aud.actions)
	}
}

func TestLoginBadPasswordRerenders(t *testing.T) {
	h, aud := testServer(t)
	rec := postForm(h, "/login", url.Values{"username": {"lisa"}, "password": {"wrong"}})
	if rec.Code != http.StatusUnauthorized {
		t.Fatalf("got %d, want 401", rec.Code)
	}
	if !strings.Contains(rec.Body.String(), "Invalid username or password") {
		t.Error("expected generic error message in body")
	}
	if len(aud.actions) == 0 || aud.actions[len(aud.actions)-1] != "login_failed" {
		t.Errorf("audit actions = %v, want login_failed", aud.actions)
	}
}

func TestHomeRequiresLogin(t *testing.T) {
	h, _ := testServer(t)
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
	if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/login" {
		t.Errorf("got %d → %q, want redirect to /login", rec.Code, rec.Header().Get("Location"))
	}
}

server.go becomes:

// Package server assembles the HTTP handler tree.
package server

import (
	"context"
	"encoding/json"
	"net/http"
	"time"

	"twit.tv/twitads/internal/auth"
)

type Pinger interface {
	Ping(ctx context.Context) error
}

type SessionStore interface {
	SessionReader
	Create(ctx context.Context, userID int64) (string, error)
	Delete(ctx context.Context, token string) error
}

type UserStore interface {
	GetByUsername(ctx context.Context, username string) (auth.User, string, error)
}

type Auditor interface {
	Record(ctx context.Context, actor *auth.User, entity, entityID, action string, detail any) error
}

type Deps struct {
	Env      string
	DB       Pinger
	Sessions SessionStore
	Users    UserStore
	Audit    Auditor
}

func New(d Deps) http.Handler {
	mw := &authMiddleware{sessions: d.Sessions}
	mux := http.NewServeMux()

	mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
		ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
		defer cancel()
		w.Header().Set("Content-Type", "application/json")
		if err := d.DB.Ping(ctx); err != nil {
			w.WriteHeader(http.StatusServiceUnavailable)
			json.NewEncoder(w).Encode(map[string]string{"status": "degraded", "db": "unreachable"})
			return
		}
		json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
	})

	mux.HandleFunc("GET /login", d.loginPage)
	mux.HandleFunc("POST /login", d.loginSubmit)
	mux.Handle("POST /logout", mw.requireUser(http.HandlerFunc(d.logout)))
	mux.Handle("GET /{$}", mw.requireUser(http.HandlerFunc(home)))

	registerAdmin(mux, mw, d) // Task 8; provide an empty stub now: func registerAdmin(mux *http.ServeMux, mw *authMiddleware, d Deps) {}

	return mux
}

func home(w http.ResponseWriter, r *http.Request) {
	u, _ := CurrentUser(r.Context())
	render(w, "home.html", pageData{User: u})
}

login.go:

package server

import (
	"net/http"

	"twit.tv/twitads/internal/auth"
)

func (d Deps) loginPage(w http.ResponseWriter, r *http.Request) {
	render(w, "login.html", pageData{})
}

func (d Deps) loginSubmit(w http.ResponseWriter, r *http.Request) {
	username := r.FormValue("username")
	password := r.FormValue("password")
	u, hash, err := d.Users.GetByUsername(r.Context(), username)
	if err != nil || !u.Active || !auth.VerifyPassword(hash, password) {
		_ = d.Audit.Record(r.Context(), nil, "auth", username, "login_failed", nil)
		w.WriteHeader(http.StatusUnauthorized)
		render(w, "login.html", pageData{Error: "Invalid username or password."})
		return
	}
	token, err := d.Sessions.Create(r.Context(), u.ID)
	if err != nil {
		http.Error(w, "session error", http.StatusInternalServerError)
		return
	}
	http.SetCookie(w, &http.Cookie{
		Name: SessionCookie, Value: token, Path: "/",
		HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: d.Env == "prod",
		MaxAge: 30 * 24 * 60 * 60,
	})
	_ = d.Audit.Record(r.Context(), &u, "auth", username, "login", nil)
	http.Redirect(w, r, "/", http.StatusSeeOther)
}

func (d Deps) logout(w http.ResponseWriter, r *http.Request) {
	u, _ := CurrentUser(r.Context())
	if c, err := r.Cookie(SessionCookie); err == nil {
		_ = d.Sessions.Delete(r.Context(), c.Value)
	}
	http.SetCookie(w, &http.Cookie{Name: SessionCookie, Value: "", Path: "/", MaxAge: -1})
	_ = d.Audit.Record(r.Context(), &u, "auth", u.Username, "logout", nil)
	http.Redirect(w, r, "/login", http.StatusSeeOther)
}

Also add the Task-8 stub at the bottom of server.go so it compiles:

func registerAdmin(mux *http.ServeMux, mw *authMiddleware, d Deps) {} // filled in Task 8

NOTE: A1's server_test.go healthz tests call New(fakePinger{}) — update them to New(Deps{Env: "test", DB: fakePinger{}, Sessions: &fakeSessionStore{fakeSessions: fakeSessions{users: map[string]auth.User{}}, created: map[int64]string{}}, Users: fakeUserStore{}, Audit: &fakeAudit{}}).

Run: go test ./internal/server/ -v → all PASS (middleware + login + healthz).

cd ~/Projects/twit-ads
git add app/internal/server/
git commit -m "feat(server): login/logout, session cookie, templates, home page"

Task 8: Admin users page

Files:

Interfaces:

type UserStore interface {
    GetByUsername(ctx context.Context, username string) (auth.User, string, error)
    GetByID(ctx context.Context, id int64) (auth.User, error)
    List(ctx context.Context) ([]auth.User, error)
    Create(ctx context.Context, username, displayName, password string, role auth.Role, ratecard bool) (auth.User, error)
    Update(ctx context.Context, id int64, displayName string, role auth.Role, ratecard, active bool) error
    ResetPassword(ctx context.Context, id int64, newPassword string) error
}

(*auth.Users already satisfies this.) Routes (all behind requireAdmin): GET /admin/users, POST /admin/users (create), POST /admin/users/{id} (update incl. deactivate), POST /admin/users/{id}/password (reset). Every mutation → Audit.Record(actor, "user", id, action, detail).

{{define "title"}}Users — TWiT Ads{{end}}
{{define "content"}}
<p><a href="/">← Home</a></p>
<h2>Users</h2>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<table>
<tr><th>Username</th><th>Display name</th><th>Role</th><th>Rate card</th><th>Active</th><th></th></tr>
{{range .Data}}
<tr>
  <td>{{.Username}}</td>
  <td colspan="4">
    <form class="inline" method="post" action="/admin/users/{{.ID}}">
      <input name="display_name" value="{{.DisplayName}}">
      <select name="role">
        <option value="admin" {{if eq .Role "admin"}}selected{{end}}>admin</option>
        <option value="sales" {{if eq .Role "sales"}}selected{{end}}>sales</option>
        <option value="continuity" {{if eq .Role "continuity"}}selected{{end}}>continuity</option>
      </select>
      <label style="display:inline"><input type="checkbox" name="ratecard" {{if .CanEditRatecard}}checked{{end}}> rate card</label>
      <label style="display:inline"><input type="checkbox" name="active" {{if .Active}}checked{{end}}> active</label>
      <button>Save</button>
    </form>
  </td>
  <td>
    <form class="inline" method="post" action="/admin/users/{{.ID}}/password">
      <input name="password" type="password" placeholder="new password" autocomplete="new-password">
      <button>Reset</button>
    </form>
  </td>
</tr>
{{end}}
</table>
<h3>Add user</h3>
<form method="post" action="/admin/users">
  <label>Username <input name="username" required></label>
  <label>Display name <input name="display_name" required></label>
  <label>Role
    <select name="role">
      <option>sales</option><option>continuity</option><option>admin</option>
    </select>
  </label>
  <label style="display:inline"><input type="checkbox" name="ratecard"> rate-card editor</label>
  <label>Password <input name="password" type="password" required autocomplete="new-password"></label>
  <p><button>Create</button></p>
</form>
{{end}}

Extend fakeUserStore (in admin_users_test.go, renaming as needed to avoid clashes — a fakeUsersFull implementing the widened interface backed by a map[int64]auth.User):

package server

import (
	"context"
	"errors"
	"net/http"
	"net/http/httptest"
	"net/url"
	"strings"
	"testing"

	"twit.tv/twitads/internal/auth"
)

type fakeUsersFull struct {
	byID   map[int64]auth.User
	hashes map[int64]string
	nextID int64
}

func newFakeUsersFull() *fakeUsersFull {
	return &fakeUsersFull{byID: map[int64]auth.User{}, hashes: map[int64]string{}, nextID: 1}
}

func (f *fakeUsersFull) GetByUsername(ctx context.Context, username string) (auth.User, string, error) {
	for id, u := range f.byID {
		if u.Username == username {
			return u, f.hashes[id], nil
		}
	}
	return auth.User{}, "", errors.New("not found")
}
func (f *fakeUsersFull) GetByID(ctx context.Context, id int64) (auth.User, error) {
	u, ok := f.byID[id]
	if !ok {
		return auth.User{}, errors.New("not found")
	}
	return u, nil
}
func (f *fakeUsersFull) List(ctx context.Context) ([]auth.User, error) {
	var out []auth.User
	for _, u := range f.byID {
		out = append(out, u)
	}
	return out, nil
}
func (f *fakeUsersFull) Create(ctx context.Context, username, displayName, password string, role auth.Role, ratecard bool) (auth.User, error) {
	u := auth.User{ID: f.nextID, Username: username, DisplayName: displayName, Role: role, CanEditRatecard: ratecard, Active: true}
	f.byID[f.nextID] = u
	f.hashes[f.nextID], _ = auth.HashPassword(password)
	f.nextID++
	return u, nil
}
func (f *fakeUsersFull) Update(ctx context.Context, id int64, displayName string, role auth.Role, ratecard, active bool) error {
	u, ok := f.byID[id]
	if !ok {
		return errors.New("not found")
	}
	u.DisplayName, u.Role, u.CanEditRatecard, u.Active = displayName, role, ratecard, active
	f.byID[id] = u
	return nil
}
func (f *fakeUsersFull) ResetPassword(ctx context.Context, id int64, newPassword string) error {
	if _, ok := f.byID[id]; !ok {
		return errors.New("not found")
	}
	f.hashes[id], _ = auth.HashPassword(newPassword)
	return nil
}

func adminServer(t *testing.T) (http.Handler, *fakeUsersFull, *fakeAudit) {
	t.Helper()
	users := newFakeUsersFull()
	users.byID[99] = auth.User{ID: 99, Username: "leo", Role: auth.RoleAdmin, DisplayName: "Leo", Active: true}
	aud := &fakeAudit{}
	h := New(Deps{
		Env: "test",
		DB:  fakePinger{},
		Sessions: &fakeSessionStore{
			fakeSessions: fakeSessions{users: map[string]auth.User{
				"admintok": users.byID[99],
				"salestok": {ID: 5, Username: "ty", Role: auth.RoleSales, Active: true},
			}},
			created: map[int64]string{},
		},
		Users: users,
		Audit: aud,
	})
	return h, users, aud
}

func adminPost(h http.Handler, path, token string, vals url.Values) *httptest.ResponseRecorder {
	req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(vals.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	req.AddCookie(&http.Cookie{Name: SessionCookie, Value: token})
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, req)
	return rec
}

func TestUsersPageForbiddenForSales(t *testing.T) {
	h, _, _ := adminServer(t)
	req := httptest.NewRequest(http.MethodGet, "/admin/users", nil)
	req.AddCookie(&http.Cookie{Name: SessionCookie, Value: "salestok"})
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, req)
	if rec.Code != http.StatusForbidden {
		t.Errorf("got %d, want 403", rec.Code)
	}
}

func TestCreateUserAuditsAndRedirects(t *testing.T) {
	h, users, aud := adminServer(t)
	rec := adminPost(h, "/admin/users", "admintok", url.Values{
		"username": {"veva"}, "display_name": {"Veva"}, "role": {"continuity"}, "password": {"pw12345"},
	})
	if rec.Code != http.StatusSeeOther {
		t.Fatalf("got %d, want 303", rec.Code)
	}
	if _, _, err := users.GetByUsername(context.Background(), "veva"); err != nil {
		t.Error("user not created")
	}
	if len(aud.actions) == 0 || aud.actions[len(aud.actions)-1] != "create" {
		t.Errorf("audit = %v", aud.actions)
	}
}

func TestUpdateUserDeactivates(t *testing.T) {
	h, users, aud := adminServer(t)
	_, _ = users.Create(context.Background(), "sebastian", "Sebastian", "pw", auth.RoleContinuity, false)
	rec := adminPost(h, "/admin/users/1", "admintok", url.Values{
		"display_name": {"Sebastian"}, "role": {"continuity"},
	}) // no "active" checkbox → deactivate
	if rec.Code != http.StatusSeeOther {
		t.Fatalf("got %d, want 303", rec.Code)
	}
	u, _ := users.GetByID(context.Background(), 1)
	if u.Active {
		t.Error("user should be deactivated")
	}
	if aud.actions[len(aud.actions)-1] != "update" {
		t.Errorf("audit = %v", aud.actions)
	}
}

func TestResetPasswordAudits(t *testing.T) {
	h, users, aud := adminServer(t)
	_, _ = users.Create(context.Background(), "debi", "Debi", "old", auth.RoleContinuity, true)
	rec := adminPost(h, "/admin/users/1/password", "admintok", url.Values{"password": {"newpw123"}})
	if rec.Code != http.StatusSeeOther {
		t.Fatalf("got %d, want 303", rec.Code)
	}
	_, hash, _ := users.GetByUsername(context.Background(), "debi")
	if !auth.VerifyPassword(hash, "newpw123") {
		t.Error("password not reset")
	}
	if aud.actions[len(aud.actions)-1] != "password_reset" {
		t.Errorf("audit = %v", aud.actions)
	}
}
package server

import (
	"fmt"
	"net/http"
	"strconv"

	"twit.tv/twitads/internal/auth"
)

func registerAdmin(mux *http.ServeMux, mw *authMiddleware, d Deps) {
	mux.Handle("GET /admin/users", mw.requireAdmin(http.HandlerFunc(d.usersPage)))
	mux.Handle("POST /admin/users", mw.requireAdmin(http.HandlerFunc(d.usersCreate)))
	mux.Handle("POST /admin/users/{id}", mw.requireAdmin(http.HandlerFunc(d.usersUpdate)))
	mux.Handle("POST /admin/users/{id}/password", mw.requireAdmin(http.HandlerFunc(d.usersResetPassword)))
}

func (d Deps) usersPage(w http.ResponseWriter, r *http.Request) {
	u, _ := CurrentUser(r.Context())
	list, err := d.Users.List(r.Context())
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	render(w, "users.html", pageData{User: u, Data: list, Error: r.URL.Query().Get("err")})
}

func (d Deps) usersCreate(w http.ResponseWriter, r *http.Request) {
	actor, _ := CurrentUser(r.Context())
	created, err := d.Users.Create(r.Context(),
		r.FormValue("username"), r.FormValue("display_name"), r.FormValue("password"),
		auth.Role(r.FormValue("role")), r.FormValue("ratecard") != "")
	if err != nil {
		http.Redirect(w, r, "/admin/users?err="+urlQueryEscape(err), http.StatusSeeOther)
		return
	}
	_ = d.Audit.Record(r.Context(), &actor, "user", fmt.Sprint(created.ID), "create",
		map[string]any{"username": created.Username, "role": created.Role})
	http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
}

func (d Deps) usersUpdate(w http.ResponseWriter, r *http.Request) {
	actor, _ := CurrentUser(r.Context())
	id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
	if err != nil {
		http.Error(w, "bad id", http.StatusBadRequest)
		return
	}
	role := auth.Role(r.FormValue("role"))
	ratecard := r.FormValue("ratecard") != ""
	active := r.FormValue("active") != ""
	if err := d.Users.Update(r.Context(), id, r.FormValue("display_name"), role, ratecard, active); err != nil {
		http.Redirect(w, r, "/admin/users?err="+urlQueryEscape(err), http.StatusSeeOther)
		return
	}
	_ = d.Audit.Record(r.Context(), &actor, "user", r.PathValue("id"), "update",
		map[string]any{"role": role, "ratecard": ratecard, "active": active})
	http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
}

func (d Deps) usersResetPassword(w http.ResponseWriter, r *http.Request) {
	actor, _ := CurrentUser(r.Context())
	id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
	if err != nil {
		http.Error(w, "bad id", http.StatusBadRequest)
		return
	}
	if err := d.Users.ResetPassword(r.Context(), id, r.FormValue("password")); err != nil {
		http.Redirect(w, r, "/admin/users?err="+urlQueryEscape(err), http.StatusSeeOther)
		return
	}
	_ = d.Audit.Record(r.Context(), &actor, "user", r.PathValue("id"), "password_reset", nil)
	http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
}

// urlQueryEscape wraps url.QueryEscape for error redirects.
func urlQueryEscape(err error) string { return url.QueryEscape(err.Error()) }

(Include "net/url" in the imports.) Update server.go: widen UserStore per the Interfaces block and delete the old stub registerAdmin.

Run: go test ./internal/server/ -v → all PASS. Then go vet ./....

cd ~/Projects/twit-ads
git add app/internal/server/
git commit -m "feat(admin): user management page with role gate and audit trail"

Task 9: Wire main, rebuild, end-to-end, close out

Files:

Replace srv := &http.Server{Addr: cfg.Listen, Handler: server.New(pool)} with:

	handler := server.New(server.Deps{
		Env:      cfg.Env,
		DB:       pool,
		Sessions: &auth.Sessions{Pool: pool},
		Users:    &auth.Users{Pool: pool},
		Audit:    &audit.Log{Pool: pool},
	})
	srv := &http.Server{Addr: cfg.Listen, Handler: handler}
cd ~/Projects/twit-ads/app
go vet ./... && just test && just test-db
docker compose up -d --build --wait
curl -s http://127.0.0.1:8730/healthz                                   # {"status":"ok"}
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8730/          # 303 (redirect to /login)
curl -s http://127.0.0.1:8730/login | grep -o "<title>[^<]*"            # <title>Log in — TWiT Ads
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8730/admin/users  # 303 (anonymous → login)
cd ~/Projects/twit-ads/app
just adduser leo admin "Leo Laporte"     # Leo supplies the password interactively — if running unattended, STOP and ask Leo to run this
# then verify a real login round-trip:
curl -s -c /tmp/tw.jar -d "username=leo" --data-urlencode "password=$TWITADS_PASSWORD" -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8730/login  # 303
curl -s -b /tmp/tw.jar http://127.0.0.1:8730/ | grep -o "Signed in as <strong>[^<]*"
rm /tmp/tw.jar

(If the password isn't available in env for the curl check, skip the curl login and leave browser verification to Leo.)

cd ~/Projects/twit-ads
git add Plans/ app/cmd/
git commit -m "chore(a2): stage complete, status board updated"

Decisions & deviations (executed 2026-07-06 by Kenobi/Fable 5)