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)
- Execute ONLY stage A2. Spec:
~/Obsidian/lgl/AI/TWiT-Ads/Specification.md§2 (Users & roles). STOP and ask Leo on ambiguity. - TDD red/green; one change at a time; frequent commits; bash for all commands.
- Migrations forward-only — this stage adds
00002_*.sql; NEVER edit00001_init.sql. - 14-day dependency rule: check
go list -m -json golang.org/x/crypto@latestpublish date; pin back if <14 days old. Beware:go mod tidycan silently re-upgrade a pinned indirect — re-check after every tidy (this bit us in A1). - go directive is
go 1.25.7; builder imagegolang:1.26-alpine. Don't change either. - No secrets in source; passwords only via env/stdin, never argv or files.
- Roles are exactly:
admin,sales,continuity+ booleancan_edit_ratecard(spec: a flag on top of role, granted to Lisa and Debi). Arbitrary number of users must be supportable (Leo 2026-07-06). /healthzstays public. Everything else added in this stage requires login.- CSRF posture for now: session cookie is
SameSite=Lax+ all mutations are POST. Full CSRF tokens are a D3-hardening item — leave a// TODO(D3): csrfcomment at the form helper, nothing more.
Task 1: Migration 00002 + password hashing
Files:
- Create:
app/internal/db/migrations/00002_users_sessions_audit.sql - Create:
app/internal/auth/password.go - Test:
app/internal/auth/password_test.go
Interfaces:
Produces:
auth.HashPassword(pw string) (string, error),auth.VerifyPassword(hash, pw string) bool; tablesusers,sessions,audit_log; enumuser_role.Step 1: Write the migration
-- +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;
- Step 2: Write the failing password tests
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")
}
}
- Step 3: Run to verify red
Run: cd ~/Projects/twit-ads/app && go test ./internal/auth/
Expected: FAIL — undefined: HashPassword.
- Step 4: Fetch x/crypto (14-day check) and implement
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
}
- Step 5: Green, then commit
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:
- Create:
app/internal/auth/users.go,app/internal/auth/testhelper_test.go - Test:
app/internal/auth/users_test.go - Modify:
app/justfile(test-db runs all packages)
Interfaces:
- Consumes:
db.Migrate,db.Connectfrom A1. - Produces:
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.
- Step 1: Update
just test-dbto cover every package
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
- Step 2: Write the shared integration helper
testhelper_test.go
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
}
- Step 3: Write the failing users tests
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)
}
}
- Step 4: Verify red
Run: go vet ./internal/auth/ → undefined: Users (et al.).
- Step 5: Implement
users.go
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
}
- Step 6: Green via
just test-db, then commit
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:
- Create:
app/internal/auth/sessions.go - Test:
app/internal/auth/sessions_test.go
Interfaces:
- Produces:
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")
- Step 1: Write the failing tests
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)
}
}
Step 2: Verify red —
go vet ./internal/auth/→undefined: Sessions.Step 3: Implement
sessions.go
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
}
- Step 4: Green via
just test-db, commit
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:
- Create:
app/internal/audit/audit.go - Test:
app/internal/audit/audit_test.go
Interfaces:
- Consumes:
auth.User. - Produces:
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.
- Step 1: Write the failing test
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)
}
}
Step 2: Verify red —
go vet ./internal/audit/→undefined: Log.Step 3: Implement
audit.go
// 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
}
- Step 4: Green via
just test-db, commit
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:
- Modify:
app/cmd/twitads/main.go - Modify:
app/justfile
Interfaces:
Produces:
twitads -config config.yaml -adduser <username> -display "<name>" -role <admin|sales|continuity> [-ratecard]— password read fromTWITADS_PASSWORDenv; runs migrations, inserts, exits 0. Alsojust adduser <username> <role> "<display>"which prompts silently for the password.Step 1: Add flags and the adduser path to
main.go
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".
- Step 2: Add the justfile recipe
# 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}}"
- Step 3: Verify by hand
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).
- Step 4: Commit
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:
- Create:
app/internal/server/middleware.go - Test:
app/internal/server/middleware_test.go
Interfaces:
- Consumes:
auth.User,auth.ErrNoSession. - Produces:
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.
- Step 1: Write the failing tests
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)
}
}
Step 2: Verify red —
go vet ./internal/server/→undefined: authMiddleware.Step 3: Implement
middleware.go
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)
}))
}
- Step 4: Green (
go test ./internal/server/), commit
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:
- Create:
app/internal/server/templates/layout.html,templates/login.html,templates/home.html,app/internal/server/templates.go,app/internal/server/login.go - Modify:
app/internal/server/server.go(Deps struct, routes) - Test:
app/internal/server/login_test.go
Interfaces:
- Produces (replaces A1's
New(Pinger)— updatemain.goin Task 9):
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).
- Step 1: Write
templates/layout.html
{{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}}
- Step 2: Write
templates/login.htmlandtemplates/home.html
{{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}}
- Step 3: Write
templates.go
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)
}
}
- Step 4: Write the failing login tests
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"))
}
}
Step 5: Verify red —
go test ./internal/server/→undefined: Deps(and New signature mismatch).Step 6: Implement — rewrite
server.goand addlogin.go
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{}}).
- Step 7: Green, commit
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:
- Create:
app/internal/server/admin_users.go,app/internal/server/templates/users.html - Modify:
app/internal/server/server.go(replace theregisterAdminstub), widenUserStore - Test:
app/internal/server/admin_users_test.go
Interfaces:
- Widen
UserStoreto:
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).
- Step 1: Write
templates/users.html
{{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}}
- Step 2: Write the failing tests
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)
}
}
Step 3: Verify red — compile fails on widened
UserStorefakes vs stub. Rungo test ./internal/server/and confirm.Step 4: Implement
admin_users.goand replace the stub
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.
- Step 5: Green, commit
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:
Modify:
app/cmd/twitads/main.go(New(Deps{...}))Modify:
Plans/README.md, this file (close-out)Step 1: Update
main.goserver construction
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}
- Step 2: Full test + rebuild + live check
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)
- Step 3: Create the real admin user and smoke-test login via curl
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.)
Step 4: Close out
Update
Plans/README.mdA2 row →executed — awaiting Leo verification.Append
## Decisions & deviationsto this file (every unplanned choice, or "None").
cd ~/Projects/twit-ads
git add Plans/ app/cmd/
git commit -m "chore(a2): stage complete, status board updated"
- Step 5: STOP. Do not begin A3. Leo verifies (from any tailnet browser): log in at
http://100.107.83.98:8730/login, see the home page with role; visit/admin/users, createlisa(sales, rate-card),debi(continuity, rate-card),ty(sales),sebastian+veva(continuity); log in as one non-admin and confirm/admin/usersis forbidden;just psql→SELECT actor_name, entity, action FROM audit_log ORDER BY at;shows every mutation.
Decisions & deviations (executed 2026-07-06 by Kenobi/Fable 5)
- Latent A1 bug found and fixed: Leo's global gitignore has
*.sql, which had silently excluded migration00001_init.sqlfrom every A1 commit — a fresh clone would not compile (go:embedmatches nothing). Added a negation rule to the repo .gitignore and committed both migrations. - x/crypto v0.53.0 (published 2026-06-08, 28 days — passes the 14-day rule); it bumped x/text to v0.38.0 (same date, fine). goose pin re-verified at v3.27.1 after tidy.
main.goDeps wiring (plan Task 9 step 1) had to land with Task 8's commit —go vet ./...can't pass with the oldserver.New(pool)signature once Deps exists.fakeUserStorein login tests gained not-implemented stubs to satisfy the widened UserStore.- Full E2E run against the live compose stack with throwaway users (admin + sales): login 303 + cookie, home shows role, admin 200 / sales 403 on /admin/users, bad password 401 +
login_failedaudit row (actorless), web user-create audited. Fixtures and audit rows wiped after — the dev DB is empty for Leo's real accounts. - All tests green (13 server unit tests; auth/audit/db integration via
just test-db).