feat(core): add the store, identity plumbing and the door (SPEC-003/004)

- store: SQLite (WAL, foreign keys ON, CGO-free driver) + the embedded
  migration runner; each module's migrations are name-prefixed (D-012)
- audit: the append-only audit log; the actor threads via context (R-1)
- users: the user_id/GUID/email anchors (D-013)
- config: the YAML settings file (D-035) + validation
- httpx: cookies · loopback detection · trusted-proxy XFF · CSRF · security
  headers · redaction
- middleware: correlation id · audit · rate limiting
- mail: the invite/recovery mailer seam
- server: the core DOOR — the resolution switch, the dispatch registry
  (RegisterModule), the role gates, /setup · enroll/login · recovery flows,
  the reserved `static` workflow, and the loopback-only bootstrap (D-025)
This commit is contained in:
Claus Lohmar 2026-10-14 10:53:51 +00:00
parent e114a2be61
commit d11e14295b
45 changed files with 8051 additions and 0 deletions

View file

@ -0,0 +1,151 @@
// Package audit is MyDay's append-only security trail (A11).
//
// Every auth-sensitive event — bootstrap, enroll, login, logout, recovery
// request/approve/deny, revoke, role change — is recorded here, together with
// the client IP and (IP-derived, D-031) location. The trail outlives the users
// it names, so it carries no foreign keys.
//
// Spec: docs/specs/spec-004-identity-sessions.md §5.
package audit
import (
"context"
"database/sql"
"log/slog"
"time"
)
// Event is one audit entry. Secrets are NEVER placed in Detail or Subject
// (A9) — those columns are for identifiers and outcomes only.
type Event struct {
// ActorUserID is the authenticated principal responsible for the event;
// 0 means anonymous/system (stored as NULL).
ActorUserID int64
// Action is the event kind, e.g. "user.create", "identity.rotate".
Action string
// Subject is what the event acted on, e.g. "user:2" or "identity:5".
Subject string
// Detail is non-secret context, e.g. "role=manager".
Detail string
// ClientIP is the resolved client address (A6); optional.
ClientIP string
// Location is the approximate, IP-derived location — AUDIT ONLY (D-031).
Location string
// CorrelationID ties the event to its request (SPEC-003).
CorrelationID string
}
// Audit records events. The contract is deliberately fire-and-forget — the
// audit trail must never break a request.
//
// Per ruling R-1 (SPEC-004 §5) the event is recorded WITH the request context:
// the door attaches Meta before any service call, and services write their
// audit rows through it, so actor · client IP · correlation are never lost.
type Audit interface {
Record(ctx context.Context, e Event)
}
// Meta is the per-request audit metadata the door attaches to the
// context.Context before calling any service (SPEC-004 §5, ruling R-1;
// SPEC-003 §4). It is IDENTIFICATION and CONTEXT only — never a credential.
type Meta struct {
// ActorUserID is the authenticated principal responsible for the request;
// 0 means anonymous/system.
ActorUserID int64
// ClientIP is the resolved client address (A6).
ClientIP string
// CorrelationID ties every event of one request together (SPEC-003 §4).
CorrelationID string
}
// metaKey is the unexported context key for Meta; using a struct type prevents
// collisions with other packages' keys.
type metaKey struct{}
// WithMeta attaches m to ctx. The door calls this once, before handing off to a
// module or a service.
func WithMeta(ctx context.Context, m Meta) context.Context {
return context.WithValue(ctx, metaKey{}, m)
}
// MetaFrom returns the Meta attached to ctx, if any.
func MetaFrom(ctx context.Context) (Meta, bool) {
m, ok := ctx.Value(metaKey{}).(Meta)
return m, ok
}
// Merge fills an event's empty context fields from the metadata on ctx, so a
// service never has to re-thread actor · client IP · correlation by hand.
//
// Explicit event values WIN — a service that knows the actor better than the
// ambient request context (e.g. the identity owner) may set it and it is kept
// (ruling R-3).
func Merge(ctx context.Context, e Event) Event {
m, ok := MetaFrom(ctx)
if !ok {
return e
}
if e.ActorUserID == 0 {
e.ActorUserID = m.ActorUserID
}
if e.ClientIP == "" {
e.ClientIP = m.ClientIP
}
if e.CorrelationID == "" {
e.CorrelationID = m.CorrelationID
}
return e
}
// Log writes events to the audit_log table.
type Log struct {
db *sql.DB
now func() time.Time
}
// New returns an Audit writing to db. time.Now is injectable for tests via the
// unexported field (same package).
func New(db *sql.DB) *Log {
return &Log{db: db, now: time.Now}
}
// Record inserts one audit row. A failure is logged, never returned: an
// un-writable audit trail must not take the application down (but it is loud).
//
// The event is merged with the Meta carried on ctx first (ruling R-1), so the
// actor · client IP · correlation attached by the door reach the row.
func (l *Log) Record(ctx context.Context, e Event) {
e = Merge(ctx, e)
var actor any
if e.ActorUserID != 0 {
actor = e.ActorUserID
}
_, err := l.db.Exec(
`INSERT INTO audit_log
(at, actor_user_id, action, subject, detail, client_ip, location, correlation_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
l.now().UTC().Truncate(time.Second).Format(time.RFC3339),
actor,
e.Action,
nullString(e.Subject),
nullString(e.Detail),
nullString(e.ClientIP),
nullString(e.Location),
nullString(e.CorrelationID),
)
if err != nil {
slog.Error("audit: record failed", "action", e.Action, "error", err)
}
}
// nullString maps "" to NULL so the trail distinguishes "absent" from "empty".
func nullString(s string) any {
if s == "" {
return nil
}
return s
}
var _ Audit = (*Log)(nil)

View file

@ -0,0 +1,155 @@
package audit_test
import (
"context"
"database/sql"
"path/filepath"
"testing"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store"
)
func newDB(t *testing.T) *sql.DB {
t.Helper()
db, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := db.Migrate(); err != nil {
t.Fatalf("Migrate: %v", err)
}
return db.DB
}
func TestRecordWritesRow(t *testing.T) {
db := newDB(t)
log := audit.New(db)
log.Record(context.Background(), audit.Event{
ActorUserID: 7,
Action: "login",
Subject: "identity:3",
Detail: "totp",
ClientIP: "203.0.113.9",
Location: "GB",
CorrelationID: "corr-1",
})
var (
actor sql.NullInt64
action string
subject string
detail string
ip string
loc string
corr string
)
err := db.QueryRow(
`SELECT actor_user_id, action, subject, detail, client_ip, location, correlation_id FROM audit_log`,
).Scan(&actor, &action, &subject, &detail, &ip, &loc, &corr)
if err != nil {
t.Fatalf("query audit_log: %v", err)
}
if !actor.Valid || actor.Int64 != 7 {
t.Errorf("actor_user_id = %+v, want 7", actor)
}
if action != "login" || subject != "identity:3" || detail != "totp" {
t.Errorf("row = (%s,%s,%s)", action, subject, detail)
}
if ip != "203.0.113.9" || loc != "GB" || corr != "corr-1" {
t.Errorf("context = (%s,%s,%s)", ip, loc, corr)
}
}
func TestRecordAnonymousActorIsNull(t *testing.T) {
db := newDB(t)
log := audit.New(db)
log.Record(context.Background(), audit.Event{Action: "bootstrap"})
var actor sql.NullInt64
if err := db.QueryRow(`SELECT actor_user_id FROM audit_log`).Scan(&actor); err != nil {
t.Fatalf("query: %v", err)
}
if actor.Valid {
t.Errorf("actor_user_id = %d, want NULL for an anonymous event", actor.Int64)
}
}
// TestMetaRoundTrip pins the R-1 context seam: WithMeta/MetaFrom are lossless.
func TestMetaRoundTrip(t *testing.T) {
ctx := context.Background()
if _, ok := audit.MetaFrom(ctx); ok {
t.Fatal("MetaFrom(background) reported a meta, want none")
}
want := audit.Meta{ActorUserID: 42, ClientIP: "203.0.113.5", CorrelationID: "corr-9"}
got, ok := audit.MetaFrom(audit.WithMeta(ctx, want))
if !ok {
t.Fatal("MetaFrom(withMeta) reported none")
}
if got != want {
t.Errorf("MetaFrom = %+v, want %+v", got, want)
}
}
// TestRecordMergesContextMeta proves the door's Meta reaches the audit row
// without the service threading it by hand (R-1); explicit event values win.
func TestRecordMergesContextMeta(t *testing.T) {
db := newDB(t)
log := audit.New(db)
ctx := audit.WithMeta(context.Background(), audit.Meta{
ActorUserID: 55,
ClientIP: "198.51.100.7",
CorrelationID: "corr-abc",
})
// The event carries no context fields — all come from the Meta.
log.Record(ctx, audit.Event{Action: "login"})
// This event overrides the actor explicitly — the event value must win.
log.Record(ctx, audit.Event{Action: "identity.revoke", ActorUserID: 9})
rows, err := db.Query(`SELECT action, actor_user_id, client_ip, correlation_id FROM audit_log ORDER BY id`)
if err != nil {
t.Fatalf("query audit_log: %v", err)
}
defer rows.Close()
type row struct {
action string
actor sql.NullInt64
ip sql.NullString
corr sql.NullString
}
var got []row
for rows.Next() {
var r row
if err := rows.Scan(&r.action, &r.actor, &r.ip, &r.corr); err != nil {
t.Fatalf("scan: %v", err)
}
got = append(got, r)
}
if err := rows.Err(); err != nil {
t.Fatalf("rows: %v", err)
}
if len(got) != 2 {
t.Fatalf("got %d rows, want 2", len(got))
}
if !got[0].actor.Valid || got[0].actor.Int64 != 55 {
t.Errorf("row 0 actor = %+v, want 55 (from Meta)", got[0].actor)
}
if got[0].ip.String != "198.51.100.7" || got[0].corr.String != "corr-abc" {
t.Errorf("row 0 context = (%q,%q), want the Meta values", got[0].ip.String, got[0].corr.String)
}
if !got[1].actor.Valid || got[1].actor.Int64 != 9 {
t.Errorf("row 1 actor = %+v, want 9 (explicit event value wins)", got[1].actor)
}
if got[1].corr.String != "corr-abc" {
t.Errorf("row 1 correlation = %q, want corr-abc", got[1].corr.String)
}
}

View file

@ -0,0 +1,307 @@
// Package config loads MyDay's configuration from `config.yaml` into a typed
// struct and validates it fail-closed (SPEC-003 §1, D-010/D-035).
//
// Settings live in the config file, not the database (D-035): infra · secrets ·
// domain · mail · locale. The real `config.yaml` is gitignored (D-010); the
// committed `config.example.yaml` is used as a development fallback so a fresh
// checkout can still start (in setup mode, loopback-only).
//
// Fail-closed rule (SPEC-003 §1): in PRODUCTIVE mode (`setup: false`) the
// process refuses to start if a required secret is missing or weak. Setup mode
// tolerates the placeholder values in the example, because the operator has not
// provisioned the instance yet — and setup mode is loopback-only (D-025).
//
// Spec: docs/specs/spec-003-core-door.md §1, §7.
package config
import (
"bytes"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"time"
"gopkg.in/yaml.v3"
)
// FileName is the productive configuration file (gitignored — D-010).
const FileName = "config.yaml"
// ExampleFileName is the committed template, used as a development fallback.
const ExampleFileName = "config.example.yaml"
// Secret-length policy. The TOTP key must be exactly totpKeyLen bytes because
// it feeds AES-256-GCM (mirrors auth.KeySize); the other secrets are signing /
// derivation keys and must be at least minSecretLen bytes.
const (
minSecretLen = 32
totpKeyLen = 32
)
// Config is the typed shape of `config.yaml` / `config.example.yaml`.
type Config struct {
// Setup forces setup mode (D-026). Together with "no user exists" it makes
// the instance serve only /setup, loopback-only (D-025, SPEC-005 §3).
Setup bool `yaml:"setup"`
// AdminRecovery is the config approval flag for admin recovery (D-033).
// Without it nothing happens (dual control).
AdminRecovery bool `yaml:"admin_recovery"`
Server Server `yaml:"server"`
App App `yaml:"app"`
Database Database `yaml:"database"`
Secrets Secrets `yaml:"secrets"`
Mail Mail `yaml:"mail"`
Modules map[string]bool `yaml:"modules"`
// Enrollment bounds the invite window (A1). SPEC-006 refuses enrollment
// outside it.
Enrollment Enrollment `yaml:"enrollment"`
// source is the path actually read (config.yaml or the example fallback).
source string
}
// Server holds the listener, its public base URL, and the trusted proxies.
type Server struct {
Listen string `yaml:"listen"`
// BaseURL is used in invite / recovery links and decides whether cookies
// are Secure (https).
BaseURL string `yaml:"base_url"`
// TrustedProxies are the peers whose X-Forwarded-For is honoured (A6).
TrustedProxies []string `yaml:"trusted_proxies"`
}
// App holds locale and timezone.
type App struct {
Locale string `yaml:"locale"`
Timezone string `yaml:"timezone"`
}
// Database points at the SQLite file (D-012).
type Database struct {
Path string `yaml:"path"`
}
// Secrets holds the symmetric key material (D-010). Never commit real values.
type Secrets struct {
SessionKey string `yaml:"session_key"`
CookieSecret string `yaml:"cookie_secret"`
CSRFKey string `yaml:"csrf_key"`
TOTPEncryptionKey string `yaml:"totp_encryption_key"` // A9
}
// Mail holds the SMTP settings used for invites and recovery.
type Mail struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
Username string `yaml:"username"`
Password string `yaml:"password"`
From string `yaml:"from"`
}
// Enrollment holds the invite-window settings (A1).
type Enrollment struct {
// Window is a Go duration string, e.g. "72h". Empty defaults to 72h.
Window string `yaml:"window"`
}
// Sentinel errors.
var (
// ErrListenRequired is returned when server.listen is empty.
ErrListenRequired = errors.New("config: server.listen is required")
// ErrDatabaseRequired is returned when database.path is empty.
ErrDatabaseRequired = errors.New("config: database.path is required")
// ErrBaseURLRequired is returned when server.base_url is empty in
// productive mode (invite/recovery links need it).
ErrBaseURLRequired = errors.New("config: server.base_url is required in productive mode")
// ErrWeakSecret is returned when a required secret is missing or weak.
ErrWeakSecret = errors.New("config: required secret is missing or weak")
// ErrInvalidEnrollWindow is returned when enrollment.window is not a valid
// positive Go duration.
ErrInvalidEnrollWindow = errors.New("config: enrollment.window is not a valid duration")
)
// DefaultEnrollWindow is the code default for the bounded enrollment window
// (A1) when enrollment.window is unset.
const DefaultEnrollWindow = 72 * time.Hour
// EnrollWindow returns the configured enrollment window, defaulting to 72h.
func (c *Config) EnrollWindow() time.Duration {
d, err := time.ParseDuration(strings.TrimSpace(c.Enrollment.Window))
if err != nil || d <= 0 {
return DefaultEnrollWindow
}
return d
}
// Load reads and validates the configuration at path.
//
// Development fallback (SPEC-003 §1): when path is the canonical `config.yaml`
// and it does not exist, the committed `config.example.yaml` in the same
// directory is used instead. Any other missing path is an error.
func Load(path string) (*Config, error) {
used, data, err := read(path)
if err != nil {
return nil, err
}
dec := yaml.NewDecoder(bytes.NewReader(data))
dec.KnownFields(true) // fail-closed: an unknown key is a configuration error
var c Config
if err := dec.Decode(&c); err != nil {
return nil, fmt.Errorf("config: parse %s: %w", used, err)
}
c.source = used
if err := c.Validate(); err != nil {
return nil, fmt.Errorf("config: %s: %w", used, err)
}
return &c, nil
}
// Source returns the path the configuration was actually loaded from.
func (c *Config) Source() string { return c.source }
// Marshal renders c as the YAML document written to `config.yaml` (D-035). The
// wizard persists through it (SPEC-005 §4). The unexported source field is not
// part of the document.
func Marshal(c *Config) ([]byte, error) {
if c == nil {
return nil, errors.New("config: cannot marshal a nil config")
}
body, err := yaml.Marshal(c)
if err != nil {
return nil, fmt.Errorf("config: marshal: %w", err)
}
const header = "# MyDay configuration — written by MyDay (the /setup wizard or the Modules page, D-035).\n" +
"# Settings live HERE, not in the database. This file holds SECRETS:\n" +
"# it is gitignored — never commit it (D-010).\n\n"
return append([]byte(header), body...), nil
}
// IsSecure reports whether the app is served over https (so cookies are Secure).
func (c *Config) IsSecure() bool {
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(c.Server.BaseURL)), "https://")
}
// GenerateSecret returns n cryptographically-random bytes, base64url-encoded
// without padding (R-6): the value carries no YAML-hostile characters and its
// encoded length is 4*ceil(n/3) — 24 bytes yield the 32 characters the TOTP
// sealer requires (auth.KeySize).
func GenerateSecret(n int) (string, error) {
if n <= 0 {
return "", errors.New("config: secret length must be positive")
}
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("config: secret entropy: %w", err)
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
// Validate applies the fail-closed rules (SPEC-003 §1).
func (c *Config) Validate() error {
if strings.TrimSpace(c.Server.Listen) == "" {
return ErrListenRequired
}
if strings.TrimSpace(c.Database.Path) == "" {
return ErrDatabaseRequired
}
// The enrollment window (A1) is parsed in both modes: a typo is a config
// error, never a silently-unbounded window.
if w := strings.TrimSpace(c.Enrollment.Window); w != "" {
d, err := time.ParseDuration(w)
if err != nil || d <= 0 {
return fmt.Errorf("%w: %q", ErrInvalidEnrollWindow, w)
}
}
// Setup mode may run with placeholder secrets: the operator has not yet
// provisioned the instance, and it is loopback-only (D-025). Productive
// mode is strict.
if c.Setup {
return nil
}
if strings.TrimSpace(c.Server.BaseURL) == "" {
return ErrBaseURLRequired
}
for _, s := range []struct{ name, value string }{
{"secrets.session_key", c.Secrets.SessionKey},
{"secrets.cookie_secret", c.Secrets.CookieSecret},
{"secrets.csrf_key", c.Secrets.CSRFKey},
{"secrets.totp_encryption_key", c.Secrets.TOTPEncryptionKey},
} {
if err := checkSecret(s.name, s.value, minSecretLen); err != nil {
return err
}
}
// The TOTP key feeds AES-256-GCM, so it must be exactly 32 bytes.
if n := len(c.Secrets.TOTPEncryptionKey); n != totpKeyLen {
return fmt.Errorf("%w: secrets.totp_encryption_key must be exactly %d bytes, got %d",
ErrWeakSecret, totpKeyLen, n)
}
return nil
}
// read loads the file, falling back to config.example.yaml for the canonical
// config.yaml only.
func read(path string) (used string, data []byte, err error) {
data, err = os.ReadFile(path)
if err == nil {
return path, data, nil
}
if !errors.Is(err, fs.ErrNotExist) {
return "", nil, fmt.Errorf("config: read %q: %w", path, err)
}
if filepath.Base(path) != FileName {
return "", nil, fmt.Errorf("config: read %q: %w", path, err)
}
alt := filepath.Join(filepath.Dir(path), ExampleFileName)
data, altErr := os.ReadFile(alt)
if altErr != nil {
return "", nil, fmt.Errorf("config: read %q (and fallback %q): %w", path, alt, err)
}
return alt, data, nil
}
// checkSecret rejects an empty, short, or placeholder secret value.
func checkSecret(name, value string, minLen int) error {
if strings.TrimSpace(value) == "" {
return fmt.Errorf("%w: %s is empty", ErrWeakSecret, name)
}
if len(value) < minLen {
return fmt.Errorf("%w: %s is %d bytes, need at least %d", ErrWeakSecret, name, len(value), minLen)
}
if isPlaceholder(value) {
return fmt.Errorf("%w: %s still holds a placeholder value", ErrWeakSecret, name)
}
return nil
}
// SecretIsPlaceholder reports whether a secret value is empty or still holds
// one of the shipped placeholder markers (R-6). The /setup wizard replaces such
// values with strong generated ones, so a first boot from config.example.yaml
// persists a viable productive configuration.
func SecretIsPlaceholder(value string) bool {
return strings.TrimSpace(value) == "" || isPlaceholder(value)
}
// isPlaceholder detects the well-known placeholder markers shipped in the
// example so a copy that was never edited fails closed in productive mode.
func isPlaceholder(value string) bool {
upper := strings.ToUpper(value)
return strings.Contains(upper, "CHANGE_ME") ||
strings.Contains(upper, "CHANGEME") ||
strings.Contains(upper, "PLACEHOLDER")
}

View file

@ -0,0 +1,313 @@
package config_test
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/config"
)
// writeConfig writes body to dir/name and returns the full path.
func writeConfig(t *testing.T, dir, name, body string) string {
t.Helper()
p := filepath.Join(dir, name)
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
t.Fatalf("write %s: %v", p, err)
}
return p
}
const setupYAML = `
setup: true
server:
listen: "127.0.0.1:8080"
base_url: "http://127.0.0.1:8080"
trusted_proxies:
- "127.0.0.1"
app:
locale: "en-GB"
timezone: "Europe/London"
database:
path: "./data/myday.db"
secrets:
session_key: "CHANGE_ME_32_BYTES_RANDOM"
cookie_secret: "CHANGE_ME_32_BYTES_RANDOM"
csrf_key: "CHANGE_ME_32_BYTES_RANDOM"
totp_encryption_key: "CHANGE_ME_32_BYTES_RANDOM"
mail:
host: "smtp.example.com"
port: 587
username: ""
password: "CHANGE_ME"
from: "myday@example.com"
modules:
requests: true
expense: false
`
const productiveYAML = `
setup: false
server:
listen: "0.0.0.0:8080"
base_url: "https://myday.example.com"
database:
path: "./data/myday.db"
secrets:
session_key: "0123456789abcdef0123456789abcdef"
cookie_secret: "0123456789abcdef0123456789abcdef"
csrf_key: "0123456789abcdef0123456789abcdef"
totp_encryption_key: "0123456789abcdef0123456789abcdef"
`
func TestLoadParsesTypedStruct(t *testing.T) {
dir := t.TempDir()
writeConfig(t, dir, config.FileName, setupYAML)
cfg, err := config.Load(filepath.Join(dir, config.FileName))
if err != nil {
t.Fatalf("Load: %v", err)
}
if !cfg.Setup {
t.Error("Setup = false, want true")
}
if cfg.Server.Listen != "127.0.0.1:8080" {
t.Errorf("Listen = %q", cfg.Server.Listen)
}
if cfg.Mail.Port != 587 {
t.Errorf("Mail.Port = %d, want 587", cfg.Mail.Port)
}
if cfg.App.Timezone != "Europe/London" {
t.Errorf("Timezone = %q", cfg.App.Timezone)
}
if !cfg.Modules["requests"] || cfg.Modules["expense"] {
t.Errorf("Modules = %v", cfg.Modules)
}
if cfg.IsSecure() {
t.Error("IsSecure = true for an http base_url")
}
}
// TestLoadFallsBackToExample covers the dev fallback: a fresh checkout has only
// the committed example.
func TestLoadFallsBackToExample(t *testing.T) {
dir := t.TempDir()
writeConfig(t, dir, config.ExampleFileName, setupYAML)
cfg, err := config.Load(filepath.Join(dir, config.FileName))
if err != nil {
t.Fatalf("Load (fallback): %v", err)
}
if filepath.Base(cfg.Source()) != config.ExampleFileName {
t.Errorf("Source = %q, want the example fallback", cfg.Source())
}
}
// TestLoadNonCanonicalMissingIsError: only config.yaml gets the fallback.
func TestLoadNonCanonicalMissingIsError(t *testing.T) {
dir := t.TempDir()
writeConfig(t, dir, config.ExampleFileName, setupYAML)
if _, err := config.Load(filepath.Join(dir, "other.yaml")); err == nil {
t.Error("Load(other.yaml, missing) = nil, want an error (no fallback)")
}
}
// TestSetupModeToleratesPlaceholders: setup mode may use the example as-is.
func TestSetupModeToleratesPlaceholders(t *testing.T) {
dir := t.TempDir()
writeConfig(t, dir, config.FileName, setupYAML)
if _, err := config.Load(filepath.Join(dir, config.FileName)); err != nil {
t.Fatalf("setup-mode load failed on placeholder secrets: %v", err)
}
}
// TestProductiveModeRequiresStrongSecrets is the fail-closed gate (SPEC-003 §1).
func TestProductiveModeRequiresStrongSecrets(t *testing.T) {
cases := []struct {
name string
body string
want error
}{
{
name: "placeholder secret",
body: strings.Replace(productiveYAML,
"0123456789abcdef0123456789abcdef", "CHANGE_ME_32_BYTES_RANDOM", 1),
want: config.ErrWeakSecret,
},
{
name: "short secret",
body: strings.Replace(productiveYAML,
"0123456789abcdef0123456789abcdef", "tooshort", 1),
want: config.ErrWeakSecret,
},
{
name: "totp key wrong length",
body: strings.Replace(productiveYAML,
`totp_encryption_key: "0123456789abcdef0123456789abcdef"`,
`totp_encryption_key: "0123456789abcdef0123456789abcdefff"`, 1),
want: config.ErrWeakSecret,
},
{
name: "empty listen",
body: strings.Replace(productiveYAML, `listen: "0.0.0.0:8080"`, `listen: ""`, 1),
want: config.ErrListenRequired,
},
{
name: "missing base_url",
body: strings.Replace(productiveYAML, `base_url: "https://myday.example.com"`, `base_url: ""`, 1),
want: config.ErrBaseURLRequired,
},
{
name: "empty database path",
body: strings.Replace(productiveYAML, `path: "./data/myday.db"`, `path: ""`, 1),
want: config.ErrDatabaseRequired,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
writeConfig(t, dir, config.FileName, tc.body)
_, err := config.Load(filepath.Join(dir, config.FileName))
if !errors.Is(err, tc.want) {
t.Errorf("Load = %v, want %v", err, tc.want)
}
})
}
}
// TestSecretIsPlaceholder covers the R-6 detector the wizard uses to decide
// which secrets to generate.
func TestSecretIsPlaceholder(t *testing.T) {
cases := []struct {
value string
want bool
}{
{"", true},
{" ", true},
{"CHANGE_ME_32_BYTES_RANDOM", true},
{"change_me", true},
{"my-PLACEHOLDER-value", true},
{"0123456789abcdef0123456789abcdef", false},
{"a-strong-generated-secret-value-here", false},
}
for _, tc := range cases {
if got := config.SecretIsPlaceholder(tc.value); got != tc.want {
t.Errorf("SecretIsPlaceholder(%q) = %v, want %v", tc.value, got, tc.want)
}
}
}
func TestProductiveModeStrongConfigLoads(t *testing.T) {
dir := t.TempDir()
writeConfig(t, dir, config.FileName, productiveYAML)
cfg, err := config.Load(filepath.Join(dir, config.FileName))
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Setup {
t.Error("Setup = true, want false")
}
if !cfg.IsSecure() {
t.Error("IsSecure = false for an https base_url")
}
}
// TestUnknownKeyFailsClosed: a typo in config.yaml must not be silently ignored.
func TestUnknownKeyFailsClosed(t *testing.T) {
dir := t.TempDir()
writeConfig(t, dir, config.FileName, setupYAML+"\nnot_a_real_key: true\n")
if _, err := config.Load(filepath.Join(dir, config.FileName)); err == nil {
t.Error("Load with an unknown key = nil, want an error (fail closed)")
}
}
// TestEnrollWindow covers the config-driven bounded window (A1).
func TestEnrollWindow(t *testing.T) {
// Unset -> the 72h default.
if got := (&config.Config{}).EnrollWindow(); got != config.DefaultEnrollWindow {
t.Errorf("default EnrollWindow = %v, want %v", got, config.DefaultEnrollWindow)
}
// Configured.
c := &config.Config{Enrollment: config.Enrollment{Window: "12h"}}
if got := c.EnrollWindow(); got != 12*time.Hour {
t.Errorf("EnrollWindow(12h) = %v, want 12h", got)
}
// A malformed window is a configuration error in any mode.
bad := &config.Config{
Server: config.Server{Listen: "127.0.0.1:8080"},
Database: config.Database{Path: "./data/myday.db"},
Enrollment: config.Enrollment{Window: "not-a-duration"},
}
if err := bad.Validate(); !errors.Is(err, config.ErrInvalidEnrollWindow) {
t.Errorf("Validate(bad window) = %v, want ErrInvalidEnrollWindow", err)
}
}
// TestLoadParsesEnrollmentWindow reads the window from YAML.
func TestLoadParsesEnrollmentWindow(t *testing.T) {
dir := t.TempDir()
writeConfig(t, dir, config.FileName, setupYAML+"\nenrollment:\n window: \"48h\"\n")
cfg, err := config.Load(filepath.Join(dir, config.FileName))
if err != nil {
t.Fatalf("Load: %v", err)
}
if got := cfg.EnrollWindow(); got != 48*time.Hour {
t.Errorf("EnrollWindow = %v, want 48h", got)
}
}
// TestMarshalRoundTrip: the wizard's persister produces a document that loads
// back through the SAME strict loader (SPEC-005 §4, D-035). A written config
// must be exactly as valid as a hand-written one.
func TestMarshalRoundTrip(t *testing.T) {
const good = "0123456789abcdef0123456789abcdef"
src := &config.Config{
Setup: false,
Server: config.Server{
Listen: "127.0.0.1:8080",
BaseURL: "http://127.0.0.1:8080",
TrustedProxies: []string{"127.0.0.1"},
},
App: config.App{Locale: "en-GB", Timezone: "Europe/London"},
Database: config.Database{Path: "./data/myday.db"},
Secrets: config.Secrets{
SessionKey: good, CookieSecret: good, CSRFKey: good, TOTPEncryptionKey: good,
},
Mail: config.Mail{Host: "smtp.example.com", Port: 587, From: "myday@example.com"},
Modules: map[string]bool{"time": true},
Enrollment: config.Enrollment{Window: "48h"},
}
data, err := config.Marshal(src)
if err != nil {
t.Fatalf("Marshal: %v", err)
}
dir := t.TempDir()
path := writeConfig(t, dir, config.FileName, string(data))
got, err := config.Load(path)
if err != nil {
t.Fatalf("the marshalled config does not load back: %v", err)
}
if got.Server.BaseURL != src.Server.BaseURL || got.App.Locale != src.App.Locale ||
got.App.Timezone != src.App.Timezone || got.Mail.Port != src.Mail.Port ||
got.Secrets.TOTPEncryptionKey != src.Secrets.TOTPEncryptionKey {
t.Errorf("round-trip lost fields: %+v", got)
}
if got.EnrollWindow() != 48*time.Hour {
t.Errorf("round-trip EnrollWindow = %v, want 48h", got.EnrollWindow())
}
if got.Setup {
t.Error("round-trip Setup = true, want false")
}
}

View file

@ -0,0 +1,121 @@
package httpx
import (
"fmt"
"net"
"net/http"
"strings"
)
// TrustedProxies is the parsed set of networks whose X-Forwarded-For is
// honoured (A6). An empty set means "nothing is trusted": the peer address is
// the client.
type TrustedProxies []*net.IPNet
// ParseTrustedProxies parses the `server.trusted_proxies` entries. A bare IP
// ("127.0.0.1") becomes a single-host network (/32 or /128); a CIDR
// ("10.0.0.0/8") is used verbatim. Empty entries are skipped.
func ParseTrustedProxies(entries []string) (TrustedProxies, error) {
var tp TrustedProxies
for _, e := range entries {
e = strings.TrimSpace(e)
if e == "" {
continue
}
if strings.Contains(e, "/") {
_, n, err := net.ParseCIDR(e)
if err != nil {
return nil, fmt.Errorf("httpx: trusted proxy %q is not a valid CIDR: %w", e, err)
}
tp = append(tp, n)
continue
}
ip := net.ParseIP(e)
if ip == nil {
return nil, fmt.Errorf("httpx: trusted proxy %q is not an IP or CIDR", e)
}
bits := 32
if ip.To4() == nil {
bits = 128
}
tp = append(tp, &net.IPNet{IP: ip, Mask: net.CIDRMask(bits, bits)})
}
return tp, nil
}
// Contains reports whether ip is one of the trusted proxies.
func (t TrustedProxies) Contains(ip net.IP) bool {
if ip == nil {
return false
}
for _, n := range t {
if n.Contains(ip) {
return true
}
}
return false
}
// ClientIP resolves the client address for r using the trusted proxies, and
// SETS the request's X-Forwarded-For to that single value — it never appends to
// a client-supplied chain (A6, SPEC-003 §4).
//
// The rules:
//
// - the immediate peer (RemoteAddr) is NOT trusted → any X-Forwarded-For is
// ignored and the peer is the client (a client cannot spoof past the gate);
// - the peer IS trusted → walk X-Forwarded-For left to right and return the
// first address that is not itself a trusted proxy (the real client); if
// every hop is trusted, the last hop is returned.
//
// The resolved value is written back with Header.Set (replace), never Add.
func ClientIP(r *http.Request, trusted TrustedProxies) string {
peer := remoteIP(r.RemoteAddr)
ip := peer
if trusted.Contains(peer) {
ip = resolveForwarded(r.Header.Get("X-Forwarded-For"), trusted, peer)
}
if ip == nil {
return ""
}
s := ip.String()
r.Header.Set("X-Forwarded-For", s) // SET — never append (A6)
return s
}
// resolveForwarded returns the leftmost non-trusted address in the header,
// falling back to the last hop, then to the peer.
func resolveForwarded(header string, trusted TrustedProxies, peer net.IP) net.IP {
if strings.TrimSpace(header) == "" {
return peer
}
var last net.IP
for _, part := range strings.Split(header, ",") {
ip := net.ParseIP(strings.TrimSpace(part))
if ip == nil {
continue
}
last = ip
if !trusted.Contains(ip) {
return ip // the real client
}
}
if last != nil {
return last
}
return peer
}
// remoteIP extracts the IP from a "host:port" RemoteAddr.
func remoteIP(addr string) net.IP {
if addr == "" {
return nil
}
host, _, err := net.SplitHostPort(addr)
if err != nil {
host = addr
}
return net.ParseIP(strings.Trim(strings.TrimSpace(host), "[]"))
}

View file

@ -0,0 +1,40 @@
// Package httpx holds the door's HTTP helpers: the session cookie, the client
// IP resolution (trusted proxies, A6), the security headers, log redaction, and
// the CSRF token (SPEC-003 §6/§8).
//
// It imports only net/http + the standard library — the door owns HTTP; the
// pure routing model (internal/core/routing) must never import this.
package httpx
import "net/http"
// SessionCookieName is the name of the session cookie (SPEC-003 §6). The value
// is an opaque random id; the server-side session row is authoritative.
const SessionCookieName = "myday_session"
// SetSessionCookie writes the hardened session cookie (SPEC-003 §6):
// HttpOnly · Secure · SameSite=Lax · Path=/. The lifetime is server-side, so
// no Max-Age is set (a browser-session cookie).
func SetSessionCookie(w http.ResponseWriter, name, value string, secure bool) {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: value,
Path: "/",
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
})
}
// ClearSessionCookie expires the session cookie (logout, or an invalid session).
func ClearSessionCookie(w http.ResponseWriter, name string, secure bool) {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: "",
Path: "/",
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
})
}

View file

@ -0,0 +1,73 @@
package httpx
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"errors"
"net/http"
)
// ErrNoCSRFKey is returned when the CSRF key is empty — fail closed.
var ErrNoCSRFKey = errors.New("httpx: CSRF key is required")
// CSRF derives and verifies the per-session CSRF token on every action request
// (SPEC-003 §6, A8). The token is HMAC-SHA256(session_id) under the config
// `csrf_key`, so it is bound to the session, needs no extra table, and dies
// with the session.
//
// The action routing fields (`do`/`action`) are NOT a security control on their
// own (D-017); this token is. The session cookie is HttpOnly, so a page embeds
// the token in a hidden form field via Token / the request context.
type CSRF struct {
key []byte
}
// NewCSRF builds a CSRF helper from the configured key. An empty key fails
// closed.
func NewCSRF(key []byte) (*CSRF, error) {
if len(key) == 0 {
return nil, ErrNoCSRFKey
}
k := make([]byte, len(key))
copy(k, key)
return &CSRF{key: k}, nil
}
// Token returns the CSRF token bound to sessionID.
func (c *CSRF) Token(sessionID string) string {
mac := hmac.New(sha256.New, c.key)
mac.Write([]byte("myday.csrf.v1:"))
mac.Write([]byte(sessionID))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
// Valid reports whether token is the correct CSRF token for sessionID. It
// rejects empty input and compares in constant time.
func (c *CSRF) Valid(sessionID, token string) bool {
if sessionID == "" || token == "" {
return false
}
want := c.Token(sessionID)
return hmac.Equal([]byte(want), []byte(token))
}
// tokenCtxKey is the unexported context key for the request's CSRF token.
type tokenCtxKey struct{}
// WithToken exposes the request's CSRF token on the context so a handler can
// embed it in a form without re-deriving it.
func WithToken(ctx context.Context, token string) context.Context {
return context.WithValue(ctx, tokenCtxKey{}, token)
}
// TokenFrom returns the CSRF token attached by the door, if any.
func TokenFrom(ctx context.Context) string {
t, _ := ctx.Value(tokenCtxKey{}).(string)
return t
}
// TokenFromRequest is a convenience for handlers: the token the door attached
// to the request, ready for a hidden form field.
func TokenFromRequest(r *http.Request) string { return TokenFrom(r.Context()) }

View file

@ -0,0 +1,28 @@
package httpx
import "net/http"
// ContentSecurityPolicy is the strict default CSP (SPEC-003 §4). It allows only
// same-origin assets; the PWA manifest and service worker are same-origin too.
const ContentSecurityPolicy = "default-src 'self'; " +
"img-src 'self' data:; " +
"style-src 'self'; " +
"script-src 'self'; " +
"connect-src 'self'; " +
"object-src 'none'; " +
"base-uri 'none'; " +
"form-action 'self'; " +
"frame-ancestors 'none'"
// SetSecurityHeaders writes the baseline security headers on every response
// (SPEC-003 §4). HSTS is set only when the app is served over https (secure).
func SetSecurityHeaders(w http.ResponseWriter, secure bool) {
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "no-referrer")
h.Set("X-Frame-Options", "DENY")
h.Set("Content-Security-Policy", ContentSecurityPolicy)
if secure {
h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
}
}

View file

@ -0,0 +1,217 @@
package httpx_test
import (
"context"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/httpx"
)
func TestSetAndClearSessionCookie(t *testing.T) {
rec := httptest.NewRecorder()
httpx.SetSessionCookie(rec, httpx.SessionCookieName, "abc123", true)
cookies := rec.Result().Cookies()
if len(cookies) != 1 {
t.Fatalf("got %d cookies, want 1", len(cookies))
}
c := cookies[0]
if c.Name != httpx.SessionCookieName || c.Value != "abc123" {
t.Errorf("cookie = %s=%s", c.Name, c.Value)
}
if !c.HttpOnly {
t.Error("cookie is not HttpOnly")
}
if !c.Secure {
t.Error("cookie is not Secure")
}
if c.SameSite != http.SameSiteLaxMode {
t.Errorf("SameSite = %v, want Lax", c.SameSite)
}
if c.Path != "/" {
t.Errorf("Path = %q, want /", c.Path)
}
// Clearing expires it.
rec2 := httptest.NewRecorder()
httpx.ClearSessionCookie(rec2, httpx.SessionCookieName, true)
if got := rec2.Result().Cookies()[0].MaxAge; got >= 0 {
t.Errorf("cleared cookie MaxAge = %d, want negative", got)
}
}
func TestParseTrustedProxies(t *testing.T) {
tp, err := httpx.ParseTrustedProxies([]string{"127.0.0.1", "10.0.0.0/8", ""})
if err != nil {
t.Fatalf("ParseTrustedProxies: %v", err)
}
if !tp.Contains(parseIP(t, "127.0.0.1")) {
t.Error("127.0.0.1 not trusted")
}
if !tp.Contains(parseIP(t, "10.1.2.3")) {
t.Error("10.1.2.3 not covered by 10.0.0.0/8")
}
if tp.Contains(parseIP(t, "8.8.8.8")) {
t.Error("8.8.8.8 reported trusted")
}
if _, err := httpx.ParseTrustedProxies([]string{"not-an-ip"}); err == nil {
t.Error("ParseTrustedProxies(bad) = nil, want error")
}
}
func TestClientIPUntrustedPeerIgnoresXFF(t *testing.T) {
tp, _ := httpx.ParseTrustedProxies([]string{"127.0.0.1"})
r := httptest.NewRequest("GET", "/", nil)
r.RemoteAddr = "203.0.113.9:5555"
r.Header.Set("X-Forwarded-For", "1.2.3.4, 5.6.7.8")
got := httpx.ClientIP(r, tp)
if got != "203.0.113.9" {
t.Errorf("ClientIP = %q, want the peer (XFF from an untrusted peer must be ignored)", got)
}
// SET, never append.
if xff := r.Header.Get("X-Forwarded-For"); xff != "203.0.113.9" {
t.Errorf("X-Forwarded-For = %q, want it SET to %q", xff, "203.0.113.9")
}
}
func TestClientIPTrustedPeerResolvesLeftmostUntrusted(t *testing.T) {
tp, _ := httpx.ParseTrustedProxies([]string{"127.0.0.1", "10.0.0.0/8"})
r := httptest.NewRequest("GET", "/", nil)
r.RemoteAddr = "127.0.0.1:5555"
r.Header.Set("X-Forwarded-For", "198.51.100.7, 10.0.0.5")
got := httpx.ClientIP(r, tp)
if got != "198.51.100.7" {
t.Errorf("ClientIP = %q, want the leftmost non-trusted 198.51.100.7", got)
}
if xff := r.Header.Get("X-Forwarded-For"); xff != "198.51.100.7" {
t.Errorf("X-Forwarded-For = %q, want it SET (replaced), not appended", xff)
}
}
func TestClientIPAllHopsTrusted(t *testing.T) {
tp, _ := httpx.ParseTrustedProxies([]string{"127.0.0.1", "10.0.0.0/8"})
r := httptest.NewRequest("GET", "/", nil)
r.RemoteAddr = "127.0.0.1:5555"
r.Header.Set("X-Forwarded-For", "10.0.0.1, 10.0.0.2")
if got := httpx.ClientIP(r, tp); got != "10.0.0.2" {
t.Errorf("ClientIP = %q, want the last hop 10.0.0.2", got)
}
}
func TestRedactPath(t *testing.T) {
cases := []struct {
in string
mustGone string
}{
{"/setup/token/SUPERSECRETTOKEN", "SUPERSECRETTOKEN"},
{"/access/reset/SUPERSECRETTOKEN", "SUPERSECRETTOKEN"},
{"/x/token=SUPERSECRETTOKEN", "SUPERSECRETTOKEN"},
}
for _, tc := range cases {
got := httpx.RedactPath(tc.in)
if strings.Contains(got, tc.mustGone) {
t.Errorf("RedactPath(%q) = %q, still contains the credential", tc.in, got)
}
if !strings.Contains(got, httpx.Redacted) {
t.Errorf("RedactPath(%q) = %q, want a %s marker", tc.in, got, httpx.Redacted)
}
}
// The GUID is public and must survive verbatim (D-001).
const guid = "AbCdEf0123456789"
if got := httpx.RedactPath("/" + guid + "/home"); got != "/"+guid+"/home" {
t.Errorf("RedactPath preserved a GUID: got %q", got)
}
}
func TestRedactQuery(t *testing.T) {
got := httpx.RedactQuery("token=SECRET&view=open")
if strings.Contains(got, "SECRET") {
t.Errorf("RedactQuery = %q, still contains the token", got)
}
if !strings.Contains(got, "view=open") {
t.Errorf("RedactQuery = %q, want the non-sensitive param kept", got)
}
}
func TestSetSecurityHeaders(t *testing.T) {
rec := httptest.NewRecorder()
httpx.SetSecurityHeaders(rec, false)
h := rec.Header()
if h.Get("X-Content-Type-Options") != "nosniff" {
t.Error("missing X-Content-Type-Options: nosniff")
}
if h.Get("Referrer-Policy") != "no-referrer" {
t.Error("missing Referrer-Policy")
}
if !strings.Contains(h.Get("Content-Security-Policy"), "default-src 'self'") {
t.Errorf("CSP = %q", h.Get("Content-Security-Policy"))
}
if h.Get("Strict-Transport-Security") != "" {
t.Error("HSTS set for an insecure request")
}
rec2 := httptest.NewRecorder()
httpx.SetSecurityHeaders(rec2, true)
if rec2.Header().Get("Strict-Transport-Security") == "" {
t.Error("HSTS missing for a secure request")
}
}
func TestCSRF(t *testing.T) {
csrf, err := httpx.NewCSRF([]byte("a-test-csrf-key-0123456789abcdef"))
if err != nil {
t.Fatalf("NewCSRF: %v", err)
}
tok := csrf.Token("session-1")
if tok == "" {
t.Fatal("Token returned empty")
}
if !csrf.Valid("session-1", tok) {
t.Error("Valid rejected the correct token")
}
if csrf.Valid("session-2", tok) {
t.Error("Valid accepted a token bound to another session")
}
if csrf.Valid("session-1", "") {
t.Error("Valid accepted an empty token")
}
if csrf.Valid("", tok) {
t.Error("Valid accepted an empty session id")
}
if csrf.Valid("session-1", tok+"x") {
t.Error("Valid accepted a tampered token")
}
if _, err := httpx.NewCSRF(nil); err == nil {
t.Error("NewCSRF(nil) = nil, want an error (fail closed)")
}
}
func TestTokenContext(t *testing.T) {
ctx := httpx.WithToken(context.Background(), "tok-9")
if got := httpx.TokenFrom(ctx); got != "tok-9" {
t.Errorf("TokenFrom = %q, want tok-9", got)
}
if got := httpx.TokenFrom(context.Background()); got != "" {
t.Errorf("TokenFrom(background) = %q, want empty", got)
}
}
func parseIP(t *testing.T, s string) (ip net.IP) {
t.Helper()
ip = net.ParseIP(s)
if ip == nil {
t.Fatalf("ParseIP(%q) = nil", s)
}
return ip
}

View file

@ -0,0 +1,30 @@
package httpx
import (
"net"
"strings"
)
// IsLoopbackHost reports whether a request Host ("host" or "host:port") names a
// loopback origin.
//
// It gates the passkey nudge (SPEC-006 §8): a passkey registered at
// `127.0.0.1` binds the Relying Party ID `127.0.0.1` and is UNUSABLE at the
// public domain (O-13/D-041), so the nudge is suppressed on loopback. An
// empty/unknown host is treated as loopback — fail closed, so an origin we
// cannot classify never triggers the nudge.
func IsLoopbackHost(hostport string) bool {
host := strings.TrimSpace(hostport)
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
host = strings.Trim(strings.TrimSpace(host), "[]")
if host == "" {
return true
}
if strings.EqualFold(host, "localhost") {
return true
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}

View file

@ -0,0 +1,140 @@
package httpx
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"net/http"
"strings"
)
// PreSessionCookieName is the cookie carrying the anonymous double-submit CSRF
// token (A15). It is issued on the enroll/login screens and verified on the
// anonymous actions — before any session exists (SPEC-006 §5).
const PreSessionCookieName = "myday_csrf"
// ErrNoSigningKey is returned when the signing secret is empty — fail closed.
var ErrNoSigningKey = errors.New("httpx: signing key is required")
// Signer signs and verifies opaque cookie values with HMAC-SHA256. It is the
// primitive behind the pre-session CSRF token and the WebAuthn ceremony state:
// both are tamper-evident without a server-side table. The key comes from
// `secrets.cookie_secret`.
type Signer struct{ key []byte }
// NewSigner builds a Signer from the configured key. An empty key fails closed.
func NewSigner(key []byte) (*Signer, error) {
if len(key) == 0 {
return nil, ErrNoSigningKey
}
k := make([]byte, len(key))
copy(k, key)
return &Signer{key: k}, nil
}
// Sign returns `base64url(value).base64url(HMAC)`. Encoding the value keeps the
// "." delimiter unambiguous even when value contains one.
func (s *Signer) Sign(value string) string {
payload := base64.RawURLEncoding.EncodeToString([]byte(value))
return payload + "." + s.mac(payload)
}
// Unsign verifies a token and returns the original value. A tampered or
// malformed token reports ok=false.
func (s *Signer) Unsign(token string) (value string, ok bool) {
payload, sig, found := strings.Cut(token, ".")
if !found || payload == "" || sig == "" {
return "", false
}
if !hmac.Equal([]byte(sig), []byte(s.mac(payload))) {
return "", false
}
raw, err := base64.RawURLEncoding.DecodeString(payload)
if err != nil {
return "", false
}
return string(raw), true
}
// mac is the HMAC over the domain-separated payload.
func (s *Signer) mac(payload string) string {
m := hmac.New(sha256.New, s.key)
m.Write([]byte("myday.sign.v1:"))
m.Write([]byte(payload))
return base64.RawURLEncoding.EncodeToString(m.Sum(nil))
}
// PreSessionCSRF is the signed double-submit token for the anonymous
// enroll/login forms (A15). SPEC-003 §6's session-bound CSRF does not apply
// before a session exists, so this fills the gap: the server issues a signed
// random token in an HttpOnly cookie AND embeds the same token in the form; on
// submit both must match and the signature must verify.
type PreSessionCSRF struct {
signer *Signer
secure bool
}
// NewPreSessionCSRF builds the helper from `secrets.cookie_secret`.
func NewPreSessionCSRF(secret []byte, secure bool) (*PreSessionCSRF, error) {
s, err := NewSigner(secret)
if err != nil {
return nil, err
}
return &PreSessionCSRF{signer: s, secure: secure}, nil
}
// Signer exposes the underlying signer so the flows can sign their own
// short-lived ceremony state with the same key.
func (p *PreSessionCSRF) Signer() *Signer { return p.signer }
// Token mints a fresh signed token.
func (p *PreSessionCSRF) Token() (string, error) {
var b [32]byte
if _, err := rand.Read(b[:]); err != nil {
return "", fmt.Errorf("httpx: csrf entropy: %w", err)
}
return p.signer.Sign(base64.RawURLEncoding.EncodeToString(b[:])), nil
}
// Ensure returns a valid token for the request, reusing the cookie when it is
// present and valid, else issuing a fresh one.
func (p *PreSessionCSRF) Ensure(w http.ResponseWriter, r *http.Request) (string, error) {
if c, err := r.Cookie(PreSessionCookieName); err == nil && c.Value != "" {
if v, ok := p.signer.Unsign(c.Value); ok && v != "" {
return c.Value, nil
}
}
tok, err := p.Token()
if err != nil {
return "", err
}
http.SetCookie(w, &http.Cookie{
Name: PreSessionCookieName,
Value: tok,
Path: "/",
HttpOnly: true,
Secure: p.secure,
SameSite: http.SameSiteLaxMode,
})
return tok, nil
}
// Valid verifies the double-submit: the submitted token must equal the cookie's
// value AND carry a valid signature. The comparison is constant-time.
func (p *PreSessionCSRF) Valid(r *http.Request, submitted string) bool {
if submitted == "" {
return false
}
c, err := r.Cookie(PreSessionCookieName)
if err != nil || c.Value == "" {
return false
}
if !hmac.Equal([]byte(c.Value), []byte(submitted)) {
return false
}
v, ok := p.signer.Unsign(submitted)
return ok && v != ""
}

View file

@ -0,0 +1,87 @@
package httpx_test
import (
"net/http/httptest"
"testing"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/httpx"
)
func TestSignerRoundTripAndTamper(t *testing.T) {
s, err := httpx.NewSigner([]byte("a-signing-key-0123456789abcdef"))
if err != nil {
t.Fatalf("NewSigner: %v", err)
}
if _, err := httpx.NewSigner(nil); err == nil {
t.Error("NewSigner(nil) = nil, want an error (fail closed)")
}
const value = "hello.world/with/slashes"
tok := s.Sign(value)
got, ok := s.Unsign(tok)
if !ok || got != value {
t.Fatalf("Unsign(Sign(%q)) = %q,%v", value, got, ok)
}
if _, ok := s.Unsign(tok + "x"); ok {
t.Error("Unsign accepted a tampered token")
}
if _, ok := s.Unsign("not-a-token"); ok {
t.Error("Unsign accepted a malformed token")
}
// A different key must not validate the token.
other, _ := httpx.NewSigner([]byte("another-key-0123456789abcdefgh"))
if _, ok := other.Unsign(tok); ok {
t.Error("a token validated under a different key")
}
}
func TestPreSessionCSRFEnsureAndValid(t *testing.T) {
p, err := httpx.NewPreSessionCSRF([]byte("a-cookie-secret-0123456789abcdef"), false)
if err != nil {
t.Fatalf("NewPreSessionCSRF: %v", err)
}
rec := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/someguid", nil)
tok, err := p.Ensure(rec, r)
if err != nil {
t.Fatalf("Ensure: %v", err)
}
cookies := rec.Result().Cookies()
if len(cookies) != 1 || cookies[0].Name != httpx.PreSessionCookieName {
t.Fatalf("Ensure cookies = %v, want one %s", cookies, httpx.PreSessionCookieName)
}
if !cookies[0].HttpOnly {
t.Error("the pre-session CSRF cookie is not HttpOnly")
}
// Double-submit: cookie value must equal the submitted token.
r2 := httptest.NewRequest("POST", "/someguid?do=auth&action=enroll.begin", nil)
r2.AddCookie(cookies[0])
if !p.Valid(r2, tok) {
t.Error("Valid rejected a matching double-submit token")
}
if p.Valid(r2, tok+"x") {
t.Error("Valid accepted a mismatched token")
}
if p.Valid(httptest.NewRequest("POST", "/", nil), tok) {
t.Error("Valid accepted a token with no cookie")
}
if p.Valid(r2, "") {
t.Error("Valid accepted an empty token")
}
// Ensure reuses a valid cookie rather than rotating it every call.
rec2 := httptest.NewRecorder()
tok2, err := p.Ensure(rec2, r2)
if err != nil {
t.Fatalf("Ensure(reuse): %v", err)
}
if tok2 != tok {
t.Error("Ensure rotated an already-valid token")
}
if len(rec2.Result().Cookies()) != 0 {
t.Error("Ensure re-issued a cookie for a valid token")
}
}

View file

@ -0,0 +1,76 @@
package httpx
import (
"net/url"
"strings"
)
// Redacted is the placeholder that replaces a credential in logs.
const Redacted = "<redacted>"
// sensitiveNames are the segment / parameter names whose VALUE is treated as a
// credential (SPEC-003 §8, A8/A11). The GUID is a public username (D-001) and
// is deliberately NOT in this set — it is never redacted.
var sensitiveNames = map[string]bool{
"token": true,
"secret": true,
"key": true,
"code": true,
"password": true,
"passwd": true,
"invite": true,
"reset": true,
"session": true,
"csrf": true,
}
// RedactPath masks credential-bearing path segments so the access log can
// record the raw path shape without leaking a credential (SPEC-003 §8).
//
// A segment that follows a sensitive name (e.g. /setup/token/<cred>)
// A `name=value` segment whose name is sensitive (e.g. /x/token=abc)
//
// The GUID is public and is preserved verbatim. Segment count is preserved so
// the logged path stays structurally comparable.
func RedactPath(p string) string {
if p == "" {
return p
}
segs := strings.Split(p, "/")
redactNext := false
for i, seg := range segs {
if redactNext {
segs[i] = Redacted
redactNext = false
continue
}
if name, _, ok := strings.Cut(seg, "="); ok {
if sensitiveNames[strings.ToLower(name)] {
segs[i] = name + "=" + Redacted
continue
}
}
if sensitiveNames[strings.ToLower(seg)] {
redactNext = true
}
}
return strings.Join(segs, "/")
}
// RedactQuery masks the values of sensitive query parameters. An unparseable
// query is redacted wholesale rather than risking a leak.
func RedactQuery(raw string) string {
if raw == "" {
return ""
}
vals, err := url.ParseQuery(raw)
if err != nil {
return Redacted
}
for name := range vals {
if sensitiveNames[strings.ToLower(name)] {
vals.Set(name, Redacted)
}
}
return vals.Encode()
}

146
internal/core/mail/mail.go Normal file
View file

@ -0,0 +1,146 @@
// Package mail is MyDay's outbound-mail seam (SPEC-007 §4/§5): recovery
// notifications and the enrolment link emailed after an identity rotation.
//
// Email is NEVER an authentication factor (D-005/D-009) — it only *initiates*
// recovery; a human in the chain authorises it. The seam is therefore tiny: an
// interface, an in-memory Fake for tests, and minimal SMTP wiring for
// production.
//
// Spec: docs/specs/spec-007-roles-chain-recovery.md.
package mail
import (
"context"
"errors"
"fmt"
"log/slog"
"net/smtp"
"strings"
"sync"
)
// Message is one outbound email.
type Message struct {
To string
Subject string
Body string
}
// Mailer sends an email. Delivery failures are the caller's to log — the
// contract is deliberately fire-and-forget, like the audit trail.
type Mailer interface {
Send(ctx context.Context, m Message) error
}
// Fake records every message in memory. It is the test seam.
type Fake struct {
mu sync.Mutex
sent []Message
}
// NewFake returns an empty recording mailer.
func NewFake() *Fake { return &Fake{} }
// Send implements Mailer.
func (f *Fake) Send(_ context.Context, m Message) error {
f.mu.Lock()
defer f.mu.Unlock()
f.sent = append(f.sent, m)
return nil
}
// Sent returns a copy of every recorded message.
func (f *Fake) Sent() []Message {
f.mu.Lock()
defer f.mu.Unlock()
out := make([]Message, len(f.sent))
copy(out, f.sent)
return out
}
// To returns every recorded message addressed to addr (case-insensitive).
func (f *Fake) To(addr string) []Message {
f.mu.Lock()
defer f.mu.Unlock()
var out []Message
for _, m := range f.sent {
if strings.EqualFold(strings.TrimSpace(m.To), strings.TrimSpace(addr)) {
out = append(out, m)
}
}
return out
}
// Nop discards messages. It is the fallback when SMTP is not configured.
type Nop struct{}
// Send implements Mailer.
func (Nop) Send(context.Context, Message) error { return nil }
// SMTP sends through a real SMTP server. It is kept deliberately minimal: the
// /setup wizard collects the settings; delivery is best-effort and never
// blocks a request's outcome.
type SMTP struct {
host string
port int
username string
password string
from string
logger *slog.Logger
}
// NewSMTP builds an SMTP mailer.
func NewSMTP(host string, port int, username, password, from string) *SMTP {
return &SMTP{
host: strings.TrimSpace(host),
port: port,
username: username,
password: password,
from: strings.TrimSpace(from),
logger: slog.Default(),
}
}
// Send implements Mailer via net/smtp (STARTTLS/plain auth when a username is
// set). An unconfigured host is a hard error — the caller decides whether that
// matters.
func (s *SMTP) Send(_ context.Context, m Message) error {
if s.host == "" {
return errors.New("mail: SMTP host is not configured")
}
if strings.TrimSpace(m.To) == "" {
return errors.New("mail: recipient is required")
}
addr := fmt.Sprintf("%s:%d", s.host, s.port)
var auth smtp.Auth
if s.username != "" {
auth = smtp.PlainAuth("", s.username, s.password, s.host)
}
if err := smtp.SendMail(addr, auth, s.from, []string{m.To}, encode(s.from, m)); err != nil {
return fmt.Errorf("mail: send to %s: %w", m.To, err)
}
return nil
}
// encode renders the RFC 5322 message. The body is plain text; every header
// value is stripped of CR/LF first, so a crafted address or subject cannot
// inject extra headers (header-injection guard).
func encode(from string, m Message) []byte {
sanitize := strings.NewReplacer("\r", " ", "\n", " ").Replace
var b strings.Builder
fmt.Fprintf(&b, "From: %s\r\n", sanitize(from))
fmt.Fprintf(&b, "To: %s\r\n", sanitize(m.To))
fmt.Fprintf(&b, "Subject: %s\r\n", sanitize(m.Subject))
b.WriteString("MIME-Version: 1.0\r\n")
b.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
b.WriteString("\r\n")
b.WriteString(m.Body)
b.WriteString("\r\n")
return []byte(b.String())
}
var (
_ Mailer = (*Fake)(nil)
_ Mailer = Nop{}
_ Mailer = (*SMTP)(nil)
)

View file

@ -0,0 +1,48 @@
package mail_test
import (
"context"
"testing"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/mail"
)
// TestFakeRecordsAndFilters: the test seam records every message and can filter
// by recipient (case-insensitive).
func TestFakeRecordsAndFilters(t *testing.T) {
f := mail.NewFake()
ctx := context.Background()
if err := f.Send(ctx, mail.Message{To: "a@example.com", Subject: "one", Body: "hi"}); err != nil {
t.Fatalf("Send: %v", err)
}
if err := f.Send(ctx, mail.Message{To: "B@Example.com", Subject: "two"}); err != nil {
t.Fatalf("Send: %v", err)
}
if got := len(f.Sent()); got != 2 {
t.Fatalf("Sent len = %d, want 2", got)
}
toB := f.To("b@example.com")
if len(toB) != 1 || toB[0].Subject != "two" {
t.Errorf("To(b@example.com) = %+v, want the second message", toB)
}
if len(f.To("nobody@example.com")) != 0 {
t.Error("To returned a message for an unknown recipient")
}
}
// TestNopSend: the unconfigured fallback discards silently.
func TestNopSend(t *testing.T) {
if err := (mail.Nop{}).Send(context.Background(), mail.Message{To: "x@example.com"}); err != nil {
t.Errorf("Nop.Send = %v, want nil", err)
}
}
// TestSMTPRequiresHost: the SMTP mailer fails closed without a host.
func TestSMTPRequiresHost(t *testing.T) {
m := mail.NewSMTP("", 25, "", "", "myday@example.com")
if err := m.Send(context.Background(), mail.Message{To: "x@example.com"}); err == nil {
t.Error("SMTP.Send with no host = nil, want an error")
}
}

View file

@ -0,0 +1,18 @@
package middleware
import (
"context"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit"
)
// AttachAudit attaches the request's audit metadata to ctx (SPEC-003 §4, ruling
// R-1). The door calls it before dispatching to a module; every service then
// writes its audit rows with the right actor · client IP · correlation.
func AttachAudit(ctx context.Context, actorUserID int64, clientIP, correlationID string) context.Context {
return audit.WithMeta(ctx, audit.Meta{
ActorUserID: actorUserID,
ClientIP: clientIP,
CorrelationID: correlationID,
})
}

View file

@ -0,0 +1,57 @@
// Package middleware holds the door's cross-cutting HTTP concerns — the
// correlation id, the rate limiter, and the audit-metadata attachment
// (SPEC-003 §3/§4).
//
// These are deliberately small and transport-level: the door
// (internal/core/server) orchestrates them in one visible pipeline.
package middleware
import (
"context"
"crypto/rand"
"fmt"
"net/http"
"time"
)
// CorrelationHeader is the response header carrying the correlation id, echoed
// in every log line and audit row (SPEC-003 §4/§8, AC-8).
const CorrelationHeader = "X-Correlation-ID"
// correlationKey is the unexported context key for the correlation id.
type correlationKey struct{}
// NewCorrelationID returns a fresh UUID v4 string. It is generated per request:
// an inbound header is never trusted, so a caller cannot forge a trail.
func NewCorrelationID() string {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
// crypto/rand failing is a platform fault; fall back to a
// time-derived id rather than emitting an empty correlation.
return fmt.Sprintf("corr-%d", time.Now().UnixNano())
}
b[6] = (b[6] & 0x0f) | 0x40 // version 4
b[8] = (b[8] & 0x3f) | 0x80 // RFC 4122 variant
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
}
// WithCorrelation attaches id to ctx.
func WithCorrelation(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, correlationKey{}, id)
}
// CorrelationFrom returns the correlation id attached to ctx, or "".
func CorrelationFrom(ctx context.Context) string {
id, _ := ctx.Value(correlationKey{}).(string)
return id
}
// CorrelationID is the middleware form: it generates a correlation id, puts it
// on the response header and the request context, then calls next.
func CorrelationID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := NewCorrelationID()
w.Header().Set(CorrelationHeader, id)
next.ServeHTTP(w, r.WithContext(WithCorrelation(r.Context(), id)))
})
}

View file

@ -0,0 +1,123 @@
package middleware_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/middleware"
)
func TestNewCorrelationID(t *testing.T) {
a := middleware.NewCorrelationID()
b := middleware.NewCorrelationID()
if a == "" || a == b {
t.Fatalf("correlation ids are empty or identical: %q, %q", a, b)
}
if len(a) != 36 || a[8] != '-' || a[13] != '-' || a[18] != '-' || a[23] != '-' {
t.Errorf("correlation id %q is not UUID-shaped", a)
}
}
func TestCorrelationIDMiddleware(t *testing.T) {
var seen string
h := middleware.CorrelationID(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen = middleware.CorrelationFrom(r.Context())
w.WriteHeader(http.StatusOK)
}))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", "/home", nil))
header := rec.Header().Get(middleware.CorrelationHeader)
if header == "" {
t.Fatal("response is missing the correlation header")
}
if seen != header {
t.Errorf("context correlation = %q, header = %q; want equal", seen, header)
}
}
func TestCorrelationRoundTrip(t *testing.T) {
if got := middleware.CorrelationFrom(context.Background()); got != "" {
t.Errorf("CorrelationFrom(background) = %q, want empty", got)
}
ctx := middleware.WithCorrelation(context.Background(), "corr-1")
if got := middleware.CorrelationFrom(ctx); got != "corr-1" {
t.Errorf("CorrelationFrom = %q, want corr-1", got)
}
}
func TestRateLimiterAllow(t *testing.T) {
l := middleware.NewRateLimiter(2, time.Minute)
if !l.Allow("k") || !l.Allow("k") {
t.Fatal("the first two requests should be allowed")
}
if l.Allow("k") {
t.Error("the third request should be denied")
}
if !l.Allow("other") {
t.Error("a different key must have its own bucket")
}
}
func TestRateLimiterWindowResets(t *testing.T) {
l := middleware.NewRateLimiter(1, time.Minute)
now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
l.SetClock(func() time.Time { return now })
if !l.Allow("k") || l.Allow("k") {
t.Fatal("limit of 1 not enforced")
}
now = now.Add(2 * time.Minute)
if !l.Allow("k") {
t.Error("the window did not reset")
}
}
func TestRateLimiterMiddleware429(t *testing.T) {
l := middleware.NewRateLimiter(1, time.Minute)
h := l.Middleware(func(*http.Request) string { return "k" },
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }))
rec1 := httptest.NewRecorder()
h.ServeHTTP(rec1, httptest.NewRequest("GET", "/access", nil))
if rec1.Code != http.StatusOK {
t.Fatalf("first request = %d, want 200", rec1.Code)
}
rec2 := httptest.NewRecorder()
h.ServeHTTP(rec2, httptest.NewRequest("GET", "/access", nil))
if rec2.Code != http.StatusTooManyRequests {
t.Fatalf("second request = %d, want 429", rec2.Code)
}
if rec2.Header().Get("Retry-After") == "" {
t.Error("429 is missing a Retry-After header")
}
}
func TestClassAndAuthSensitive(t *testing.T) {
cases := []struct {
path string
want string
}{
{"/", ""},
{"/home", ""},
{"/api/v1", ""},
{"/static/x.css", ""},
{"/access", "access"},
{"/setup", "setup"},
{"/AbCdEf0123456789", "guid"},
{"/AbCdEf0123456789/home", "guid"},
}
for _, tc := range cases {
if got := middleware.Class(tc.path); got != tc.want {
t.Errorf("Class(%q) = %q, want %q", tc.path, got, tc.want)
}
if got := middleware.AuthSensitive(tc.path); got != (tc.want != "") {
t.Errorf("AuthSensitive(%q) = %v, want %v", tc.path, got, tc.want != "")
}
}
}

View file

@ -0,0 +1,129 @@
package middleware
import (
"net/http"
"strconv"
"sync"
"time"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/routing"
)
// RateLimiter is a per-key fixed-window limiter (A3, SPEC-003 §4/AC-9). It
// guards the auth-sensitive routes: /{GUID}, /access, /setup. State is
// in-memory and single-process, which matches the single-instance model
// (D-012).
type RateLimiter struct {
mu sync.Mutex
limit int
window time.Duration
now func() time.Time
state map[string]*window
}
type window struct {
count int
reset time.Time
}
// DefaultRateLimit / DefaultRateWindow are the code defaults for the
// auth-sensitive routes.
const (
DefaultRateLimit = 30
DefaultRateWindow = time.Minute
)
// NewRateLimiter builds a limiter allowing limit requests per window per key.
// Non-positive values fall back to the defaults.
func NewRateLimiter(limit int, win time.Duration) *RateLimiter {
if limit <= 0 {
limit = DefaultRateLimit
}
if win <= 0 {
win = DefaultRateWindow
}
return &RateLimiter{
limit: limit,
window: win,
now: time.Now,
state: make(map[string]*window),
}
}
// Allow records a request for key and reports whether it is within the limit.
func (l *RateLimiter) Allow(key string) bool {
l.mu.Lock()
defer l.mu.Unlock()
now := l.now()
w, ok := l.state[key]
if !ok || !now.Before(w.reset) {
l.state[key] = &window{count: 1, reset: now.Add(l.window)}
return true
}
w.count++
return w.count <= l.limit
}
// SetClock injects a clock (tests).
func (l *RateLimiter) SetClock(fn func() time.Time) {
if fn == nil {
return
}
l.mu.Lock()
defer l.mu.Unlock()
l.now = fn
}
// RetryAfter returns the whole seconds until the window resets for key (for the
// Retry-After header). It is best-effort.
func (l *RateLimiter) RetryAfter(key string) int {
l.mu.Lock()
defer l.mu.Unlock()
w, ok := l.state[key]
if !ok {
return 0
}
d := time.Until(w.reset)
if d < 0 {
return 0
}
return int(d.Seconds()) + 1
}
// Class classifies a path into an auth-sensitive bucket, or "" when the path is
// not rate-limited. /access and /setup are explicit; any non-reserved first
// segment is a GUID lookup.
func Class(path string) string {
p := routing.Parse(path)
if len(p) == 0 || p[0] == "" {
return ""
}
switch p[0] {
case "access", "setup":
return p[0]
}
if routing.IsReservedWorkflow(p[0]) {
return ""
}
return "guid"
}
// AuthSensitive reports whether path is one of the rate-limited routes (A3).
func AuthSensitive(path string) bool { return Class(path) != "" }
// Middleware wraps next with a limiter keyed by keyFn. It answers 429 with a
// Retry-After header once the limit is exceeded.
func (l *RateLimiter) Middleware(keyFn func(*http.Request) string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key := keyFn(r)
if !l.Allow(key) {
if secs := l.RetryAfter(key); secs > 0 {
w.Header().Set("Retry-After", strconv.Itoa(secs))
}
http.Error(w, "too many requests", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}

View file

@ -0,0 +1,49 @@
package server
import (
"net/http"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth/flow"
)
// authModule is the `do` module name carrying the SPEC-006 enroll/login
// actions. The GUID still rides the path — `do`/`action` never carry identity
// (D-032/D-039).
const authModule = "auth"
// RegisterAuthFlow registers the SPEC-006 core screens (enroll · login) and the
// `auth` action module into reg. It is the wiring the door needs to make
// `POST /{GUID}?do=auth&action=…` resolve and dispatch.
func RegisterAuthFlow(reg *MapRegistry, a *flow.Actions) {
reg.RegisterCore("enroll", adaptFlow(a.EnrollScreen))
reg.RegisterCore("login", adaptFlow(a.LoginScreen))
reg.RegisterModule(NewModule(authModule).
// SPEC-006 §8 (D-041): the session-gated passkey-nudge screen. It is a
// ROUTE on the auth module (GET /{GUID}/auth/passkey) — the redirect
// target of a nudged TOTP login.
AddRoute("passkey", "passkey.prompt", adaptFlow(a.PasskeyPrompt)).
AddAction("enroll.begin", "enroll.begin", adaptFlow(a.EnrollBegin)).
AddAction("enroll.finish", "enroll.finish", adaptFlow(a.EnrollFinish)).
AddAction("totp.begin", "totp.begin", adaptFlow(a.TOTPBegin)).
AddAction("totp.verify", "totp.verify", adaptFlow(a.TOTPVerify)).
AddAction("login.begin", "login.begin", adaptFlow(a.LoginBegin)).
AddAction("login.finish", "login.finish", adaptFlow(a.LoginFinish)).
AddAction("logout", "logout", adaptFlow(a.Logout)).
// The nudge actions carry a session, so the door session-gates them
// (serveAuthAction forwards the `passkey.` prefix to serveGUID).
AddAction("passkey.add", "passkey.add", adaptFlow(a.PasskeyAdd)).
AddAction("passkey.later", "passkey.later", adaptFlow(a.PasskeyLater)).
AddAction("passkey.never", "passkey.never", adaptFlow(a.PasskeyNever)))
}
// flowFunc is a flow handler: the GUID comes from the RequestContext (the door
// resolves it from the path; the flow never parses a raw path).
type flowFunc func(w http.ResponseWriter, r *http.Request, guid string) error
// adaptFlow adapts a flow handler to the door's Handler signature.
func adaptFlow(f flowFunc) Handler {
return func(w http.ResponseWriter, r *http.Request, rc *RequestContext) error {
return f(w, r, rc.Workflow)
}
}

View file

@ -0,0 +1,488 @@
package server_test
import (
"bytes"
"context"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
pqotp "github.com/pquerna/otp/totp"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth/flow"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth/webauthn"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/httpx"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/server"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users"
)
// ── the WebAuthn fake (the ceremony is browser-bound) ────────────────────────
type fakeWebAuthn struct {
beginReg, finishReg int
beginLogin, finishLogin int
beginErr, finishErr error
}
func (f *fakeWebAuthn) BeginRegistration(context.Context, webauthn.User, []webauthn.Credential) (*webauthn.Begin, error) {
f.beginReg++
if f.beginErr != nil {
return nil, f.beginErr
}
return fakeBegin("reg-challenge"), nil
}
func (f *fakeWebAuthn) FinishRegistration(context.Context, webauthn.User, *webauthn.Begin, *http.Request) (webauthn.Credential, error) {
f.finishReg++
if f.finishErr != nil {
return webauthn.Credential{}, f.finishErr
}
return webauthn.Credential{
ID: []byte("passkey-1"), PublicKey: []byte("cose-pubkey-1"),
SignCount: 1, Transports: []string{"internal"}, AAGUID: []byte{1, 2, 3, 4},
}, nil
}
func (f *fakeWebAuthn) BeginLogin(context.Context, webauthn.User, []webauthn.Credential) (*webauthn.Begin, error) {
f.beginLogin++
if f.beginErr != nil {
return nil, f.beginErr
}
return fakeBegin("login-challenge"), nil
}
func (f *fakeWebAuthn) FinishLogin(context.Context, webauthn.User, *webauthn.Begin, *http.Request, []webauthn.Credential) (webauthn.Credential, error) {
f.finishLogin++
if f.finishErr != nil {
return webauthn.Credential{}, f.finishErr
}
return webauthn.Credential{
ID: []byte("passkey-1"), PublicKey: []byte("cose-pubkey-1"),
SignCount: 2, Transports: []string{"internal"}, AAGUID: []byte{1, 2, 3, 4},
}, nil
}
func fakeBegin(challenge string) *webauthn.Begin {
opts, _ := json.Marshal(map[string]any{
"publicKey": map[string]any{"challenge": challenge},
})
sd, _ := json.Marshal(map[string]any{
"challenge": challenge,
"expires": time.Now().Add(5 * time.Minute).UTC().Format(time.RFC3339),
})
return &webauthn.Begin{Options: opts, SessionData: sd}
}
// ── harness helpers ──────────────────────────────────────────────────────────
// pendingUser creates a team user under the admin and mints a pending identity.
func (h *harness) pendingUser(t *testing.T, email string) (userID, identityID int64, guid string) {
t.Helper()
userID, err := h.users.Create(h.ctx, h.adminID, email, "Pending", users.RoleTeam)
if err != nil {
t.Fatalf("create user: %v", err)
}
identityID, guid, err = h.ids.Mint(h.ctx, userID)
if err != nil {
t.Fatalf("Mint: %v", err)
}
return userID, identityID, guid
}
// action issues an anonymous flow action with a fresh pre-session CSRF token.
func (h *harness) action(action, guid string, form url.Values, cookies ...*http.Cookie) *httptest.ResponseRecorder {
h.t.Helper()
cookie, token := h.preCSRF()
if form == nil {
form = url.Values{}
}
form.Set("csrf", token)
req := httptest.NewRequest("POST", "/"+guid+"?do=auth&action="+url.QueryEscape(action),
strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
for _, c := range cookies {
req.AddCookie(c)
}
rec := httptest.NewRecorder()
h.srv.ServeHTTP(rec, req)
return rec
}
// sessionAction issues a session-gated action (logout) with the session-bound
// CSRF token.
func (h *harness) sessionAction(action, guid, sessionID string, extra ...*http.Cookie) *httptest.ResponseRecorder {
h.t.Helper()
form := url.Values{"csrf": {h.csrf.Token(sessionID)}}
req := httptest.NewRequest("POST", "/"+guid+"?do=auth&action="+url.QueryEscape(action),
strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(&http.Cookie{Name: httpx.SessionCookieName, Value: sessionID})
for _, c := range extra {
req.AddCookie(c)
}
rec := httptest.NewRecorder()
h.srv.ServeHTTP(rec, req)
return rec
}
// ceremonyCookie extracts the signed WebAuthn ceremony cookie from a response.
func ceremonyCookie(t *testing.T, rec *httptest.ResponseRecorder) *http.Cookie {
t.Helper()
for _, c := range rec.Result().Cookies() {
if c.Name == flow.CeremonyCookieName && c.Value != "" {
return c
}
}
t.Fatalf("response carried no %s cookie", flow.CeremonyCookieName)
return nil
}
// sessionCookie extracts the session cookie value from a response.
func sessionCookie(t *testing.T, rec *httptest.ResponseRecorder) string {
t.Helper()
for _, c := range rec.Result().Cookies() {
if c.Name == httpx.SessionCookieName && c.Value != "" {
return c.Value
}
}
t.Fatalf("response carried no session cookie (status %d)", rec.Code)
return ""
}
// enrollFully runs the whole two-factor enrollment and returns the session id.
func (h *harness) enrollFully(t *testing.T, identityID int64, guid string) string {
t.Helper()
// Factor 1 — passkey.
begin := h.action("enroll.begin", guid, nil)
if begin.Code != http.StatusOK {
t.Fatalf("enroll.begin = %d, want 200", begin.Code)
}
finish := h.action("enroll.finish", guid, nil, ceremonyCookie(t, begin))
if finish.Code != http.StatusOK {
t.Fatalf("enroll.finish = %d, want 200", finish.Code)
}
// Factor 2 — TOTP.
if rec := h.action("totp.begin", guid, nil); rec.Code != http.StatusOK {
t.Fatalf("totp.begin = %d, want 200", rec.Code)
}
secret, err := h.ids.TOTPSecret(h.ctx, identityID)
if err != nil {
t.Fatalf("TOTPSecret: %v", err)
}
code, err := pqotp.GenerateCode(string(secret), time.Now())
if err != nil {
t.Fatalf("GenerateCode: %v", err)
}
done := h.action("totp.verify", guid, url.Values{"code": {code}})
if done.Code != http.StatusSeeOther {
t.Fatalf("totp.verify = %d, want 303 (enrollment complete)", done.Code)
}
return sessionCookie(t, done)
}
// ── AC-1 ─────────────────────────────────────────────────────────────────────
func TestSpec006AC1_PendingRoutesToEnrollNotLogin(t *testing.T) {
h := newHarness(t)
_, _, guid := h.pendingUser(t, "ac1@example.com")
rec := request(h.srv, "GET", "/"+guid)
if rec.Code != http.StatusOK {
t.Fatalf("GET /{pending-guid} = %d, want 200 (enroll)", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "Enrollment") {
t.Error("a pending GUID did not render the enrollment screen (AC-1)")
}
if strings.Contains(body, "Sign in") {
t.Error("a pending GUID rendered the login screen (AC-1)")
}
// The anonymous screen issues the pre-session CSRF cookie (A15).
if !hasCookie(rec, httpx.PreSessionCookieName) {
t.Error("the enrollment screen issued no pre-session CSRF cookie (A15)")
}
}
// ── AC-2 ─────────────────────────────────────────────────────────────────────
func TestSpec006AC2_EnrollmentRequiresBothFactors(t *testing.T) {
h := newHarness(t)
_, id, guid := h.pendingUser(t, "ac2@example.com")
// TOTP first, no passkey yet -> refused; identity stays pending.
if rec := h.action("totp.begin", guid, nil); rec.Code != http.StatusOK {
t.Fatalf("totp.begin = %d, want 200", rec.Code)
}
secret, err := h.ids.TOTPSecret(h.ctx, id)
if err != nil {
t.Fatalf("TOTPSecret: %v", err)
}
code, err := pqotp.GenerateCode(string(secret), time.Now())
if err != nil {
t.Fatalf("GenerateCode: %v", err)
}
rec := h.action("totp.verify", guid, url.Values{"code": {code}})
if rec.Code != http.StatusBadRequest {
t.Errorf("totp.verify without a passkey = %d, want 400 (AC-2)", rec.Code)
}
if hasCookie(rec, httpx.SessionCookieName) {
t.Error("a session was minted without both factors (AC-2)")
}
if idn, err := h.ids.ByGUID(h.ctx, guid); err != nil || idn.Status != auth.StatusPendingEnrollment {
t.Errorf("identity after one factor = %+v, want pending_enrollment (AC-2)", idn)
}
// Passkey, no verified TOTP -> still pending, still no session.
begin := h.action("enroll.begin", guid, nil)
if begin.Code != http.StatusOK {
t.Fatalf("enroll.begin = %d, want 200", begin.Code)
}
finish := h.action("enroll.finish", guid, nil, ceremonyCookie(t, begin))
if finish.Code != http.StatusOK {
t.Fatalf("enroll.finish = %d, want 200", finish.Code)
}
if hasCookie(finish, httpx.SessionCookieName) {
t.Error("enroll.finish minted a session before TOTP (AC-2)")
}
if idn, err := h.ids.ByGUID(h.ctx, guid); err != nil || idn.Status != auth.StatusPendingEnrollment {
t.Errorf("identity after passkey only = %+v, want pending_enrollment (AC-2)", idn)
}
// Now both -> active + session.
code, err = pqotp.GenerateCode(string(secret), time.Now())
if err != nil {
t.Fatalf("GenerateCode: %v", err)
}
rec = h.action("totp.verify", guid, url.Values{"code": {code}})
if rec.Code != http.StatusSeeOther {
t.Fatalf("totp.verify with both factors = %d, want 303 (AC-2)", rec.Code)
}
if !hasCookie(rec, httpx.SessionCookieName) {
t.Error("no session minted after both factors (AC-2)")
}
idn, err := h.ids.ByGUID(h.ctx, guid)
if err != nil {
t.Fatalf("ByGUID: %v", err)
}
if idn.Status != auth.StatusActive {
t.Errorf("identity after both factors = %s, want active (AC-2)", idn.Status)
}
}
// ── AC-3 ─────────────────────────────────────────────────────────────────────
func TestSpec006AC3_ActiveRoutesToLoginThenApp(t *testing.T) {
h := newHarness(t) // the admin is active
rec := request(h.srv, "GET", "/"+h.guid)
if rec.Code != http.StatusOK {
t.Errorf("GET /{active-guid} without a session = %d, want 200 (login screen, F-1)", rec.Code)
}
if !strings.Contains(rec.Body.String(), "Sign in") {
t.Error("an active GUID without a session did not render the login screen (AC-3)")
}
rec = request(h.srv, "GET", "/"+h.guid, h.cookie())
if rec.Code != http.StatusOK {
t.Fatalf("GET /{active-guid} with a session = %d, want 200 (the app)", rec.Code)
}
if !strings.Contains(rec.Body.String(), "MyDay") {
t.Error("a valid session did not reach the app (AC-3)")
}
}
// ── AC-4 ─────────────────────────────────────────────────────────────────────
func TestSpec006AC4_WindowExpiryBlocksEnrollment(t *testing.T) {
h := newHarness(t)
_, id, guid := h.pendingUser(t, "ac4@example.com")
// Age the identity beyond the bounded window (A1).
past := store.FormatTime(time.Now().Add(-100 * time.Hour))
if _, err := h.db.Exec(`UPDATE identities SET created_at = ? WHERE identity_id = ?`, past, id); err != nil {
t.Fatalf("age identity: %v", err)
}
if rec := request(h.srv, "GET", "/"+guid); rec.Code != http.StatusNotFound {
t.Errorf("GET /{expired-guid} = %d, want 404 (A1)", rec.Code)
}
if rec := h.action("enroll.begin", guid, nil); rec.Code != http.StatusNotFound {
t.Errorf("enroll.begin past the window = %d, want 404 (A1)", rec.Code)
}
if rec := h.action("totp.begin", guid, nil); rec.Code != http.StatusNotFound {
t.Errorf("totp.begin past the window = %d, want 404 (A1)", rec.Code)
}
}
// ── AC-5 ─────────────────────────────────────────────────────────────────────
func TestSpec006AC5_LoginRotatesAndLogoutRevokes(t *testing.T) {
h := newHarness(t)
_, id, guid := h.pendingUser(t, "ac5@example.com")
s1 := h.enrollFully(t, id, guid)
if _, err := h.sessions.Validate(h.ctx, s1); err != nil {
t.Fatalf("the enrollment session is invalid: %v", err)
}
// A passkey login while S1 is live rotates it (fixation defence).
begin := h.action("login.begin", guid, nil, &http.Cookie{Name: httpx.SessionCookieName, Value: s1})
if begin.Code != http.StatusOK {
t.Fatalf("login.begin = %d, want 200", begin.Code)
}
finish := h.action("login.finish", guid, nil,
&http.Cookie{Name: httpx.SessionCookieName, Value: s1}, ceremonyCookie(t, begin))
if finish.Code != http.StatusSeeOther {
t.Fatalf("login.finish = %d, want 303", finish.Code)
}
s2 := sessionCookie(t, finish)
if s2 == s1 {
t.Error("login did not rotate the session id (AC-5)")
}
if _, err := h.sessions.Validate(h.ctx, s1); err == nil {
t.Error("the pre-login session survived rotation (AC-5)")
}
if _, err := h.sessions.Validate(h.ctx, s2); err != nil {
t.Errorf("the rotated session is invalid: %v", err)
}
// Logout revokes S2.
lo := h.sessionAction("logout", guid, s2)
if lo.Code != http.StatusSeeOther {
t.Fatalf("logout = %d, want 303", lo.Code)
}
if _, err := h.sessions.Validate(h.ctx, s2); err == nil {
t.Error("logout did not revoke the session (AC-5)")
}
}
// ── AC-6 ─────────────────────────────────────────────────────────────────────
func TestSpec006AC6_TOTPSecretEncryptedAndNeverLogged(t *testing.T) {
var logs bytes.Buffer
h := newHarness(t, func(d *server.Deps) {
d.Logger = slog.New(slog.NewTextHandler(&logs, nil))
})
_, id, guid := h.pendingUser(t, "ac6@example.com")
rec := h.action("totp.begin", guid, nil)
if rec.Code != http.StatusOK {
t.Fatalf("totp.begin = %d, want 200", rec.Code)
}
secret, err := h.ids.TOTPSecret(h.ctx, id)
if err != nil {
t.Fatalf("TOTPSecret: %v", err)
}
if len(secret) == 0 {
t.Fatal("the issued secret is empty")
}
// 1) The stored blob is ciphertext, not the secret (A9).
var raw []byte
if err := h.db.QueryRow(`SELECT secret_encrypted FROM totp_secrets WHERE identity_id = ?`, id).Scan(&raw); err != nil {
t.Fatalf("read secret_encrypted: %v", err)
}
if bytes.Contains(raw, secret) {
t.Error("AC-6: secret_encrypted holds the plaintext secret (A9)")
}
// 2) The otpauth URL is shown to the user (the one display) — but the
// secret never reached the log (A9).
if !strings.Contains(rec.Body.String(), "otpauth://") {
t.Error("totp.begin did not return an otpauth URL")
}
if strings.Contains(logs.String(), string(secret)) {
t.Error("AC-6: the TOTP secret reached the log (A9)")
}
}
// ── AC-7 ─────────────────────────────────────────────────────────────────────
func TestSpec006AC7_RateLimitAndGenericErrors(t *testing.T) {
// Rate limit (A3): the enroll/login routes return 429 past the limit.
h := newHarness(t, func(d *server.Deps) {
d.RateLimit = 1
d.RateWindow = time.Minute
})
_, _, guid := h.pendingUser(t, "ac7@example.com")
if rec := request(h.srv, "GET", "/"+guid); rec.Code != http.StatusOK {
t.Fatalf("first request = %d, want 200", rec.Code)
}
rec := request(h.srv, "GET", "/"+guid)
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("second request = %d, want 429 (A3/AC-7)", rec.Code)
}
if rec.Header().Get("Retry-After") == "" {
t.Error("429 is missing a Retry-After header")
}
// Generic error text: failures never reveal which factor or why.
h2 := newHarness(t)
_, id2, guid2 := h2.pendingUser(t, "ac7b@example.com")
h2.enrollFully(t, id2, guid2)
rec = h2.action("totp.verify", guid2, url.Values{"code": {"000000"}})
if rec.Code != http.StatusUnauthorized {
t.Fatalf("bad TOTP code = %d, want 401", rec.Code)
}
if got := strings.TrimSpace(rec.Body.String()); got != "unauthorized" {
t.Errorf("failed login body = %q, want a generic %q", got, "unauthorized")
}
// A missing/forged pre-session token is rejected generically.
req := httptest.NewRequest("POST", "/"+guid2+"?do=auth&action=enroll.begin", nil)
req.AddCookie(&http.Cookie{Name: httpx.PreSessionCookieName, Value: "forged"})
rec2 := httptest.NewRecorder()
h2.srv.ServeHTTP(rec2, req)
if rec2.Code != http.StatusForbidden {
t.Errorf("forged pre-session token = %d, want 403 (A15)", rec2.Code)
}
if got := strings.TrimSpace(rec2.Body.String()); got != "forbidden" {
t.Errorf("CSRF failure body = %q, want a generic %q", got, "forbidden")
}
}
// TestF2_NoStoreOnEnrollAndTOTPResponses: the enrollment screen and the TOTP
// issue response (which embeds the otpauth URL — the secret) are never cached.
func TestF2_NoStoreOnEnrollAndTOTPResponses(t *testing.T) {
h := newHarness(t)
_, _, guid := h.pendingUser(t, "f2@example.com")
rec := request(h.srv, "GET", "/"+guid)
if rec.Code != http.StatusOK {
t.Fatalf("enrollment screen = %d, want 200", rec.Code)
}
if got := rec.Header().Get("Cache-Control"); got != "no-store" {
t.Errorf("enrollment screen Cache-Control = %q, want no-store (F-2)", got)
}
rec = h.action("totp.begin", guid, nil)
if rec.Code != http.StatusOK {
t.Fatalf("totp.begin = %d, want 200", rec.Code)
}
if got := rec.Header().Get("Cache-Control"); got != "no-store" {
t.Errorf("totp.begin Cache-Control = %q, want no-store (F-2)", got)
}
}
// hasCookie reports whether the response set a non-empty cookie of that name.
func hasCookie(rec *httptest.ResponseRecorder, name string) bool {
for _, c := range rec.Result().Cookies() {
if c.Name == name && c.Value != "" {
return true
}
}
return false
}

View file

@ -0,0 +1,66 @@
package server
import (
"net/http"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/web"
)
// The core handlers below are the door's minimal landing surfaces. SPEC-003 §4
// says the door renders no dynamic HTML — it resolves, gates, and hands off;
// these are the hand-off targets for the routes SPEC-003 must classify, and the
// real screens arrive with SPEC-005 (setup), SPEC-006 (enroll/login) and
// SPEC-007 (access). SPEC-009 §4 renders them inside the shared shell.
// Home is the neutral landing page. It is PUBLIC: it takes no session and
// touches no database (SPEC-003 §9 AC-2). It is registered both as the core
// `home` workflow (level 1, /home) and as the `home` module's dashboard (level
// 2, /{GUID}).
func Home(w http.ResponseWriter, _ *http.Request, _ *RequestContext) error {
return web.Render(w, http.StatusOK, web.Page{
Title: "MyDay",
Guest: true,
Content: web.Markup(`<h1>MyDay</h1>
<p>Welcome. Sign in with your personal link.</p>`),
})
}
// LoginPlaceholder stands in for the login screen (SPEC-006). It answers 200:
// a rendered page is a successful response — only a failed credential attempt
// is 401/400 (F-1).
func LoginPlaceholder(w http.ResponseWriter, _ *http.Request, _ *RequestContext) error {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, err := w.Write([]byte(`<!doctype html><html><body><h1>Sign in</h1>` +
`<p>This link requires a valid session.</p></body></html>`))
return err
}
// EnrollPlaceholder stands in for the enrollment screen (SPEC-006). The GUID is
// found but not yet enrolled, so no session is required to see it.
func EnrollPlaceholder(w http.ResponseWriter, _ *http.Request, _ *RequestContext) error {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, err := w.Write([]byte(`<!doctype html><html><body><h1>Enrollment</h1>` +
`<p>Passkey and TOTP enrollment.</p></body></html>`))
return err
}
// SetupPlaceholder stands in for the /setup wizard (SPEC-005). The door only
// reaches it in setup mode.
func SetupPlaceholder(w http.ResponseWriter, _ *http.Request, _ *RequestContext) error {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, err := w.Write([]byte(`<!doctype html><html><body><h1>Setup</h1>` +
`<p>Bootstrap wizard.</p></body></html>`))
return err
}
// AccessPlaceholder stands in for the recovery entry (SPEC-007).
func AccessPlaceholder(w http.ResponseWriter, _ *http.Request, _ *RequestContext) error {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, err := w.Write([]byte(`<!doctype html><html><body><h1>Account access</h1>` +
`<p>Recovery.</p></body></html>`))
return err
}

View file

@ -0,0 +1,52 @@
package server
import (
"net/http"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/recovery"
)
// recoveryModule is the `do` module carrying the SPEC-007 approve/deny actions.
// It rides a GUID path, so the door session-gates it (unlike the anonymous
// `auth` recover action).
const recoveryModule = "recovery"
// RegisterRecoveryFlow registers SPEC-007's surface:
//
// - the core `access` workflow (GET /access — the anonymous recovery entry),
// - the `auth` module's `recover` action (POST /access?do=auth&action=recover,
// anonymous, A15), and
// - the session-gated `recovery` approve/deny actions
// (POST /{GUID}?do=recovery&action=approve|deny).
func RegisterRecoveryFlow(reg *MapRegistry, a *recovery.Actions) {
reg.RegisterCore("access", adaptRecovery(a.Page))
// The recovery request rides the `auth` action module, exactly as SPEC-007
// §4 spells it: POST /access?do=auth&action=recover. The module may already
// exist (SPEC-006's enroll/login); extend it rather than replace it.
auth := reg.module(authModule)
if auth == nil {
auth = NewModule(authModule)
reg.RegisterModule(auth)
}
auth.AddAction("recover", "recover.request", adaptRecovery(a.Request))
reg.RegisterModule(NewModule(recoveryModule).
AddAction("approve", "approve", adaptRecoveryActor(a.Approve)).
AddAction("deny", "deny", adaptRecoveryActor(a.Deny)))
}
// adaptRecovery adapts an anonymous recovery handler to the door's Handler.
func adaptRecovery(f func(http.ResponseWriter, *http.Request) error) Handler {
return func(w http.ResponseWriter, r *http.Request, _ *RequestContext) error {
return f(w, r)
}
}
// adaptRecoveryActor adapts a session-gated decision handler: the door has
// authenticated the actor and put its id on the RequestContext.
func adaptRecoveryActor(f func(http.ResponseWriter, *http.Request, int64) error) Handler {
return func(w http.ResponseWriter, r *http.Request, rc *RequestContext) error {
return f(w, r, rc.UserID)
}
}

View file

@ -0,0 +1,162 @@
package server
import (
"net/http"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/routing"
)
// Handler is a dispatched module function. It receives the door's resolved
// RequestContext — identification and routing, never a raw path (rule of the
// door). A non-nil error is logged and answered as 500.
type Handler func(w http.ResponseWriter, r *http.Request, rc *RequestContext) error
// Registry is the door's dispatch surface: the pure routing.Registry (so
// routing.Dispatch can resolve a Resolution) plus the function lookup that
// turns a resolved (module, function) into a Handler.
//
// SPEC-008 owns the full, discovered registry; this is the minimal in-memory
// implementation that makes the door exercisable.
type Registry interface {
// routing.Registry is the whitelist the pure model consults.
routing.Registry
// Handler returns the function registered for (module, function).
Handler(module, function string) (Handler, bool)
// CoreHandler returns the handler for a core-owned workflow (home, access,
// setup, login, enroll, …) — the reserved level-1 routes.
CoreHandler(workflow string) (Handler, bool)
}
// MapModule is one in-memory module: its routing.Module whitelist plus its
// function handlers.
type MapModule struct {
name string
deflt string
routes map[string]string
actions map[string]string
handlers map[string]Handler
}
// NewModule starts a module definition.
func NewModule(name string) *MapModule {
return &MapModule{
name: name,
routes: make(map[string]string),
actions: make(map[string]string),
handlers: make(map[string]Handler),
}
}
// Name implements routing.Module.
func (m *MapModule) Name() string { return m.name }
// SetDefault declares the function (and handler) for an absent/trailing PARAM3.
func (m *MapModule) SetDefault(fn string, h Handler) *MapModule {
m.deflt = fn
if h != nil {
m.handlers[fn] = h
}
return m
}
// AddRoute registers a whitelisted path-mode function.
func (m *MapModule) AddRoute(name, fn string, h Handler) *MapModule {
m.routes[name] = fn
if h != nil {
m.handlers[fn] = h
}
return m
}
// AddAction registers a whitelisted action-mode function.
func (m *MapModule) AddAction(name, fn string, h Handler) *MapModule {
m.actions[name] = fn
if h != nil {
m.handlers[fn] = h
}
return m
}
// Route implements routing.Module.
func (m *MapModule) Route(function string, tail routing.Params) (string, routing.Params, bool) {
if function == "" {
if m.deflt == "" {
return "", nil, false
}
return m.deflt, tail, true
}
if fn, ok := m.routes[function]; ok {
return fn, tail, true
}
return "", nil, false
}
// Action implements routing.Module.
func (m *MapModule) Action(action string) (string, bool) {
fn, ok := m.actions[action]
return fn, ok
}
// MapRegistry is the minimal in-memory Registry.
type MapRegistry struct {
modules map[string]*MapModule
core map[string]Handler
}
// NewMapRegistry returns an empty registry.
func NewMapRegistry() *MapRegistry {
return &MapRegistry{
modules: make(map[string]*MapModule),
core: make(map[string]Handler),
}
}
// RegisterModule adds a module under PARAM2.
func (r *MapRegistry) RegisterModule(m *MapModule) *MapRegistry {
r.modules[m.name] = m
return r
}
// RegisterCore adds a core-owned workflow handler (home, access, setup, login,
// enroll).
func (r *MapRegistry) RegisterCore(workflow string, h Handler) *MapRegistry {
r.core[workflow] = h
return r
}
// module returns the registered MapModule (nil when absent). It is the
// package-internal handle a registration helper uses to EXTEND an existing
// module — e.g. the SPEC-006 `auth` module gaining SPEC-007's `recover` action.
func (r *MapRegistry) module(name string) *MapModule { return r.modules[name] }
// Module implements routing.Registry. An unknown module is "not found" → 404.
func (r *MapRegistry) Module(name string) (routing.Module, bool) {
m, ok := r.modules[name]
if !ok {
return nil, false
}
return m, true
}
// Handler returns the function handler for (module, function).
func (r *MapRegistry) Handler(module, function string) (Handler, bool) {
m, ok := r.modules[module]
if !ok {
return nil, false
}
h, ok := m.handlers[function]
return h, ok
}
// CoreHandler returns the core handler for a reserved workflow.
func (r *MapRegistry) CoreHandler(workflow string) (Handler, bool) {
h, ok := r.core[workflow]
return h, ok
}
var (
_ Registry = (*MapRegistry)(nil)
_ routing.Module = (*MapModule)(nil)
)

View file

@ -0,0 +1,70 @@
package server
import (
"strconv"
"strings"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/routing"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users"
)
// RequestContext is the in-process hand-over from the door to a module function
// (SPEC-003 §5, D-003). It carries IDENTIFICATION, never a credential — the
// Principal and the session cookie are separate concerns (D-001).
//
// Field names match the GMS `X-*-*` headers so a future process split is
// mechanical (D-003).
type RequestContext struct {
// Workflow is the PARAM1 classification: a reserved workflow ("home",
// "access", …) or the resolved GUID.
Workflow string
// UserID is the resolved user (0 when anonymous).
UserID int64
// Principal is "user:<id>" — IDENTIFICATION only, never a credential.
Principal string
// Function is the resolved module function / action.
Function string
// Page is the joined path remainder handed to the function.
Page string
// Correlation is the request's correlation id.
Correlation string
// ClientIP is the resolved client IP.
ClientIP string
// Location is IP-derived and AUDIT ONLY (D-031) — no tracking.
Location string
}
// newRequestContext builds the hand-over from a resolution.
func newRequestContext(res routing.Resolution, userID int64, correlation, clientIP string) *RequestContext {
principal := "anonymous"
if userID != 0 {
principal = "user:" + strconv.FormatInt(userID, 10)
}
return &RequestContext{
Workflow: res.Workflow,
UserID: userID,
Principal: principal,
Function: res.Function,
Page: strings.Join(res.Params, "/"),
Correlation: correlation,
ClientIP: clientIP,
}
}
// RoleGate decides whether an authenticated user's role may call a given
// (module, function) (SPEC-003 §4 step 5d).
//
// SPEC-003 does not fix the policy — that is SPEC-007/SPEC-008. The door
// enforces the gate; the default implementation admits any authenticated user
// (the tier model ripples down, D-034).
type RoleGate interface {
Allowed(role users.Role, module, function string) bool
}
// AllowAuthenticated is the default RoleGate: every authenticated user may call
// every registered function. The one-admin and role-above rules are enforced by
// the users service; per-module authorisation arrives with SPEC-007/008.
type AllowAuthenticated struct{}
// Allowed implements RoleGate.
func (AllowAuthenticated) Allowed(users.Role, string, string) bool { return true }

View file

@ -0,0 +1,50 @@
package server
import "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users"
// Tiers is the D-034 RoleGate: simple tiers, ONE role per user, and the
// assigned role RIPPLES DOWN (admin > director > manager > team) — a director
// may do everything a manager and a team member may. There are no tick-boxes
// and no ACL.
//
// A function carries a MINIMUM required role. With no explicit requirement the
// floor is team, so every authenticated user is admitted — the door still
// enforces session + identity before this gate runs (SPEC-003 §4 step 5d).
//
// The role-ABOVE rules that need the *other* party (creation authority,
// recovery approval) do not live here: they cannot be decided from
// (role, module, function) alone, so the users service enforces them
// (SPEC-007 AC-1/AC-2).
type Tiers struct {
required map[string]users.Role
floor users.Role
}
// NewTiers returns the tiered gate with the default floor (team).
func NewTiers() *Tiers {
return &Tiers{required: make(map[string]users.Role), floor: users.RoleTeam}
}
// Require records the minimum role a (module, function) needs and returns the
// receiver for chaining. An unknown (module, function) keeps the floor.
func (t *Tiers) Require(module, function string, min users.Role) *Tiers {
if t.required == nil {
t.required = make(map[string]users.Role)
}
t.required[gateKey(module, function)] = min
return t
}
// Allowed implements RoleGate. The ripple-down rule is simply rank >= minimum;
// an invalid role (rank 0) never passes — fail closed.
func (t *Tiers) Allowed(role users.Role, module, function string) bool {
min, ok := t.required[gateKey(module, function)]
if !ok {
min = t.floor
}
return role.Rank() >= min.Rank()
}
func gateKey(module, function string) string { return module + "/" + function }
var _ RoleGate = (*Tiers)(nil)

View file

@ -0,0 +1,49 @@
package server
import (
"testing"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users"
)
// TestTiersRippleDown proves the D-034 role gate: simple tiers, one role per
// user, and a role implies every role below it.
func TestTiersRippleDown(t *testing.T) {
g := NewTiers()
// No explicit requirement -> the floor (team): every authenticated role is
// admitted.
for _, r := range []users.Role{users.RoleTeam, users.RoleManager, users.RoleDirector, users.RoleAdmin} {
if !g.Allowed(r, "home", "dashboard") {
t.Errorf("%s was denied an unlisted function (floor = team)", r)
}
}
// An invalid/empty role fails closed.
if g.Allowed(users.Role("boss"), "home", "dashboard") {
t.Error("an invalid role was admitted by the tier gate")
}
if g.Allowed(users.Role(""), "home", "dashboard") {
t.Error("an empty role was admitted by the tier gate")
}
// A function requiring manager: team is denied, manager and above pass
// (ripple-down).
g.Require("users", "create", users.RoleManager)
if g.Allowed(users.RoleTeam, "users", "create") {
t.Error("team was admitted to a manager function")
}
for _, r := range []users.Role{users.RoleManager, users.RoleDirector, users.RoleAdmin} {
if !g.Allowed(r, "users", "create") {
t.Errorf("%s was denied a manager function (ripple-down)", r)
}
}
// A director function: only director and admin pass.
g.Require("users", "promote", users.RoleDirector)
if g.Allowed(users.RoleManager, "users", "promote") {
t.Error("manager was admitted to a director function")
}
if !g.Allowed(users.RoleAdmin, "users", "promote") {
t.Error("admin was denied a director function")
}
}

View file

@ -0,0 +1,591 @@
// Package server is MyDay's ONE door (SPEC-003 §1/§4): it turns an inbound
// request into a dispatched module function, applying identity · session · role
// and every cross-cutting concern. The door RESOLVES and GATES; a module never
// sees a request it should not (D-002, D-039).
//
// The pipeline (SPEC-003 §4):
//
// 1. correlation id -> context + response header
// 2. client IP -> from server.trusted_proxies (SET XFF, A6)
// 3. security headers
// 4. rate limit -> /{GUID}, /access, /setup (A3)
// 5. routing.Dispatch -> the pure model (SPEC-002)
// 6. the Kind switch -> Redirect · NotFound · Setup · Enroll · Route/Action
// Route/Action under a GUID -> the identity/session/role gate:
// session valid? (non-empty, not expired/revoked — A2)
// session.user_id == the GUID's user?
// role permits the function?
// attach audit.Meta -> dispatch
//
// Spec: docs/specs/spec-003-core-door.md.
package server
import (
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"strconv"
"strings"
"time"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/config"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/httpx"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/middleware"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/routing"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users"
)
// ErrNonLoopbackSetup is returned when a non-loopback listen address is
// requested while in setup mode (D-025) — the process must refuse to start.
var ErrNonLoopbackSetup = errors.New("server: refusing to bind a non-loopback address in setup mode")
// Deps assembles the door's dependencies.
type Deps struct {
// Config is the loaded configuration.
Config *config.Config
// Resolver answers the pure model's GUID question (SPEC-002 §3.1).
Resolver routing.Resolver
// Registry is the module + core-handler dispatch surface.
Registry Registry
// Sessions validates the session cookie.
Sessions auth.Sessions
// Identities resolves a GUID to a user (the door's own lookup, distinct
// from the pure Resolver surface).
Identities auth.Identities
// Users loads the authenticated user's role for the role gate.
Users users.Users
// Audit receives the door's and the services' audit rows.
Audit audit.Audit
// CSRF verifies action requests (A8).
CSRF *httpx.CSRF
// PreSession verifies the anonymous enroll/login double-submit token
// (A15). When nil the `auth` action module is unavailable (404).
PreSession *httpx.PreSessionCSRF
// TrustedProxies overrides the set parsed from Config.Server.TrustedProxies.
TrustedProxies httpx.TrustedProxies
// SetupMode reports whether the instance is in setup mode (D-025). When nil
// it defaults to Config.Setup; the entrypoint widens it to "or no user
// exists" (SPEC-005 §3).
SetupMode func() bool
// RoleGate enforces per-function authorisation. Defaults to
// AllowAuthenticated.
RoleGate RoleGate
// Logger receives structured logs. Defaults to slog.Default().
Logger *slog.Logger
// Now is the clock (tests). Defaults to time.Now.
Now func() time.Time
// RateLimit / RateWindow tune the auth-sensitive limiter (A3).
RateLimit int
RateWindow time.Duration
}
// Server is the door.
type Server struct {
cfg *config.Config
disp *routing.Dispatcher
reg Registry
sessions auth.Sessions
ids auth.Identities
users users.Users
audit audit.Audit
csrf *httpx.CSRF
precsrf *httpx.PreSessionCSRF
trusted httpx.TrustedProxies
secure bool
setup func() bool
roles RoleGate
log *slog.Logger
now func() time.Time
limiter *middleware.RateLimiter
}
// New validates the dependencies and builds the door.
func New(d Deps) (*Server, error) {
if d.Config == nil {
return nil, errors.New("server: Config is required")
}
if d.Resolver == nil {
return nil, errors.New("server: Resolver is required")
}
if d.Registry == nil {
return nil, errors.New("server: Registry is required")
}
if d.Sessions == nil {
return nil, errors.New("server: Sessions is required")
}
if d.Identities == nil {
return nil, errors.New("server: Identities is required")
}
if d.Users == nil {
return nil, errors.New("server: Users is required")
}
if d.CSRF == nil {
return nil, errors.New("server: CSRF is required")
}
trusted := d.TrustedProxies
if trusted == nil {
var err error
trusted, err = httpx.ParseTrustedProxies(d.Config.Server.TrustedProxies)
if err != nil {
return nil, err
}
}
setup := d.SetupMode
if setup == nil {
cfg := d.Config
setup = func() bool { return cfg.Setup }
}
logger := d.Logger
if logger == nil {
logger = slog.Default()
}
clock := d.Now
if clock == nil {
clock = time.Now
}
roles := d.RoleGate
if roles == nil {
roles = AllowAuthenticated{}
}
return &Server{
cfg: d.Config,
disp: routing.New(d.Resolver, d.Registry),
reg: d.Registry,
sessions: d.Sessions,
ids: d.Identities,
users: d.Users,
audit: d.Audit,
csrf: d.CSRF,
precsrf: d.PreSession,
trusted: trusted,
secure: d.Config.IsSecure(),
setup: setup,
roles: roles,
log: logger,
now: clock,
limiter: middleware.NewRateLimiter(d.RateLimit, d.RateWindow),
}, nil
}
// Handler wraps the door with the correlation-id middleware (SPEC-003 §4).
func (s *Server) Handler() http.Handler { return middleware.CorrelationID(s) }
// Serve binds listen and serves the door until it fails. In setup mode a
// non-loopback listen is refused outright (D-025, AC-7).
func (s *Server) Serve(listen string) error {
if err := CheckSetupBind(s.setup(), listen); err != nil {
return err
}
s.log.Info("myday: listening", "addr", listen, "setup_mode", s.setup())
srv := &http.Server{
Addr: listen,
Handler: s.Handler(),
ReadHeaderTimeout: 10 * time.Second,
}
return srv.ListenAndServe()
}
// ServeHTTP is the door pipeline (SPEC-003 §4).
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
start := s.now()
ctx := r.Context()
cid := middleware.CorrelationFrom(ctx)
if cid == "" {
cid = middleware.NewCorrelationID()
ctx = middleware.WithCorrelation(ctx, cid)
}
w.Header().Set(middleware.CorrelationHeader, cid)
// 2. client IP (SET XFF, never append — A6).
ip := httpx.ClientIP(r, s.trusted)
// 3. security headers.
httpx.SetSecurityHeaders(w, s.secure)
// The door attaches audit.Meta before any service call (R-1). The actor is
// filled in once the identity gate resolves it.
ctx = middleware.AttachAudit(ctx, 0, ip, cid)
r = r.WithContext(ctx)
sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
var res routing.Resolution
defer func() {
s.log.Info("request",
"correlation_id", cid,
"method", r.Method,
"path", httpx.RedactPath(r.URL.Path),
"query", httpx.RedactQuery(r.URL.RawQuery),
"client_ip", ip,
"status", sw.status,
"workflow", res.Workflow,
"module", res.Module,
"function", res.Function,
"duration_ms", s.now().Sub(start).Milliseconds(),
)
}()
// 4. rate limit the auth-sensitive routes (A3, AC-9).
if class := middleware.Class(r.URL.Path); class != "" {
key := ip + "|" + class
if !s.limiter.Allow(key) {
if secs := s.limiter.RetryAfter(key); secs > 0 {
sw.Header().Set("Retry-After", strconv.Itoa(secs))
}
s.writeError(sw, http.StatusTooManyRequests, "too many requests")
return
}
}
// 5. the pure model.
res = s.disp.Dispatch(r.URL.Path, r.FormValue("do"), r.FormValue("action"))
// 6. the Kind switch.
s.dispatch(sw, r, res, cid, ip)
}
// dispatch is the Kind switch (SPEC-003 §4 step 5).
func (s *Server) dispatch(w http.ResponseWriter, r *http.Request, res routing.Resolution, cid, ip string) {
// SPEC-005 §3: in setup mode the app serves ONLY /setup (+ static assets);
// every other route is 404 (delete, don't redirect). Reserved names like
// /home and /access are 404'd too — the bootstrap has no app yet.
if s.setup() && !setupAllowed(res) {
s.writeError(w, http.StatusNotFound, "not found")
return
}
switch res.Kind {
case routing.KindRedirect:
// The ONE canonical redirect ("/" -> "/home").
http.Redirect(w, r, res.Redirect, http.StatusFound)
case routing.KindNotFound:
s.writeError(w, http.StatusNotFound, "not found")
case routing.KindSetup:
// /setup is served ONLY in setup mode; otherwise it 404s (delete,
// don't redirect — SPEC-005 §5).
if !s.setup() {
s.writeError(w, http.StatusNotFound, "not found")
return
}
s.serveCore(w, r, "setup", res, cid, ip)
case routing.KindEnroll:
// GUID found but not enrolled -> the enrollment screen (SPEC-006).
s.serveCore(w, r, "enroll", res, cid, ip)
case routing.KindLogin:
s.serveCore(w, r, "login", res, cid, ip)
case routing.KindRoute, routing.KindAction:
// /setup is setup-mode-only through BOTH doors — a path request
// classifies as KindSetup, but `do`/`action` under /setup classifies as
// KindAction with Workflow "setup". Productive mode 404s either way
// (delete, don't redirect — SPEC-005 §5).
if res.Workflow == "setup" && !s.setup() {
s.writeError(w, http.StatusNotFound, "not found")
return
}
// SPEC-005: the wizard's steps are actions under the reserved `setup`
// workflow. They run anonymously (pre-session CSRF, A15).
if res.Kind == routing.KindAction && res.Workflow == "setup" {
s.serveSetupAction(w, r, res, cid, ip)
return
}
// SPEC-006: the `auth` module's actions are the enroll/login flows.
// They run BEFORE the session gate (the whole point is to obtain a
// session); logout is the exception and stays session-gated.
if res.Kind == routing.KindAction && res.Module == authModule &&
!routing.IsReservedWorkflow(res.Workflow) {
s.serveAuthAction(w, r, res, cid, ip)
return
}
// SPEC-007: the anonymous /access recovery action — the recovery
// request form (`do=auth`). It runs before any session (pre-session
// CSRF, A15) and is always a generic response (A4). Only the `auth`
// module is served here: the session-gated approve/deny actions must
// ride a GUID path and are deliberately NOT reachable at /access.
if res.Kind == routing.KindAction && res.Workflow == "access" && res.Module == authModule {
s.serveAccessAction(w, r, res, cid, ip)
return
}
// A reserved level-1 workflow (home · access · api · static · …) is
// core-owned and needs no identity gate.
if routing.IsReservedWorkflow(res.Workflow) {
s.serveCore(w, r, res.Workflow, res, cid, ip)
return
}
// Otherwise it is a GUID branch: the identity/session/role gate.
s.serveGUID(w, r, res, cid, ip)
default:
s.writeError(w, http.StatusNotFound, "not found")
}
}
// setupAllowed reports whether a Resolution is one of the only routes served
// while in setup mode: the wizard itself (`/setup`, path or action) and static
// assets (SPEC-005 §3). Everything else — including the canonical redirect and
// every GUID branch — is 404.
func setupAllowed(res routing.Resolution) bool {
switch res.Kind {
case routing.KindSetup:
return true
case routing.KindAction:
return res.Workflow == "setup"
case routing.KindRoute:
return res.Workflow == "static"
}
return false
}
// serveCore hands off to a core-owned workflow handler.
func (s *Server) serveCore(w http.ResponseWriter, r *http.Request, workflow string, res routing.Resolution, cid, ip string) {
h, ok := s.reg.CoreHandler(workflow)
if !ok {
s.writeError(w, http.StatusNotFound, "not found")
return
}
s.invoke(w, r, h, newRequestContext(res, 0, cid, ip))
}
// serveAuthAction runs the anonymous enroll/login actions (SPEC-006 §4/§5).
// It runs BEFORE the identity/session gate — the whole point is to obtain a
// session — so it verifies the signed pre-session double-submit token (A15)
// instead of the session-bound one. `logout` is session-gated (serveGUID).
func (s *Server) serveAuthAction(w http.ResponseWriter, r *http.Request, res routing.Resolution, cid, ip string) {
if s.precsrf == nil {
s.writeError(w, http.StatusNotFound, "not found")
return
}
// `logout` revokes an existing session, and the SPEC-006 §8 passkey-nudge
// actions (passkey.add / passkey.later / passkey.never) require one — they
// are session-gated (SPEC-003 §6 CSRF), NOT anonymous like the enroll/login
// actions above.
if res.Function == "logout" || strings.HasPrefix(res.Function, "passkey.") {
s.serveGUID(w, r, res, cid, ip)
return
}
if !s.precsrf.Valid(r, r.FormValue("csrf")) {
s.writeError(w, http.StatusForbidden, "forbidden")
return
}
h, ok := s.reg.Handler(res.Module, res.Function)
if !ok {
s.writeError(w, http.StatusNotFound, "not found")
return
}
s.invoke(w, r, h, newRequestContext(res, 0, cid, ip))
}
// serveSetupAction runs a SPEC-005 wizard step. The wizard is anonymous — no
// session exists before the admin is created — so it verifies the signed
// pre-session double-submit token (A15) rather than the session-bound CSRF. The
// door has already gated this to setup mode.
func (s *Server) serveSetupAction(w http.ResponseWriter, r *http.Request, res routing.Resolution, cid, ip string) {
if !s.setup() {
s.writeError(w, http.StatusNotFound, "not found")
return
}
if s.precsrf == nil {
s.writeError(w, http.StatusNotFound, "not found")
return
}
if !s.precsrf.Valid(r, r.FormValue("csrf")) {
s.writeError(w, http.StatusForbidden, "forbidden")
return
}
h, ok := s.reg.Handler(res.Module, res.Function)
if !ok {
s.writeError(w, http.StatusNotFound, "not found")
return
}
s.invoke(w, r, h, newRequestContext(res, 0, cid, ip))
}
// serveAccessAction runs a SPEC-007 /access action. The recovery entry is
// anonymous — no session exists yet — so it verifies the signed pre-session
// double-submit token (A15) rather than the session-bound CSRF (§6). The
// response is generic whatever the email matches (AC-4).
func (s *Server) serveAccessAction(w http.ResponseWriter, r *http.Request, res routing.Resolution, cid, ip string) {
if s.precsrf == nil {
s.writeError(w, http.StatusNotFound, "not found")
return
}
if !s.precsrf.Valid(r, r.FormValue("csrf")) {
s.writeError(w, http.StatusForbidden, "forbidden")
return
}
h, ok := s.reg.Handler(res.Module, res.Function)
if !ok {
s.writeError(w, http.StatusNotFound, "not found")
return
}
s.invoke(w, r, h, newRequestContext(res, 0, cid, ip))
}
// serveGUID runs the identity/session/role gate, then dispatches to the module
// function (SPEC-003 §4 step 5).
func (s *Server) serveGUID(w http.ResponseWriter, r *http.Request, res routing.Resolution, cid, ip string) {
ctx := r.Context()
// (a) load the session cookie; an absent or empty id is rejected (A2).
cookie, err := r.Cookie(httpx.SessionCookieName)
if err != nil || cookie.Value == "" {
s.loginRequired(w, r, res, cid, ip)
return
}
// Validate: exists · not expired · not revoked · non-empty (A2/AC-5).
sess, err := s.sessions.Validate(ctx, cookie.Value)
if err != nil {
httpx.ClearSessionCookie(w, httpx.SessionCookieName, s.secure)
s.loginRequired(w, r, res, cid, ip)
return
}
// Resolve the GUID's user and require an ACTIVE identity.
idn, err := s.ids.ByGUID(ctx, res.Workflow)
if err != nil || idn.Status != auth.StatusActive {
s.writeError(w, http.StatusNotFound, "not found")
return
}
// (b) the identity match: the session must belong to the GUID's user
// (AC-4). The rejection is generic — no oracle about the session's owner.
if sess.UserID != idn.UserID {
s.loginRequired(w, r, res, cid, ip)
return
}
// (d) the role gate.
u, err := s.users.Get(ctx, sess.UserID)
if err != nil {
s.writeError(w, http.StatusNotFound, "not found")
return
}
if !s.roles.Allowed(u.Role, res.Module, res.Function) {
s.writeError(w, http.StatusForbidden, "forbidden")
return
}
// Attach the actor now that the identity is known (R-1).
ctx = middleware.AttachAudit(ctx, sess.UserID, ip, cid)
// CSRF on every action (A8/AC-6). The routing fields are not a control.
if res.Kind == routing.KindAction {
if !s.csrf.Valid(sess.ID, r.FormValue("csrf")) {
s.writeError(w, http.StatusForbidden, "invalid csrf token")
return
}
}
// (e) build the RequestContext and dispatch.
h, ok := s.reg.Handler(res.Module, res.Function)
if !ok {
s.writeError(w, http.StatusNotFound, "not found")
return
}
ctx = httpx.WithToken(ctx, s.csrf.Token(sess.ID))
r = r.WithContext(ctx)
s.invoke(w, r, h, newRequestContext(res, sess.UserID, cid, ip))
}
// loginRequired hands off to the login screen without revealing why the session
// was rejected (missing, empty, invalid, or belonging to another user). The
// screen is a RENDERED PAGE and answers 200; only a failed credential attempt is
// 401/400 (F-1). The 401 below is a degenerate fallback for a registry that
// carries no login screen at all.
func (s *Server) loginRequired(w http.ResponseWriter, r *http.Request, res routing.Resolution, cid, ip string) {
h, ok := s.reg.CoreHandler("login")
if !ok {
s.writeError(w, http.StatusUnauthorized, "login required")
return
}
s.invoke(w, r, h, newRequestContext(res, 0, cid, ip))
}
// invoke calls a handler and maps a returned error to a 500 (logged).
func (s *Server) invoke(w http.ResponseWriter, r *http.Request, h Handler, rc *RequestContext) {
if err := h(w, r, rc); err != nil {
s.log.Error("handler error",
"correlation_id", rc.Correlation,
"workflow", rc.Workflow,
"function", rc.Function,
"error", err,
)
s.writeError(w, http.StatusInternalServerError, "internal error")
}
}
// writeError writes a terse plain-text error response.
func (s *Server) writeError(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(status)
_, _ = w.Write([]byte(msg + "\n"))
}
// CheckSetupBind enforces D-025: while in setup mode the process MUST NOT bind
// a non-loopback address. It is fail-closed — an unparseable or wildcard listen
// is refused.
func CheckSetupBind(setup bool, listen string) error {
if !setup {
return nil
}
if !IsLoopback(listen) {
return fmt.Errorf("%w: listen %q", ErrNonLoopbackSetup, listen)
}
return nil
}
// IsLoopback reports whether a listen address ("host:port") is loopback-only.
func IsLoopback(listen string) bool {
host := strings.TrimSpace(listen)
if h, _, err := net.SplitHostPort(listen); err == nil {
host = h
}
host = strings.Trim(strings.TrimSpace(host), "[]")
if host == "" {
return false // ":8080" / "0.0.0.0" bind every interface — never loopback
}
if strings.EqualFold(host, "localhost") {
return true
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}
// statusWriter captures the response status for the access log.
type statusWriter struct {
http.ResponseWriter
status int
wrote bool
}
func (w *statusWriter) WriteHeader(code int) {
if w.wrote {
return
}
w.status = code
w.wrote = true
w.ResponseWriter.WriteHeader(code)
}
func (w *statusWriter) Write(b []byte) (int, error) {
if !w.wrote {
w.WriteHeader(http.StatusOK)
}
return w.ResponseWriter.Write(b)
}
var _ http.Handler = (*Server)(nil)

View file

@ -0,0 +1,631 @@
package server_test
import (
"bytes"
"context"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth/flow"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth/totp"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/config"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/httpx"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/middleware"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/routing"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/server"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users"
)
// ── fakes / wrappers ─────────────────────────────────────────────────────────
// The counting wrappers embed the real interface and increment a counter before
// delegating, so a test can prove "the door never touched this" (AC-2).
type countingResolver struct {
routing.Resolver
calls *int
}
func (c countingResolver) GUID(g string) (bool, bool) { *c.calls++; return c.Resolver.GUID(g) }
type countingSessions struct {
auth.Sessions
calls *int
}
func (c countingSessions) Validate(ctx context.Context, id string) (auth.Session, error) {
*c.calls++
return c.Sessions.Validate(ctx, id)
}
type countingIdentities struct {
auth.Identities
calls *int
}
func (c countingIdentities) ByGUID(ctx context.Context, g string) (auth.Identity, error) {
*c.calls++
return c.Identities.ByGUID(ctx, g)
}
type countingUsers struct {
users.Users
calls *int
}
func (c countingUsers) Get(ctx context.Context, id int64) (users.User, error) {
*c.calls++
return c.Users.Get(ctx, id)
}
// probeCapture records what the door handed to a module function.
type probeCapture struct {
rc *server.RequestContext
meta audit.Meta
metaOK bool
}
// ── registry ─────────────────────────────────────────────────────────────────
func testRegistry(cap *probeCapture) *server.MapRegistry {
reg := server.NewMapRegistry()
reg.RegisterCore("home", server.Home)
reg.RegisterCore("login", server.LoginPlaceholder)
reg.RegisterCore("enroll", server.EnrollPlaceholder)
reg.RegisterCore("setup", server.SetupPlaceholder)
reg.RegisterCore("access", server.AccessPlaceholder)
reg.RegisterModule(server.NewModule("home").
SetDefault("dashboard", server.Home).
AddRoute("home", "home", server.Home))
reg.RegisterModule(server.NewModule("hr").
AddAction("save-data", "save-data", func(w http.ResponseWriter, _ *http.Request, _ *server.RequestContext) error {
w.WriteHeader(http.StatusOK)
_, err := w.Write([]byte("saved"))
return err
}))
reg.RegisterModule(server.NewModule("probe").
AddRoute("show", "show", func(w http.ResponseWriter, r *http.Request, rc *server.RequestContext) error {
if cap != nil {
cap.rc = rc
cap.meta, cap.metaOK = audit.MetaFrom(r.Context())
}
w.WriteHeader(http.StatusOK)
_, err := w.Write([]byte("probe"))
return err
}))
return reg
}
// ── harness ──────────────────────────────────────────────────────────────────
type harness struct {
t *testing.T
ctx context.Context
db *store.DB
users *users.Store
ids *auth.IdentitiesService
sessions *auth.SessionsService
csrf *httpx.CSRF
pre *httpx.PreSessionCSRF
wa *fakeWebAuthn
totp *totp.Generator
srv *server.Server
adminID int64
guid string
session auth.Session
}
func newHarness(t *testing.T, opts ...func(*server.Deps)) *harness {
t.Helper()
ctx := context.Background()
db, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := db.Migrate(); err != nil {
t.Fatalf("Migrate: %v", err)
}
aud := audit.New(db.DB)
us := users.New(db.DB, aud)
adminID, err := us.Create(ctx, 0, "admin@example.com", "Admin", users.RoleAdmin)
if err != nil {
t.Fatalf("bootstrap admin: %v", err)
}
sealer, err := auth.NewAEADSealer(bytes.Repeat([]byte{0x42}, auth.KeySize))
if err != nil {
t.Fatalf("NewAEADSealer: %v", err)
}
core := auth.New(db.DB, aud, auth.WithSealer(sealer))
ids := core.Identities()
sessions := core.Sessions()
idID, guid, err := ids.Mint(ctx, adminID)
if err != nil {
t.Fatalf("Mint: %v", err)
}
if err := ids.Activate(ctx, idID); err != nil {
t.Fatalf("Activate: %v", err)
}
sess, err := sessions.Mint(ctx, idID, "127.0.0.1", "test")
if err != nil {
t.Fatalf("session Mint: %v", err)
}
const (
csrfKey = "test-csrf-key-0123456789abcdef"
cookieSecret = "test-cookie-secret-0123456789abcdef"
)
csrf, err := httpx.NewCSRF([]byte(csrfKey))
if err != nil {
t.Fatalf("NewCSRF: %v", err)
}
pre, err := httpx.NewPreSessionCSRF([]byte(cookieSecret), false)
if err != nil {
t.Fatalf("NewPreSessionCSRF: %v", err)
}
cfg := &config.Config{
Setup: false,
Server: config.Server{Listen: "127.0.0.1:8080", BaseURL: "http://127.0.0.1:8080"},
Database: config.Database{Path: db.Path()},
Secrets: config.Secrets{CSRFKey: csrfKey, CookieSecret: cookieSecret},
}
wa := &fakeWebAuthn{}
totpSvc := totp.New("MyDay", ids)
deps := server.Deps{
Config: cfg,
Resolver: ids,
Sessions: sessions,
Identities: ids,
Users: us,
Audit: aud,
CSRF: csrf,
PreSession: pre,
}
for _, o := range opts {
o(&deps)
}
// Build the SPEC-006 flow with the (possibly overridden) logger + CSRF, and
// register it unless the test supplied its own registry.
authFlow, err := flow.New(flow.Deps{
Identities: ids,
Credentials: ids,
Sessions: sessions,
Users: us,
WebAuthn: wa,
TOTP: totpSvc,
CSRF: deps.PreSession,
Audit: aud,
Logger: deps.Logger,
// The passkey nudge (SPEC-006 §8) is suppressed in setup mode; the
// door's SetupMode is the same predicate (nil = nudge enabled).
SuppressNudge: deps.SetupMode,
})
if err != nil {
t.Fatalf("flow.New: %v", err)
}
if deps.Registry == nil {
reg := testRegistry(nil)
server.RegisterAuthFlow(reg, authFlow)
deps.Registry = reg
}
srv, err := server.New(deps)
if err != nil {
t.Fatalf("server.New: %v", err)
}
return &harness{
t: t, ctx: ctx, db: db, users: us, ids: ids, sessions: sessions,
csrf: csrf, pre: pre, wa: wa, totp: totpSvc, srv: srv,
adminID: adminID, guid: guid, session: sess,
}
}
// preCSRF mints a signed pre-session token plus its cookie, as the enroll/login
// screens would (A15).
func (h *harness) preCSRF() (*http.Cookie, string) {
h.t.Helper()
tok, err := h.pre.Token()
if err != nil {
h.t.Fatalf("pre-session Token: %v", err)
}
return &http.Cookie{Name: httpx.PreSessionCookieName, Value: tok}, tok
}
func (h *harness) cookie() *http.Cookie {
return &http.Cookie{Name: httpx.SessionCookieName, Value: h.session.ID}
}
// request issues a request directly against the door (no network).
func request(h http.Handler, method, target string, cookies ...*http.Cookie) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, target, nil)
for _, c := range cookies {
req.AddCookie(c)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
// ── AC-1 ─────────────────────────────────────────────────────────────────────
func TestAC1_RedirectAndNotFound(t *testing.T) {
h := newHarness(t)
rec := request(h.srv, "GET", "/")
if rec.Code != http.StatusFound {
t.Fatalf("GET / = %d, want 302", rec.Code)
}
if loc := rec.Header().Get("Location"); loc != "/home" {
t.Errorf("Location = %q, want /home", loc)
}
rec = request(h.srv, "GET", "/holy-shit")
if rec.Code != http.StatusNotFound {
t.Errorf("GET /holy-shit = %d, want 404", rec.Code)
}
}
// ── AC-2 ─────────────────────────────────────────────────────────────────────
func TestAC2_HomeNoSessionNoDB(t *testing.T) {
var resolverCalls, validateCalls, byGUIDCalls, getCalls int
h := newHarness(t, func(d *server.Deps) {
d.Resolver = countingResolver{Resolver: d.Resolver, calls: &resolverCalls}
d.Sessions = countingSessions{Sessions: d.Sessions, calls: &validateCalls}
d.Identities = countingIdentities{Identities: d.Identities, calls: &byGUIDCalls}
d.Users = countingUsers{Users: d.Users, calls: &getCalls}
})
rec := request(h.srv, "GET", "/home")
if rec.Code != http.StatusOK {
t.Fatalf("GET /home = %d, want 200", rec.Code)
}
if !strings.Contains(rec.Body.String(), "MyDay") {
t.Error("GET /home did not render the landing page")
}
if resolverCalls != 0 || validateCalls != 0 || byGUIDCalls != 0 || getCalls != 0 {
t.Errorf("/home touched storage: resolver=%d sessions=%d identities=%d users=%d (AC-2)",
resolverCalls, validateCalls, byGUIDCalls, getCalls)
}
}
// ── AC-3 ─────────────────────────────────────────────────────────────────────
func TestAC3_GUIDWithAndWithoutSession(t *testing.T) {
h := newHarness(t)
// No session -> the login screen (a rendered page: 200, F-1).
rec := request(h.srv, "GET", "/"+h.guid)
if rec.Code != http.StatusOK {
t.Errorf("GET /{guid} without a session = %d, want 200 (login screen, F-1)", rec.Code)
}
if !strings.Contains(rec.Body.String(), "Sign in") {
t.Error("GET /{guid} without a session did not render the login screen (AC-3)")
}
// Valid session -> the module function (the dashboard).
rec = request(h.srv, "GET", "/"+h.guid, h.cookie())
if rec.Code != http.StatusOK {
t.Fatalf("GET /{guid} with a session = %d, want 200", rec.Code)
}
if !strings.Contains(rec.Body.String(), "MyDay") {
t.Error("the module function did not render the dashboard")
}
// /{GUID}/home is the same user landing.
rec = request(h.srv, "GET", "/"+h.guid+"/home", h.cookie())
if rec.Code != http.StatusOK {
t.Errorf("GET /{guid}/home with a session = %d, want 200", rec.Code)
}
}
// ── AC-4 ─────────────────────────────────────────────────────────────────────
func TestAC4_SessionUserMismatchRejected(t *testing.T) {
h := newHarness(t)
// A second user with an active identity.
otherID, err := h.users.Create(h.ctx, h.adminID, "b@example.com", "B", users.RoleTeam)
if err != nil {
t.Fatalf("create other user: %v", err)
}
idOther, guidOther, err := h.ids.Mint(h.ctx, otherID)
if err != nil {
t.Fatalf("Mint other: %v", err)
}
if err := h.ids.Activate(h.ctx, idOther); err != nil {
t.Fatalf("Activate other: %v", err)
}
// The admin's session must NOT open the other user's GUID: the door hands
// off to the login screen (200, a rendered page — F-1), never the module.
rec := request(h.srv, "GET", "/"+guidOther, h.cookie())
if rec.Code != http.StatusOK {
t.Errorf("GET /{other-guid} with a foreign session = %d, want 200 (login screen, AC-4)", rec.Code)
}
if !strings.Contains(rec.Body.String(), "Sign in") {
t.Error("a foreign session did not land on the login screen (AC-4)")
}
// The dashboard marker proves the module function was never reached (the
// shell's own chrome carries "MyDay" on every page, login included).
if strings.Contains(rec.Body.String(), "Your day, at a glance.") {
t.Error("a foreign session reached the module function (AC-4)")
}
}
// ── AC-5 ─────────────────────────────────────────────────────────────────────
func TestAC5_EmptyOrMissingSessionRejected(t *testing.T) {
h := newHarness(t)
empty := &http.Cookie{Name: httpx.SessionCookieName, Value: ""}
cases := []struct {
name string
target string
cookie *http.Cookie
}{
{"missing id on /{guid}", "/" + h.guid, nil},
{"empty id on /{guid}", "/" + h.guid, empty},
{"missing id on /{guid}/home", "/" + h.guid + "/home", nil},
{"empty id on /{guid}/home", "/" + h.guid + "/home", empty},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var rec *httptest.ResponseRecorder
if tc.cookie == nil {
rec = request(h.srv, "GET", tc.target)
} else {
rec = request(h.srv, "GET", tc.target, tc.cookie)
}
// The door rejects the empty/missing session and renders the login
// screen (200, F-1) — it must never reach the module.
if rec.Code != http.StatusOK {
t.Errorf("%s = %d, want 200 (login screen, A2/F-1)", tc.target, rec.Code)
}
if !strings.Contains(rec.Body.String(), "Sign in") {
t.Errorf("%s did not render the login screen (A2)", tc.target)
}
})
}
}
// ── AC-6 ─────────────────────────────────────────────────────────────────────
func TestAC6_ActionRequiresCSRF(t *testing.T) {
h := newHarness(t)
base := "/" + h.guid + "?do=hr&action=save-data"
// No CSRF token -> rejected.
rec := request(h.srv, "POST", base, h.cookie())
if rec.Code != http.StatusForbidden {
t.Errorf("action without a CSRF token = %d, want 403 (AC-6)", rec.Code)
}
// A wrong token -> rejected.
rec = request(h.srv, "POST", base+"&csrf=wrong", h.cookie())
if rec.Code != http.StatusForbidden {
t.Errorf("action with a bad CSRF token = %d, want 403", rec.Code)
}
// The correct, session-bound token -> dispatched.
token := url.QueryEscape(h.csrf.Token(h.session.ID))
rec = request(h.srv, "POST", base+"&csrf="+token, h.cookie())
if rec.Code != http.StatusOK {
t.Fatalf("action with a valid CSRF token = %d, want 200", rec.Code)
}
if !strings.Contains(rec.Body.String(), "saved") {
t.Error("the action handler did not run")
}
}
// ── AC-7 ─────────────────────────────────────────────────────────────────────
func TestAC7_LoopbackEnforcement(t *testing.T) {
cases := []struct {
setup bool
listen string
want bool // want an error?
}{
{true, "0.0.0.0:8080", true},
{true, ":8080", true},
{true, "192.168.1.5:8080", true},
{true, "127.0.0.1:8080", false},
{true, "localhost:8080", false},
{true, "[::1]:8080", false},
{false, "0.0.0.0:8080", false}, // productive: a public bind is allowed
}
for _, tc := range cases {
err := server.CheckSetupBind(tc.setup, tc.listen)
if tc.want && !errors.Is(err, server.ErrNonLoopbackSetup) {
t.Errorf("CheckSetupBind(%v, %q) = %v, want ErrNonLoopbackSetup", tc.setup, tc.listen, err)
}
if !tc.want && err != nil {
t.Errorf("CheckSetupBind(%v, %q) = %v, want nil", tc.setup, tc.listen, err)
}
}
// The full Serve path refuses to bind too (no listener is opened).
h := newHarness(t, func(d *server.Deps) { d.SetupMode = func() bool { return true } })
if err := h.srv.Serve("0.0.0.0:0"); !errors.Is(err, server.ErrNonLoopbackSetup) {
t.Errorf("Serve with a public listen in setup mode = %v, want ErrNonLoopbackSetup (AC-7)", err)
}
}
// ── AC-8 ─────────────────────────────────────────────────────────────────────
func TestAC8_CorrelationAndRedaction(t *testing.T) {
var logs bytes.Buffer
h := newHarness(t, func(d *server.Deps) {
d.Logger = slog.New(slog.NewTextHandler(&logs, nil))
})
rec := request(h.srv, "GET", "/home")
cid := rec.Header().Get(middleware.CorrelationHeader)
if cid == "" {
t.Fatal("the response carries no correlation id (AC-8)")
}
if !strings.Contains(logs.String(), cid) {
t.Errorf("the log does not echo the correlation id %q (AC-8)", cid)
}
// A credential riding the path is redacted in the log.
logs.Reset()
request(h.srv, "GET", "/setup/token/SUPERSECRETTOKEN")
out := logs.String()
if strings.Contains(out, "SUPERSECRETTOKEN") {
t.Error("the access log leaked a path credential (A8/AC-8)")
}
if !strings.Contains(out, httpx.Redacted) {
t.Errorf("the access log did not mark the redaction (%q)", out)
}
}
// ── AC-9 ─────────────────────────────────────────────────────────────────────
func TestAC9_RateLimiterOnAuthRoutes(t *testing.T) {
h := newHarness(t, func(d *server.Deps) {
d.RateLimit = 2
d.RateWindow = time.Minute
})
for i := 0; i < 2; i++ {
if rec := request(h.srv, "GET", "/access"); rec.Code != http.StatusOK {
t.Fatalf("request %d = %d, want 200", i+1, rec.Code)
}
}
rec := request(h.srv, "GET", "/access")
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("third request = %d, want 429 (AC-9)", rec.Code)
}
if rec.Header().Get("Retry-After") == "" {
t.Error("429 is missing a Retry-After header")
}
}
// ── supporting behaviour ─────────────────────────────────────────────────────
// TestRequestContextHandover proves §5: the module function receives the
// resolved identification, and the door attached audit.Meta (R-1).
func TestRequestContextHandover(t *testing.T) {
var cap probeCapture
h := newHarness(t, func(d *server.Deps) { d.Registry = testRegistry(&cap) })
rec := request(h.srv, "GET", "/"+h.guid+"/probe/show", h.cookie())
if rec.Code != http.StatusOK {
t.Fatalf("probe/show = %d, want 200", rec.Code)
}
if cap.rc == nil {
t.Fatal("the module function was never invoked")
}
if cap.rc.UserID != h.adminID {
t.Errorf("rc.UserID = %d, want %d", cap.rc.UserID, h.adminID)
}
if want := "user:" + strconv.FormatInt(h.adminID, 10); cap.rc.Principal != want {
t.Errorf("rc.Principal = %q, want %q", cap.rc.Principal, want)
}
if cap.rc.Workflow != h.guid {
t.Errorf("rc.Workflow = %q, want the GUID %q", cap.rc.Workflow, h.guid)
}
if cap.rc.Function != "show" {
t.Errorf("rc.Function = %q, want show", cap.rc.Function)
}
if cap.rc.Correlation == "" || cap.rc.ClientIP == "" {
t.Errorf("rc correlation/client IP not set: %+v", cap.rc)
}
if !cap.metaOK {
t.Fatal("audit.Meta was not attached to the context (R-1)")
}
if cap.meta.ActorUserID != h.adminID {
t.Errorf("meta.ActorUserID = %d, want %d", cap.meta.ActorUserID, h.adminID)
}
if cap.meta.CorrelationID != cap.rc.Correlation {
t.Errorf("meta.CorrelationID = %q, want %q", cap.meta.CorrelationID, cap.rc.Correlation)
}
}
// TestSetupModeRouting: /setup is served only in setup mode (SPEC-003 §4).
func TestSetupModeRouting(t *testing.T) {
productive := newHarness(t)
if rec := request(productive.srv, "GET", "/setup"); rec.Code != http.StatusNotFound {
t.Errorf("GET /setup in productive mode = %d, want 404", rec.Code)
}
// The action door must also 404 /setup in productive mode.
if rec := request(productive.srv, "POST", "/setup?do=setup&action=begin"); rec.Code != http.StatusNotFound {
t.Errorf("POST /setup?do=setup in productive mode = %d, want 404", rec.Code)
}
inSetup := newHarness(t, func(d *server.Deps) { d.SetupMode = func() bool { return true } })
if rec := request(inSetup.srv, "GET", "/setup"); rec.Code != http.StatusOK {
t.Errorf("GET /setup in setup mode = %d, want 200", rec.Code)
}
}
// TestEnrollRouting: a pending identity routes to the enrollment screen.
func TestEnrollRouting(t *testing.T) {
h := newHarness(t)
pendingUser, err := h.users.Create(h.ctx, h.adminID, "pending@example.com", "P", users.RoleTeam)
if err != nil {
t.Fatalf("create pending user: %v", err)
}
_, guidPending, err := h.ids.Mint(h.ctx, pendingUser) // pending_enrollment
if err != nil {
t.Fatalf("Mint pending: %v", err)
}
rec := request(h.srv, "GET", "/"+guidPending)
if rec.Code != http.StatusOK {
t.Fatalf("GET /{pending-guid} = %d, want 200 (enroll)", rec.Code)
}
if !strings.Contains(rec.Body.String(), "Enrollment") {
t.Error("a pending GUID did not route to the enrollment screen")
}
}
// TestTrustedProxyClientIPAtTheDoor: only a trusted peer's XFF is honoured.
func TestTrustedProxyClientIPAtTheDoor(t *testing.T) {
h := newHarness(t, func(d *server.Deps) {
d.Config.Server.TrustedProxies = []string{"127.0.0.1"}
})
req := httptest.NewRequest("GET", "/home", nil)
req.RemoteAddr = "127.0.0.1:9999"
req.Header.Set("X-Forwarded-For", "203.0.113.77")
rec := httptest.NewRecorder()
h.srv.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if got := req.Header.Get("X-Forwarded-For"); got != "203.0.113.77" {
t.Errorf("X-Forwarded-For = %q, want the client value SET (never appended)", got)
}
}

View file

@ -0,0 +1,592 @@
package server_test
import (
"bytes"
"context"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
pqotp "github.com/pquerna/otp/totp"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth/flow"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth/totp"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/config"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/httpx"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/server"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/setup"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users"
)
// ── harness: a FRESH instance (no user) wired to the real SPEC-005 wizard ─────
type setupHarness struct {
t *testing.T
ctx context.Context
db *store.DB
users *users.Store
ids *auth.IdentitiesService
sessions *auth.SessionsService
csrf *httpx.CSRF
pre *httpx.PreSessionCSRF
wa *fakeWebAuthn
totpSvc *totp.Generator
cfg *config.Config
cfgPath string
prints *bytes.Buffer
// writeFile overrides the persister (a failing writer exercises the
// "print the values" fallback, SPEC-005 §4).
writeFile func(string, []byte) error
setupFlow *setup.Actions
srv *server.Server
}
func newSetupHarness(t *testing.T, opts ...func(*setupHarness)) *setupHarness {
t.Helper()
ctx := context.Background()
dir := t.TempDir()
db, err := store.Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := db.Migrate(); err != nil {
t.Fatalf("Migrate: %v", err)
}
aud := audit.New(db.DB)
us := users.New(db.DB, aud) // NO user created — a fresh instance.
sealer, err := auth.NewAEADSealer(bytes.Repeat([]byte{0x24}, auth.KeySize))
if err != nil {
t.Fatalf("NewAEADSealer: %v", err)
}
core := auth.New(db.DB, aud, auth.WithSealer(sealer))
ids := core.Identities()
sessions := core.Sessions()
const (
csrfKey = "setup-csrf-key-0123456789abcdef"
cookieSecret = "setup-cookie-secret-0123456789abcdef"
)
csrf, err := httpx.NewCSRF([]byte(csrfKey))
if err != nil {
t.Fatalf("NewCSRF: %v", err)
}
pre, err := httpx.NewPreSessionCSRF([]byte(cookieSecret), false)
if err != nil {
t.Fatalf("NewPreSessionCSRF: %v", err)
}
cfg := &config.Config{
Setup: true,
Server: config.Server{Listen: "127.0.0.1:8080"},
Database: config.Database{Path: db.Path()},
Secrets: config.Secrets{CSRFKey: csrfKey, CookieSecret: cookieSecret},
}
h := &setupHarness{
t: t, ctx: ctx, db: db, users: us, ids: ids, sessions: sessions,
csrf: csrf, pre: pre, wa: &fakeWebAuthn{}, totpSvc: totp.New("MyDay", ids),
cfg: cfg, cfgPath: filepath.Join(dir, config.FileName), prints: &bytes.Buffer{},
}
for _, o := range opts {
o(h)
}
// Setup mode (SPEC-005 §3), latched: config.setup OR not yet bootstrapped.
// Latched at startup so the wizard can finish in-process; cleared by Done().
bootstrapped := func() bool {
n, err := us.Count(context.Background())
if err != nil || n == 0 {
return false
}
idn, err := ids.ByUserID(context.Background(), 1)
if err != nil {
return false
}
return idn.Status == auth.StatusActive
}
inSetup := cfg.Setup || !bootstrapped()
setupMode := func() bool {
if h.setupFlow != nil && h.setupFlow.Done() {
return false
}
return inSetup
}
// The SPEC-006 flow, reused by the wizard. TOTPOnlyEnroll is the wizard's
// D-041 mode (setup enrolls TOTP only); SuccessPath keeps the operator on
// the wizard until it is finished.
authFlow, err := flow.New(flow.Deps{
Identities: ids, Credentials: ids, Sessions: sessions, Users: us,
WebAuthn: h.wa, TOTP: h.totpSvc, CSRF: pre, Audit: aud,
TOTPOnlyEnroll: true,
SuppressNudge: setupMode,
SuccessPath: func(string) string {
if setupMode() {
return "/setup"
}
return ""
},
})
if err != nil {
t.Fatalf("flow.New: %v", err)
}
setupFlow, err := setup.New(setup.Deps{
Config: cfg, ConfigPath: h.cfgPath, Users: us, Identities: ids,
Enroll: authFlow, Audit: aud, CSRF: pre,
Printout: h.prints, WriteFile: h.writeFile,
})
if err != nil {
t.Fatalf("setup.New: %v", err)
}
h.setupFlow = setupFlow
reg := server.NewMapRegistry()
reg.RegisterCore("home", server.Home)
reg.RegisterCore("access", server.AccessPlaceholder)
reg.RegisterModule(server.NewModule("home").
SetDefault("dashboard", server.Home).
AddRoute("home", "home", server.Home))
server.RegisterSetupFlow(reg, setupFlow)
srv, err := server.New(server.Deps{
Config: cfg, Resolver: ids, Registry: reg, Sessions: sessions,
Identities: ids, Users: us, Audit: aud, CSRF: csrf, PreSession: pre,
SetupMode: setupMode,
})
if err != nil {
t.Fatalf("server.New: %v", err)
}
h.srv = srv
return h
}
// preCSRF mints a signed pre-session token plus its cookie (A15).
func (h *setupHarness) preCSRF() (*http.Cookie, string) {
h.t.Helper()
tok, err := h.pre.Token()
if err != nil {
h.t.Fatalf("pre-session Token: %v", err)
}
return &http.Cookie{Name: httpx.PreSessionCookieName, Value: tok}, tok
}
// post issues a wizard step with a fresh pre-session CSRF token.
func (h *setupHarness) post(action string, form url.Values, cookies ...*http.Cookie) *httptest.ResponseRecorder {
h.t.Helper()
cookie, token := h.preCSRF()
if form == nil {
form = url.Values{}
}
form.Set("csrf", token)
req := httptest.NewRequest("POST", "/setup?do=setup&action="+url.QueryEscape(action),
strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
for _, c := range cookies {
req.AddCookie(c)
}
rec := httptest.NewRecorder()
h.srv.ServeHTTP(rec, req)
return rec
}
// beginAdmin runs steps 1 and 2a: the domain, then the admin's details.
func (h *setupHarness) beginAdmin(t *testing.T) {
t.Helper()
if rec := h.post("domain.save", url.Values{"base_url": {"http://127.0.0.1:8080"}}); rec.Code != http.StatusSeeOther {
t.Fatalf("domain.save = %d, want 303", rec.Code)
}
if rec := h.post("admin.create", url.Values{
"display_name": {"Claus"}, "email": {"admin@example.com"},
}); rec.Code != http.StatusSeeOther {
t.Fatalf("admin.create = %d, want 303", rec.Code)
}
}
// enrollAdmin runs step 2b — the TOTP-only ceremony (D-041) — and returns the
// session id the flow minted on completion (SPEC-006 reused).
func (h *setupHarness) enrollAdmin(t *testing.T) string {
t.Helper()
if rec := h.post("admin.totp.begin", nil); rec.Code != http.StatusOK {
t.Fatalf("admin.totp.begin = %d, want 200", rec.Code)
}
secret, err := h.ids.TOTPSecret(h.ctx, 1)
if err != nil {
t.Fatalf("TOTPSecret: %v", err)
}
code, err := pqotp.GenerateCode(string(secret), time.Now())
if err != nil {
t.Fatalf("GenerateCode: %v", err)
}
done := h.post("admin.totp.verify", url.Values{"code": {code}})
if done.Code != http.StatusSeeOther {
t.Fatalf("admin.totp.verify = %d, want 303 (enrolled)", done.Code)
}
// The wizard reuses the flow's SuccessPath so the admin stays on /setup.
if loc := done.Header().Get("Location"); loc != "/setup" {
t.Errorf("post-enroll Location = %q, want /setup", loc)
}
return sessionCookie(t, done)
}
// ── AC-1 ─────────────────────────────────────────────────────────────────────
func TestSpec005AC1_SetupModeServesOnlySetup(t *testing.T) {
h := newSetupHarness(t)
rec := request(h.srv, "GET", "/setup")
if rec.Code != http.StatusOK {
t.Fatalf("GET /setup on a fresh instance = %d, want 200 (AC-1)", rec.Code)
}
if !strings.Contains(rec.Body.String(), "MyDay setup") {
t.Error("GET /setup did not render the wizard (AC-1)")
}
// The wizard issues the pre-session CSRF cookie (A15).
if !hasCookie(rec, httpx.PreSessionCookieName) {
t.Error("the wizard issued no pre-session CSRF cookie (A15)")
}
// EVERY other route is 404 — including the app the productive instance
// would serve (/home, /access, the canonical redirect, GUID branches).
for _, p := range []string{"/home", "/access", "/", "/holy-shit", "/some-guid", "/home/extra"} {
if rec := request(h.srv, "GET", p); rec.Code != http.StatusNotFound {
t.Errorf("GET %s in setup mode = %d, want 404 (AC-1)", p, rec.Code)
}
}
}
// ── R-6 · generated secrets ──────────────────────────────────────────────────
// TestR6_WizardGeneratesStrongSecrets: a first boot from config.example.yaml
// (all secrets are placeholders) must persist a VIABLE productive config — the
// wizard generates strong replacements (R-6), and the result reloads through
// the strict loader.
func TestR6_WizardGeneratesStrongSecrets(t *testing.T) {
h := newSetupHarness(t, func(h *setupHarness) {
h.cfg.Secrets = config.Secrets{
SessionKey: "CHANGE_ME_32_BYTES_RANDOM",
CookieSecret: "CHANGE_ME_32_BYTES_RANDOM",
CSRFKey: "CHANGE_ME_32_BYTES_RANDOM",
TOTPEncryptionKey: "CHANGE_ME_32_BYTES_RANDOM",
}
})
h.beginAdmin(t)
h.enrollAdmin(t)
if rec := h.post("finish", url.Values{"app.locale": {"en-GB"}}); rec.Code != http.StatusSeeOther {
t.Fatalf("finish = %d, want 303", rec.Code)
}
data, err := os.ReadFile(h.cfgPath)
if err != nil {
t.Fatalf("config was not persisted: %v", err)
}
if strings.Contains(string(data), "CHANGE_ME") {
t.Error("the persisted config still holds a placeholder secret (R-6)")
}
cfg, err := config.Load(h.cfgPath)
if err != nil {
t.Fatalf("the generated config does not load (R-6): %v", err)
}
for name, v := range map[string]string{
"session_key": cfg.Secrets.SessionKey,
"cookie_secret": cfg.Secrets.CookieSecret,
"csrf_key": cfg.Secrets.CSRFKey,
"totp_encryption_key": cfg.Secrets.TOTPEncryptionKey,
} {
if config.SecretIsPlaceholder(v) {
t.Errorf("%s is still a placeholder after setup (R-6)", name)
}
if len(v) < 32 {
t.Errorf("%s is only %d bytes, want >= 32 (R-6)", name, len(v))
}
}
// The TOTP key feeds AES-256-GCM: exactly 32 bytes.
if n := len(cfg.Secrets.TOTPEncryptionKey); n != 32 {
t.Errorf("totp_encryption_key = %d bytes, want exactly 32 (R-6)", n)
}
}
// ── AC-2 ─────────────────────────────────────────────────────────────────────
func TestSpec005AC2_NonLoopbackRefusedInSetupMode(t *testing.T) {
h := newSetupHarness(t)
// The process refuses to START on a non-loopback listen (D-025).
if err := h.srv.Serve("0.0.0.0:8080"); !errors.Is(err, server.ErrNonLoopbackSetup) {
t.Errorf("Serve(0.0.0.0:8080) in setup mode = %v, want ErrNonLoopbackSetup (AC-2)", err)
}
if err := h.srv.Serve("192.168.1.5:8080"); !errors.Is(err, server.ErrNonLoopbackSetup) {
t.Errorf("Serve(192.168.1.5:8080) in setup mode = %v, want ErrNonLoopbackSetup (AC-2)", err)
}
// Loopback is allowed (no error, and no listener is opened for :0).
if err := server.CheckSetupBind(true, "127.0.0.1:8080"); err != nil {
t.Errorf("CheckSetupBind(loopback) = %v, want nil", err)
}
}
// ── AC-3 ─────────────────────────────────────────────────────────────────────
func TestSpec005AC3_CompletingTheWizardCreatesTheTopAdmin(t *testing.T) {
h := newSetupHarness(t)
h.beginAdmin(t)
// Step 2b renders the TOTP-only enrollment ceremony (D-041) — the passkey
// is deliberately NOT offered here.
page := request(h.srv, "GET", "/setup")
if page.Code != http.StatusOK || !strings.Contains(page.Body.String(), "admin.totp.verify") {
t.Errorf("the enrollment step did not render the TOTP form (AC-3): %d", page.Code)
}
if strings.Contains(page.Body.String(), "admin.enroll.begin") {
t.Error("the wizard still offers passkey enrollment (D-041, AC-3)")
}
h.enrollAdmin(t)
u, err := h.users.Get(h.ctx, 1)
if err != nil {
t.Fatalf("user 1 was not created: %v", err)
}
if u.Role != users.RoleAdmin {
t.Errorf("user 1 role = %q, want admin (AC-3)", u.Role)
}
if u.Manager.Set {
t.Error("the top admin must be the root of the chain (no manager, AC-3)")
}
idn, err := h.ids.ByUserID(h.ctx, 1)
if err != nil {
t.Fatalf("ByUserID(1): %v", err)
}
if idn.Status != auth.StatusActive {
t.Errorf("the admin's identity = %s, want active (AC-3)", idn.Status)
}
// D-041/AC-3: TOTP is enrolled, and NO passkey is — the admin adds one
// later at first sign-in on the real origin, via the nudge (SPEC-006 §8).
if hasPasskey, err := h.ids.HasWebAuthnCredential(h.ctx, idn.ID); err != nil || hasPasskey {
t.Errorf("the wizard enrolled a passkey (D-041, AC-3): %v", err)
}
secret, err := h.ids.TOTPSecret(h.ctx, idn.ID)
if err != nil || len(secret) == 0 {
t.Errorf("the admin has no TOTP secret (AC-3): %v", err)
}
}
// ── AC-4 ─────────────────────────────────────────────────────────────────────
func TestSpec005AC4_CompletionPersistsAndSetupDisappears(t *testing.T) {
h := newSetupHarness(t)
h.beginAdmin(t)
h.enrollAdmin(t)
rec := h.post("finish", url.Values{
"mail.host": {"smtp.example.com"}, "mail.port": {"587"},
"mail.username": {"myday"}, "mail.from": {"myday@example.com"},
"app.locale": {"en-GB"}, "app.timezone": {"Europe/London"},
})
if rec.Code != http.StatusSeeOther {
t.Fatalf("finish = %d, want 303", rec.Code)
}
if !h.setupFlow.Done() {
t.Error("the wizard did not signal completion (AC-4)")
}
// The settings were persisted to config.yaml (D-035).
data, err := os.ReadFile(h.cfgPath)
if err != nil {
t.Fatalf("config was not persisted to %s: %v", h.cfgPath, err)
}
got := string(data)
for _, want := range []string{
"setup: false",
"base_url: http://127.0.0.1:8080",
"locale: en-GB",
"timezone: Europe/London",
"smtp.example.com",
} {
if !strings.Contains(got, want) {
t.Errorf("persisted config is missing %q (AC-4)", want)
}
}
// /setup now 404s — through BOTH doors (delete, don't redirect).
if rec := request(h.srv, "GET", "/setup"); rec.Code != http.StatusNotFound {
t.Errorf("GET /setup after completion = %d, want 404 (AC-4)", rec.Code)
}
if rec := h.post("finish", nil); rec.Code != http.StatusNotFound {
t.Errorf("POST /setup action after completion = %d, want 404 (AC-4)", rec.Code)
}
}
// TestSpec005AC4_UnwritableConfigPrintsTheValues: when config.yaml is not
// writable the wizard prints the settings for the operator and still completes.
func TestSpec005AC4_UnwritableConfigPrintsTheValues(t *testing.T) {
h := newSetupHarness(t, func(h *setupHarness) {
h.writeFile = func(string, []byte) error { return errors.New("read-only file system") }
})
h.beginAdmin(t)
h.enrollAdmin(t)
if rec := h.post("finish", url.Values{"app.locale": {"en-GB"}}); rec.Code != http.StatusSeeOther {
t.Fatalf("finish = %d, want 303", rec.Code)
}
if !h.setupFlow.Done() {
t.Error("setup was not marked complete despite the printed fallback (AC-4)")
}
if !strings.Contains(h.prints.String(), "setup: false") {
t.Error("the values were not printed for the operator (AC-4)")
}
if _, err := os.Stat(h.cfgPath); !errors.Is(err, os.ErrNotExist) {
t.Error("a config file was written even though the writer failed (AC-4)")
}
if rec := request(h.srv, "GET", "/setup"); rec.Code != http.StatusNotFound {
t.Errorf("GET /setup after the printed completion = %d, want 404 (AC-4)", rec.Code)
}
}
// ── AC-5 ─────────────────────────────────────────────────────────────────────
func TestSpec005AC5_OnlyOneAdmin(t *testing.T) {
h := newSetupHarness(t)
h.beginAdmin(t) // creates user 1
rec := h.post("admin.create", url.Values{
"display_name": {"Second"}, "email": {"second@example.com"},
})
if rec.Code != http.StatusConflict {
t.Errorf("a second admin.create = %d, want 409 (AC-5)", rec.Code)
}
// The service itself refuses a second admin, whoever asks (SPEC-004 AC-6).
if _, err := h.users.Create(h.ctx, 1, "third@example.com", "Third", users.RoleAdmin); !errors.Is(err, users.ErrOneAdmin) {
t.Errorf("Create(role=admin) with user 1 present = %v, want ErrOneAdmin (AC-5)", err)
}
n, err := h.users.Count(h.ctx)
if err != nil {
t.Fatalf("Count: %v", err)
}
if n != 1 {
t.Errorf("user count = %d, want exactly 1 (AC-5)", n)
}
}
// ── AC-6 ─────────────────────────────────────────────────────────────────────
func TestSpec005AC6_TheBootstrapIsAudited(t *testing.T) {
h := newSetupHarness(t)
h.beginAdmin(t)
h.enrollAdmin(t)
if rec := h.post("finish", url.Values{"app.locale": {"en-GB"}}); rec.Code != http.StatusSeeOther {
t.Fatalf("finish = %d, want 303", rec.Code)
}
rows, err := h.db.Query(`SELECT action FROM audit_log`)
if err != nil {
t.Fatalf("read audit_log: %v", err)
}
defer rows.Close()
got := map[string]bool{}
for rows.Next() {
var a string
if err := rows.Scan(&a); err != nil {
t.Fatalf("scan audit action: %v", err)
}
got[a] = true
}
if err := rows.Err(); err != nil {
t.Fatalf("iterate audit_log: %v", err)
}
// The whole bootstrap: the admin's creation, its identity, the TOTP
// enrollment (D-041: no passkey), and the completion of setup (AC-6).
for _, want := range []string{
"user.create", "identity.mint",
"enroll.totp.begin", "enroll.complete",
"setup.admin", "setup.complete",
} {
if !got[want] {
t.Errorf("audit log is missing %q (AC-6); got %v", want, got)
}
}
}
// TestSpec005AdminCreateRepairsMissingIdentity: a crash between Create and Mint
// must not wedge the wizard — the next admin.create mints the missing identity.
func TestSpec005AdminCreateRepairsMissingIdentity(t *testing.T) {
h := newSetupHarness(t)
// The user exists (user 1) but has NO identity.
if _, err := h.users.Create(h.ctx, 0, "admin@example.com", "Claus", users.RoleAdmin); err != nil {
t.Fatalf("create admin: %v", err)
}
if _, err := h.ids.ByUserID(h.ctx, 1); !errors.Is(err, auth.ErrIdentityNotFound) {
t.Fatalf("precondition: identity exists already (%v)", err)
}
rec := h.post("admin.create", url.Values{"display_name": {"Claus"}, "email": {"admin@example.com"}})
if rec.Code != http.StatusSeeOther {
t.Fatalf("admin.create (repair) = %d, want 303", rec.Code)
}
idn, err := h.ids.ByUserID(h.ctx, 1)
if err != nil {
t.Fatalf("the admin identity was not repaired: %v", err)
}
if idn.Status != auth.StatusPendingEnrollment {
t.Errorf("repaired identity = %s, want pending_enrollment", idn.Status)
}
}
// TestSpec005FailSafeSetupMode: with `config.setup: false` and an empty
// database the instance is STILL in setup mode (fail-safe) — and it stays there
// until the wizard finishes, even though creating the admin makes a live
// "no user exists" check false.
func TestSpec005FailSafeSetupMode(t *testing.T) {
h := newSetupHarness(t, func(h *setupHarness) { h.cfg.Setup = false })
// Fresh: setup mode via the fail-safe clause.
if rec := request(h.srv, "GET", "/setup"); rec.Code != http.StatusOK {
t.Fatalf("GET /setup (fail-safe) = %d, want 200", rec.Code)
}
if rec := request(h.srv, "GET", "/home"); rec.Code != http.StatusNotFound {
t.Fatalf("GET /home (fail-safe) = %d, want 404", rec.Code)
}
// Create the admin (now a user exists) and enroll — setup mode must HOLD.
h.beginAdmin(t)
if rec := request(h.srv, "GET", "/setup"); rec.Code != http.StatusOK {
t.Fatalf("GET /setup after admin.create = %d, want 200 (setup must hold mid-wizard)", rec.Code)
}
if rec := request(h.srv, "GET", "/home"); rec.Code != http.StatusNotFound {
t.Fatalf("GET /home after admin.create = %d, want 404 (setup must hold mid-wizard)", rec.Code)
}
h.enrollAdmin(t)
// Finish ends setup mode.
if rec := h.post("finish", url.Values{"app.locale": {"en-GB"}}); rec.Code != http.StatusSeeOther {
t.Fatalf("finish = %d, want 303", rec.Code)
}
if rec := request(h.srv, "GET", "/setup"); rec.Code != http.StatusNotFound {
t.Errorf("GET /setup after finish = %d, want 404", rec.Code)
}
}

View file

@ -0,0 +1,38 @@
package server
import (
"net/http"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/setup"
)
// setupModule is the `do` module name for the SPEC-005 wizard steps. The
// wizard is a reserved level-1 workflow (`/setup`) whose steps are actions.
const setupModule = "setup"
// RegisterSetupFlow registers the SPEC-005 `/setup` wizard: the page (the core
// `setup` workflow) and the action module (domain.save · admin.create ·
// admin.enroll.* · admin.totp.* · finish). The door gates them to setup mode
// and verifies the pre-session CSRF token (A15) before dispatch.
func RegisterSetupFlow(reg *MapRegistry, a *setup.Actions) {
reg.RegisterCore("setup", adaptSetup(a.Page))
reg.RegisterModule(NewModule(setupModule).
AddAction("domain.save", "domain.save", adaptSetup(a.DomainSave)).
AddAction("admin.create", "admin.create", adaptSetup(a.AdminCreate)).
// Step 2 is TOTP only (D-041) — there is no admin.enroll.* action.
AddAction("admin.totp.begin", "admin.totp.begin", adaptSetup(a.TOTPBegin)).
AddAction("admin.totp.verify", "admin.totp.verify", adaptSetup(a.TOTPVerify)).
AddAction("finish", "finish", adaptSetup(a.Finish)))
}
// setupFunc is a wizard handler. The door resolves the workflow; the wizard
// never parses a raw path.
type setupFunc func(w http.ResponseWriter, r *http.Request) error
// adaptSetup adapts a wizard handler to the door's Handler signature.
func adaptSetup(f setupFunc) Handler {
return func(w http.ResponseWriter, r *http.Request, _ *RequestContext) error {
return f(w, r)
}
}

View file

@ -0,0 +1,263 @@
package server_test
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
pqotp "github.com/pquerna/otp/totp"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth/webauthn"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/httpx"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/server"
)
// ── SPEC-006 §8 · the passkey nudge (D-041) · AC-8…AC-11 ─────────────────────
// issueTOTP issues a TOTP secret for an identity and returns a valid code.
func (h *harness) issueTOTP(t *testing.T, identityID int64) string {
t.Helper()
if _, err := h.totp.Begin(h.ctx, identityID, h.guid); err != nil {
t.Fatalf("totp.Begin: %v", err)
}
secret, err := h.ids.TOTPSecret(h.ctx, identityID)
if err != nil {
t.Fatalf("TOTPSecret: %v", err)
}
code, err := pqotp.GenerateCode(string(secret), time.Now())
if err != nil {
t.Fatalf("GenerateCode: %v", err)
}
return code
}
// totpLogin performs an anonymous TOTP login for guid. host overrides the
// request Host (the browser origin); "" keeps the httptest default.
func (h *harness) totpLogin(t *testing.T, guid, code, host string) *httptest.ResponseRecorder {
t.Helper()
cookie, token := h.preCSRF()
form := url.Values{"csrf": {token}, "code": {code}}
req := httptest.NewRequest("POST", "/"+guid+"?do=auth&action=totp.verify",
strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if host != "" {
req.Host = host
}
req.AddCookie(cookie)
rec := httptest.NewRecorder()
h.srv.ServeHTTP(rec, req)
return rec
}
// putPasskey stores a passkey credential for an identity (the WebAuthn fake).
func (h *harness) putPasskey(t *testing.T, identityID int64) {
t.Helper()
err := h.ids.PutWebAuthnCredential(h.ctx, identityID, webauthn.Credential{
ID: []byte("passkey-1"), PublicKey: []byte("cose-pubkey-1"), SignCount: 1,
})
if err != nil {
t.Fatalf("PutWebAuthnCredential: %v", err)
}
}
// countAudit returns how many audit rows carry the given action.
func (h *harness) countAudit(t *testing.T, action string) int {
t.Helper()
var n int
if err := h.db.QueryRow(`SELECT count(*) FROM audit_log WHERE action = ?`, action).Scan(&n); err != nil {
t.Fatalf("count audit %q: %v", action, err)
}
return n
}
func (h *harness) sessionCookie(id string) *http.Cookie {
return &http.Cookie{Name: httpx.SessionCookieName, Value: id}
}
// ── AC-8 ─────────────────────────────────────────────────────────────────────
func TestSpec006AC8_TOTPLoginWithoutPasskeyIsNudged(t *testing.T) {
h := newHarness(t)
code := h.issueTOTP(t, h.session.IdentityID)
rec := h.totpLogin(t, h.guid, code, "") // httptest Host = example.com (public)
if rec.Code != http.StatusSeeOther {
t.Fatalf("TOTP login = %d, want 303 (AC-8)", rec.Code)
}
if loc := rec.Header().Get("Location"); loc != "/"+h.guid+"/auth/passkey" {
t.Errorf("TOTP login without a passkey landed on %q, want the nudge (AC-8)", loc)
}
if s := sessionCookie(t, rec); s == "" {
t.Error("the nudge redirect carried no session cookie (AC-8)")
}
// A TOTP login WITH a passkey present is NEVER nudged (AC-8, second half).
h.putPasskey(t, h.session.IdentityID)
code = h.issueTOTP(t, h.session.IdentityID)
rec = h.totpLogin(t, h.guid, code, "")
if loc := rec.Header().Get("Location"); loc != "/"+h.guid+"/home" {
t.Errorf("TOTP login with a passkey landed on %q, want the app (AC-8)", loc)
}
}
// ── AC-9 ─────────────────────────────────────────────────────────────────────
func TestSpec006AC9_DontAskAgainPersistsAndSilences(t *testing.T) {
h := newHarness(t)
code := h.issueTOTP(t, h.session.IdentityID)
// First TOTP login -> nudge.
rec := h.totpLogin(t, h.guid, code, "")
if loc := rec.Header().Get("Location"); loc != "/"+h.guid+"/auth/passkey" {
t.Fatalf("first TOTP login = %q, want the nudge (AC-9)", loc)
}
sid := sessionCookie(t, rec)
// "Don't ask again" -> passkey_opt_out = true (audited).
never := h.sessionAction("passkey.never", h.guid, sid)
if never.Code != http.StatusSeeOther {
t.Fatalf("passkey.never = %d, want 303 (AC-9)", never.Code)
}
if loc := never.Header().Get("Location"); loc != "/"+h.guid+"/home" {
t.Errorf("passkey.never landed on %q, want the app (AC-9)", loc)
}
optOut, err := h.users.PasskeyOptOut(h.ctx, h.adminID)
if err != nil {
t.Fatalf("PasskeyOptOut: %v", err)
}
if !optOut {
t.Error("passkey.never did not persist the opt-out (AC-9)")
}
if n := h.countAudit(t, "user.passkey_opt_out"); n != 1 {
t.Errorf("opt-out audit rows = %d, want 1 (A11/AC-9)", n)
}
// Later TOTP logins are never nudged again.
code = h.issueTOTP(t, h.session.IdentityID)
rec = h.totpLogin(t, h.guid, code, "")
if loc := rec.Header().Get("Location"); loc != "/"+h.guid+"/home" {
t.Errorf("a login after opting out landed on %q, want the app (AC-9)", loc)
}
}
// ── AC-10 ────────────────────────────────────────────────────────────────────
func TestSpec006AC10_NoNudgeOnLoopback(t *testing.T) {
h := newHarness(t)
code := h.issueTOTP(t, h.session.IdentityID)
rec := h.totpLogin(t, h.guid, code, "127.0.0.1") // the loopback origin
if rec.Code != http.StatusSeeOther {
t.Fatalf("loopback TOTP login = %d, want 303 (AC-10)", rec.Code)
}
if loc := rec.Header().Get("Location"); loc != "/"+h.guid+"/home" {
t.Errorf("loopback TOTP login landed on %q, want the app — no nudge (AC-10)", loc)
}
}
func TestSpec006AC10_NoNudgeInSetupMode(t *testing.T) {
h := newHarness(t, func(d *server.Deps) { d.SetupMode = func() bool { return true } })
// In setup mode the app serves ONLY /setup: the TOTP login action and the
// passkey screen are both unreachable, so the nudge can never appear.
if rec := h.totpLogin(t, h.guid, "123456", ""); rec.Code != http.StatusNotFound {
t.Errorf("TOTP login in setup mode = %d, want 404 (AC-10)", rec.Code)
}
if rec := request(h.srv, "GET", "/"+h.guid+"/auth/passkey", h.sessionCookie(h.session.ID)); rec.Code != http.StatusNotFound {
t.Errorf("the passkey screen in setup mode = %d, want 404 (AC-10)", rec.Code)
}
}
// ── AC-11 ────────────────────────────────────────────────────────────────────
func TestSpec006AC11_PasskeyAddRegistersAndAudits(t *testing.T) {
h := newHarness(t)
code := h.issueTOTP(t, h.session.IdentityID)
// TOTP login -> the nudge screen.
rec := h.totpLogin(t, h.guid, code, "")
if loc := rec.Header().Get("Location"); loc != "/"+h.guid+"/auth/passkey" {
t.Fatalf("TOTP login = %q, want the nudge (AC-11)", loc)
}
sid := sessionCookie(t, rec)
// GET the nudge screen with the session: it begins the registration and
// carries the ceremony state.
page := request(h.srv, "GET", "/"+h.guid+"/auth/passkey", h.sessionCookie(sid))
if page.Code != http.StatusOK {
t.Fatalf("GET the passkey screen = %d, want 200 (AC-11)", page.Code)
}
if !strings.Contains(page.Body.String(), "passkey.add") {
t.Error("the nudge screen does not offer passkey.add (AC-11)")
}
if h.wa.beginReg != 1 {
t.Errorf("BeginRegistration calls = %d, want 1 (AC-11)", h.wa.beginReg)
}
// passkey.add completes the registration: the credential is stored and the
// event is audited.
add := h.sessionAction("passkey.add", h.guid, sid, ceremonyCookie(t, page))
if add.Code != http.StatusSeeOther {
t.Fatalf("passkey.add = %d, want 303 (AC-11)", add.Code)
}
if loc := add.Header().Get("Location"); loc != "/"+h.guid+"/home" {
t.Errorf("passkey.add landed on %q, want the app (AC-11)", loc)
}
has, err := h.ids.HasWebAuthnCredential(h.ctx, h.session.IdentityID)
if err != nil || !has {
t.Errorf("passkey.add stored no credential (AC-11): %v", err)
}
if n := h.countAudit(t, "passkey.add"); n != 1 {
t.Errorf("passkey.add audit rows = %d, want 1 (AC-11)", n)
}
}
// TestSpec006_NudgeLaterPersistsNothing: "Not now" skips this time — nothing is
// written, and the user is nudged again at the next TOTP login (SPEC-006 §8).
func TestSpec006_NudgeLaterPersistsNothing(t *testing.T) {
h := newHarness(t)
code := h.issueTOTP(t, h.session.IdentityID)
rec := h.totpLogin(t, h.guid, code, "")
sid := sessionCookie(t, rec)
later := h.sessionAction("passkey.later", h.guid, sid)
if later.Code != http.StatusSeeOther {
t.Fatalf("passkey.later = %d, want 303", later.Code)
}
if optOut, err := h.users.PasskeyOptOut(h.ctx, h.adminID); err != nil || optOut {
t.Errorf("passkey.later persisted an opt-out (SPEC-006 §8): %v", err)
}
// Still nudged next time.
code = h.issueTOTP(t, h.session.IdentityID)
rec = h.totpLogin(t, h.guid, code, "")
if loc := rec.Header().Get("Location"); loc != "/"+h.guid+"/auth/passkey" {
t.Errorf("after 'not now' the next login landed on %q, want the nudge", loc)
}
}
// TestSpec006_PasskeyLoginNeverNudged: a passkey login never nudges, even with
// a passkey present and a public origin (SPEC-006 §8).
func TestSpec006_PasskeyLoginNeverNudged(t *testing.T) {
h := newHarness(t)
h.putPasskey(t, h.session.IdentityID)
begin := h.action("login.begin", h.guid, nil)
if begin.Code != http.StatusOK {
t.Fatalf("login.begin = %d, want 200", begin.Code)
}
finish := h.action("login.finish", h.guid, nil, ceremonyCookie(t, begin))
if finish.Code != http.StatusSeeOther {
t.Fatalf("login.finish = %d, want 303", finish.Code)
}
if loc := finish.Header().Get("Location"); loc != "/"+h.guid+"/home" {
t.Errorf("a passkey login landed on %q, want the app — never nudged", loc)
}
if h.wa.finishLogin != 1 {
t.Errorf("FinishLogin calls = %d, want 1", h.wa.finishLogin)
}
}

View file

@ -0,0 +1,524 @@
package server_test
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/config"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/httpx"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/mail"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/recovery"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/server"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users"
)
// member is one enrolled user with a live session (the chain's four tiers).
type member struct {
userID int64
identityID int64
guid string
sessionID string
email string
role users.Role
}
type recoveryHarness struct {
t *testing.T
ctx context.Context
db *store.DB
users *users.Store
ids *auth.IdentitiesService
sessions *auth.SessionsService
requests *recovery.Store
mailer *mail.Fake
csrf *httpx.CSRF
pre *httpx.PreSessionCSRF
srv *server.Server
admin member
director member
manager member
team member
peer member // a second team member (same role as team)
}
func newRecoveryHarness(t *testing.T) *recoveryHarness {
t.Helper()
ctx := context.Background()
db, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := db.Migrate(); err != nil {
t.Fatalf("Migrate: %v", err)
}
aud := audit.New(db.DB)
us := users.New(db.DB, aud)
sealer, err := auth.NewAEADSealer(bytes.Repeat([]byte{0x51}, auth.KeySize))
if err != nil {
t.Fatalf("NewAEADSealer: %v", err)
}
core := auth.New(db.DB, aud, auth.WithSealer(sealer))
ids := core.Identities()
sessions := core.Sessions()
const (
csrfKey = "recovery-csrf-key-0123456789abcdef"
cookieSecret = "recovery-cookie-secret-0123456789abcdef"
)
csrf, err := httpx.NewCSRF([]byte(csrfKey))
if err != nil {
t.Fatalf("NewCSRF: %v", err)
}
pre, err := httpx.NewPreSessionCSRF([]byte(cookieSecret), false)
if err != nil {
t.Fatalf("NewPreSessionCSRF: %v", err)
}
h := &recoveryHarness{
t: t, ctx: ctx, db: db, users: us, ids: ids, sessions: sessions,
mailer: mail.NewFake(), csrf: csrf, pre: pre,
}
h.requests = recovery.NewStore(db.DB, aud)
// The chain, built top-down (D-019).
h.admin = h.enroll(ctx, 0, "admin@example.com", "Admin", users.RoleAdmin)
h.director = h.enroll(ctx, h.admin.userID, "director@example.com", "Director", users.RoleDirector)
h.manager = h.enroll(ctx, h.director.userID, "manager@example.com", "Manager", users.RoleManager)
h.team = h.enroll(ctx, h.manager.userID, "team@example.com", "Team", users.RoleTeam)
h.peer = h.enroll(ctx, h.manager.userID, "peer@example.com", "Peer", users.RoleTeam)
rec, err := recovery.New(recovery.Deps{
Requests: h.requests,
Users: us,
Identities: ids,
Mailer: h.mailer,
CSRF: pre,
Audit: aud,
BaseURL: "https://myday.example.com",
Logger: nil,
})
if err != nil {
t.Fatalf("recovery.New: %v", err)
}
reg := server.NewMapRegistry()
reg.RegisterCore("home", server.Home)
reg.RegisterModule(server.NewModule("home").
SetDefault("dashboard", server.Home).
AddRoute("home", "home", server.Home))
server.RegisterRecoveryFlow(reg, rec)
cfg := &config.Config{
Server: config.Server{Listen: "127.0.0.1:8080", BaseURL: "https://myday.example.com"},
Database: config.Database{Path: db.Path()},
Secrets: config.Secrets{CSRFKey: csrfKey, CookieSecret: cookieSecret},
}
srv, err := server.New(server.Deps{
Config: cfg, Resolver: ids, Registry: reg, Sessions: sessions,
Identities: ids, Users: us, Audit: aud, CSRF: csrf, PreSession: pre,
RoleGate: server.NewTiers(),
})
if err != nil {
t.Fatalf("server.New: %v", err)
}
h.srv = srv
return h
}
// enroll creates a user on the chain, activates an identity and mints a session.
func (h *recoveryHarness) enroll(ctx context.Context, creator int64, email, name string, role users.Role) member {
h.t.Helper()
uid, err := h.users.Create(ctx, creator, email, name, role)
if err != nil {
h.t.Fatalf("create %s: %v", role, err)
}
id, guid, err := h.ids.Mint(ctx, uid)
if err != nil {
h.t.Fatalf("Mint %s: %v", role, err)
}
if err := h.ids.Activate(ctx, id); err != nil {
h.t.Fatalf("Activate %s: %v", role, err)
}
sess, err := h.sessions.Mint(ctx, id, "127.0.0.1", "test")
if err != nil {
h.t.Fatalf("session Mint %s: %v", role, err)
}
return member{userID: uid, identityID: id, guid: guid, sessionID: sess.ID, email: email, role: role}
}
func (h *recoveryHarness) preCSRF() (*http.Cookie, string) {
h.t.Helper()
tok, err := h.pre.Token()
if err != nil {
h.t.Fatalf("pre-session Token: %v", err)
}
return &http.Cookie{Name: httpx.PreSessionCookieName, Value: tok}, tok
}
// access posts the recovery request form (anonymous, A15).
func (h *recoveryHarness) access(email string) *httptest.ResponseRecorder {
h.t.Helper()
cookie, token := h.preCSRF()
form := url.Values{"email": {email}, "csrf": {token}}
req := httptest.NewRequest("POST", "/access?do=auth&action=recover", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rec := httptest.NewRecorder()
h.srv.ServeHTTP(rec, req)
return rec
}
// decide issues a session-gated approve/deny action as actor.
func (h *recoveryHarness) decide(action string, actor member, requestID int64) *httptest.ResponseRecorder {
h.t.Helper()
form := url.Values{
"request": {strconv.FormatInt(requestID, 10)},
"csrf": {h.csrf.Token(actor.sessionID)},
}
req := httptest.NewRequest("POST", "/"+actor.guid+"?do=recovery&action="+action, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(&http.Cookie{Name: httpx.SessionCookieName, Value: actor.sessionID})
rec := httptest.NewRecorder()
h.srv.ServeHTTP(rec, req)
return rec
}
// openRequest submits team's recovery request and returns its id.
func (h *recoveryHarness) openRequest(m member) recovery.Request {
h.t.Helper()
if rec := h.access(m.email); rec.Code != http.StatusOK {
h.t.Fatalf("POST /access = %d, want 200", rec.Code)
}
req, err := h.requests.Pending(h.ctx, m.userID)
if err != nil {
h.t.Fatalf("Pending(%d): %v", m.userID, err)
}
return req
}
// auditActions returns the set of audit actions recorded.
func (h *recoveryHarness) auditActions(t *testing.T) map[string]bool {
t.Helper()
rows, err := h.db.Query(`SELECT action FROM audit_log`)
if err != nil {
t.Fatalf("read audit_log: %v", err)
}
defer rows.Close()
got := map[string]bool{}
for rows.Next() {
var a string
if err := rows.Scan(&a); err != nil {
t.Fatalf("scan audit action: %v", err)
}
got[a] = true
}
if err := rows.Err(); err != nil {
t.Fatalf("iterate audit_log: %v", err)
}
return got
}
// ── AC-2 ─────────────────────────────────────────────────────────────────────
// TestSpec007AC2_NoSelfApprovalOrLowerRole: the requester can never approve
// their own request, and a peer (same role) cannot either (D-019, AC-2).
func TestSpec007AC2_NoSelfApprovalOrLowerRole(t *testing.T) {
h := newRecoveryHarness(t)
req := h.openRequest(h.team)
// The requester themselves -> forbidden. The body must be EXACTLY the
// client error — a returned error would make the door append a 500 on top
// of an already-written response.
if rec := h.decide("approve", h.team, req.ID); rec.Code != http.StatusForbidden {
t.Errorf("self-approval = %d, want 403 (AC-2)", rec.Code)
} else if got := rec.Body.String(); strings.TrimSpace(got) != "forbidden" {
t.Errorf("self-approval body = %q, want exactly %q", got, "forbidden")
}
// A peer (same role) -> forbidden.
if rec := h.decide("approve", h.peer, req.ID); rec.Code != http.StatusForbidden {
t.Errorf("peer approval = %d, want 403 (AC-2)", rec.Code)
}
// Nothing changed: the identity is still active, the request still pending.
idn, err := h.ids.ByGUID(h.ctx, h.team.guid)
if err != nil || idn.Status != auth.StatusActive {
t.Errorf("identity after a refused approval = %+v, want still active (AC-2)", idn)
}
still, err := h.requests.Get(h.ctx, req.ID)
if err != nil || still.Status != recovery.StatusPending {
t.Errorf("request after a refused approval = %+v, want pending (AC-2)", still)
}
}
// TestSpec007AC2_R7_ChainAuthority: the recovery authority is the CHAIN (R-7).
// A director in a DIFFERENT subtree is a role above a team member but not on
// their manager line, so they may NOT approve; the admin (the root) may.
func TestSpec007AC2_R7_ChainAuthority(t *testing.T) {
h := newRecoveryHarness(t)
// A second director, created by the admin: role-above team, but NOT an
// ancestor of team (team -> manager -> director -> admin).
directorB := h.enroll(h.ctx, h.admin.userID, "director2@example.com", "Director2", users.RoleDirector)
req := h.openRequest(h.team)
// Role-above but off the chain -> forbidden (R-7's correction).
if rec := h.decide("approve", directorB, req.ID); rec.Code != http.StatusForbidden {
t.Errorf("non-ancestor director approval = %d, want 403 (R-7)", rec.Code)
}
// The request is untouched by the refusal.
if still, err := h.requests.Get(h.ctx, req.ID); err != nil || still.Status != recovery.StatusPending {
t.Errorf("request after a refused off-chain approval = %+v, want pending (R-7)", still)
}
// The admin is the root of the chain and may approve.
if rec := h.decide("approve", h.admin, req.ID); rec.Code != http.StatusOK {
t.Errorf("admin approval = %d, want 200 (R-7)", rec.Code)
}
}
// ── AC-3 ─────────────────────────────────────────────────────────────────────
// TestSpec007AC3_ApprovalRotatesIdentity: an approver on the requester's chain
// revokes the old identity and all its sessions and mints a NEW GUID, then
// emails the enrolment link (D-007, AC-3).
func TestSpec007AC3_ApprovalRotatesIdentity(t *testing.T) {
h := newRecoveryHarness(t)
req := h.openRequest(h.team)
rec := h.decide("approve", h.manager, req.ID) // manager is above team
if rec.Code != http.StatusOK {
t.Fatalf("approve = %d, want 200 (body %q)", rec.Code, rec.Body.String())
}
// The OLD identity is revoked and its GUID tombstoned.
old, err := h.ids.ByGUID(h.ctx, h.team.guid)
if err != nil {
t.Fatalf("ByGUID(old): %v", err)
}
if old.Status != auth.StatusRevoked {
t.Errorf("old identity status = %s, want revoked (AC-3)", old.Status)
}
// Every session of the old identity is dead.
if _, err := h.sessions.Validate(h.ctx, h.team.sessionID); err == nil {
t.Error("a pre-approval session survived the rotation (AC-3)")
}
// A NEW GUID is minted, pending enrollment, under the same user.
fresh, err := h.ids.ByUserID(h.ctx, h.team.userID)
if err != nil {
t.Fatalf("ByUserID: %v", err)
}
if fresh.GUID == h.team.guid {
t.Error("approval reissued the old GUID (AC-3)")
}
if fresh.Status != auth.StatusPendingEnrollment {
t.Errorf("new identity status = %s, want pending_enrollment (AC-3)", fresh.Status)
}
// The enrolment link reached the address on record.
found := false
for _, m := range h.mailer.To(h.team.email) {
if strings.Contains(m.Body, fresh.GUID) {
found = true
}
}
if !found {
t.Errorf("no enrolment email carrying the new GUID reached %s (AC-3)", h.team.email)
}
// The request is approved.
done, err := h.requests.Get(h.ctx, req.ID)
if err != nil || done.Status != recovery.StatusApproved {
t.Errorf("request = %+v, want approved (AC-3)", done)
}
}
// ── AC-4 ─────────────────────────────────────────────────────────────────────
// TestSpec007AC4_AccessIsGeneric: /access never reveals whether the email
// matched — the same status and the same body either way (A3, AC-4).
func TestSpec007AC4_AccessIsGeneric(t *testing.T) {
h := newRecoveryHarness(t)
hit := h.access(h.team.email)
miss := h.access("nobody@example.com")
if hit.Code != http.StatusOK || miss.Code != http.StatusOK {
t.Fatalf("handle codes = %d/%d, want 200/200", hit.Code, miss.Code)
}
if hit.Body.String() != miss.Body.String() {
t.Errorf("the /access response differs on a hit vs a miss:\nhit: %q\nmiss: %q",
hit.Body.String(), miss.Body.String())
}
if !strings.Contains(hit.Body.String(), "If a record exists") {
t.Errorf("the /access notice is not generic: %q", hit.Body.String())
}
}
// ── AC-6 ─────────────────────────────────────────────────────────────────────
// TestSpec007AC6_RecoveryIsAudited: request, approve, deny and the identity
// rotation are all recorded (A11, AC-6).
func TestSpec007AC6_RecoveryIsAudited(t *testing.T) {
h := newRecoveryHarness(t)
// approve
req := h.openRequest(h.team)
if rec := h.decide("approve", h.manager, req.ID); rec.Code != http.StatusOK {
t.Fatalf("approve = %d, want 200", rec.Code)
}
// deny
req2 := h.openRequest(h.manager)
if rec := h.decide("deny", h.director, req2.ID); rec.Code != http.StatusOK {
t.Fatalf("deny = %d, want 200", rec.Code)
}
got := h.auditActions(t)
for _, want := range []string{
"recovery.request",
"recovery.approve",
"recovery.deny",
"identity.rotate",
} {
if !got[want] {
t.Errorf("audit log is missing %q (AC-6); got %v", want, got)
}
}
}
// ── supporting behaviour ─────────────────────────────────────────────────────
// TestSpec007AccessIssuesPreSessionCSRF: the anonymous /access form carries the
// signed pre-session token (A15) and the action without it is refused.
func TestSpec007AccessIssuesPreSessionCSRF(t *testing.T) {
h := newRecoveryHarness(t)
rec := request(h.srv, "GET", "/access")
if rec.Code != http.StatusOK {
t.Fatalf("GET /access = %d, want 200", rec.Code)
}
if !hasCookie(rec, httpx.PreSessionCookieName) {
t.Error("GET /access issued no pre-session CSRF cookie (A15)")
}
if got := rec.Header().Get("Cache-Control"); got != "no-store" {
t.Errorf("GET /access Cache-Control = %q, want no-store", got)
}
// Without the double-submit token the action is refused (A15).
req := httptest.NewRequest("POST", "/access?do=auth&action=recover",
strings.NewReader(url.Values{"email": {h.team.email}}.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
h.srv.ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
t.Errorf("recovery request without CSRF = %d, want 403 (A15)", rr.Code)
}
}
// TestSpec007_RepeatRequestDoesNotRenotify: a second POST for the same user
// reuses the pending request and does NOT email the chain again (no spam).
func TestSpec007_RepeatRequestDoesNotRenotify(t *testing.T) {
h := newRecoveryHarness(t)
if rec := h.access(h.team.email); rec.Code != http.StatusOK {
t.Fatalf("first POST /access = %d, want 200", rec.Code)
}
first := len(h.mailer.Sent())
if first == 0 {
t.Fatal("the first request sent no notification")
}
if rec := h.access(h.team.email); rec.Code != http.StatusOK {
t.Fatalf("second POST /access = %d, want 200", rec.Code)
}
if got := len(h.mailer.Sent()); got != first {
t.Errorf("a repeated request re-notified the chain: %d messages, want %d", got, first)
}
}
// TestSpec007_SecondDecisionRefused: the request is claimed atomically — a
// second approve/deny is refused and does not rotate a second time.
func TestSpec007_SecondDecisionRefused(t *testing.T) {
h := newRecoveryHarness(t)
req := h.openRequest(h.team)
if rec := h.decide("approve", h.manager, req.ID); rec.Code != http.StatusOK {
t.Fatalf("first approve = %d, want 200 (body %q)", rec.Code, rec.Body.String())
}
if rec := h.decide("approve", h.director, req.ID); rec.Code != http.StatusConflict {
t.Errorf("second approve = %d, want 409 (already decided)", rec.Code)
}
// The user has exactly one live (pending) identity — no double rotation.
var n int
if err := h.db.QueryRow(
`SELECT count(*) FROM identities WHERE user_id = ? AND status <> 'revoked'`, h.team.userID,
).Scan(&n); err != nil {
t.Fatalf("count live identities: %v", err)
}
if n != 1 {
t.Errorf("live identities = %d, want exactly 1 (no double rotation)", n)
}
}
// TestSpec007_ExpiredRequestCannotBeApproved: a request past the ~72h window
// is expired and refused (SPEC-007 §4).
func TestSpec007_ExpiredRequestCannotBeApproved(t *testing.T) {
h := newRecoveryHarness(t)
req := h.openRequest(h.team)
// Age the request well past the TTL.
past := store.FormatTime(time.Now().Add(-100 * time.Hour))
if _, err := h.db.Exec(`UPDATE recovery_requests SET requested_at = ? WHERE request_id = ?`, past, req.ID); err != nil {
t.Fatalf("age request: %v", err)
}
rec := h.decide("approve", h.manager, req.ID)
if rec.Code != http.StatusGone {
t.Errorf("approve an expired request = %d, want 410", rec.Code)
}
// The identity was NOT rotated.
idn, err := h.ids.ByGUID(h.ctx, h.team.guid)
if err != nil || idn.Status != auth.StatusActive {
t.Errorf("identity after an expired approval = %+v, want still active", idn)
}
stored, err := h.requests.Get(h.ctx, req.ID)
if err != nil || stored.Status != recovery.StatusExpired {
t.Errorf("request = %+v, want expired", stored)
}
}
// TestSpec007DenyDoesNotRotate: a denial leaves the requester's identity intact.
func TestSpec007DenyDoesNotRotate(t *testing.T) {
h := newRecoveryHarness(t)
req := h.openRequest(h.team)
if rec := h.decide("deny", h.director, req.ID); rec.Code != http.StatusOK {
t.Fatalf("deny = %d, want 200", rec.Code)
}
idn, err := h.ids.ByGUID(h.ctx, h.team.guid)
if err != nil || idn.Status != auth.StatusActive {
t.Errorf("identity after a denial = %+v, want still active", idn)
}
done, err := h.requests.Get(h.ctx, req.ID)
if err != nil || done.Status != recovery.StatusDenied {
t.Errorf("request = %+v, want denied", done)
}
}

View file

@ -0,0 +1,235 @@
package server_test
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"sync"
"testing"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/config"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/httpx"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/module"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/server"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users"
// Discovery (D-028): blank-importing the aggregator runs the module
// directories' init(), so module.Load sees the real, discovered set.
_ "git.lohmar.co.uk/cclohmar/NextMyDay/internal/modules"
)
// doorAudit records audit action names in memory (AC-6).
type doorAudit struct {
mu sync.Mutex
actions []string
}
func (a *doorAudit) Record(_ context.Context, e audit.Event) {
a.mu.Lock()
defer a.mu.Unlock()
a.actions = append(a.actions, e.Action)
}
func (a *doorAudit) has(action string) bool {
a.mu.Lock()
defer a.mu.Unlock()
for _, x := range a.actions {
if x == action {
return true
}
}
return false
}
// testModule is a minimal OPTIONAL module (a stand-in for a future feature such
// as `expense`) used to exercise enable/disable through the door.
type testModule struct{ name string }
func (m *testModule) Name() string { return m.name }
func (m *testModule) Required() bool { return false }
func (m *testModule) Kind() string { return "domain" }
func (m *testModule) Migrations() []module.Migration {
return nil
}
func (m *testModule) Routes() []module.Route {
return []module.Route{{Function: "index", Handler: func(w http.ResponseWriter, _ *http.Request, _ *server.RequestContext) error {
w.WriteHeader(http.StatusOK)
return nil
}, Default: true}}
}
func (m *testModule) Actions() []module.Action { return nil }
func (m *testModule) Register(r *module.Registry) error { return r.Add(m) }
func (m *testModule) Start(context.Context) error { return nil }
// consoleGate is the admin-only role gate the entrypoint installs (SPEC-008 §7).
func consoleGate() *server.Tiers {
return server.NewTiers().
Require(module.ConsoleModule, module.FunctionModules, users.RoleAdmin).
Require(module.ConsoleModule, module.FunctionToggle, users.RoleAdmin)
}
// buildConsole mounts a Modules page (with a writable temp config) onto reg.
func buildConsole(t *testing.T, mods *module.Registry, reg *server.MapRegistry, aud audit.Audit) *config.Config {
t.Helper()
cfg := &config.Config{
Server: config.Server{Listen: "127.0.0.1:8080"},
Database: config.Database{Path: "test.db"},
Modules: map[string]bool{},
}
page, err := module.NewPage(module.PageDeps{
Registry: mods,
Config: cfg,
ConfigPath: filepath.Join(t.TempDir(), config.FileName),
Audit: aud,
})
if err != nil {
t.Fatalf("module.NewPage: %v", err)
}
page.Register(reg)
return cfg
}
// serveForm issues a urlencoded POST against the door.
func serveForm(h http.Handler, target string, form url.Values, cookies ...*http.Cookie) *httptest.ResponseRecorder {
req := httptest.NewRequest("POST", target, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for _, c := range cookies {
req.AddCookie(c)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
// TestSpec008_DoorDispatchesToDiscoveredHome is the definition-of-done proof:
// the discovered `home` module mounts into the door and the door dispatches
// /{GUID} to its dashboard.
func TestSpec008_DoorDispatchesToDiscoveredHome(t *testing.T) {
mods, err := module.Load(nil)
if err != nil {
t.Fatalf("module.Load: %v", err)
}
reg := server.NewMapRegistry()
mods.Mount(reg)
h := newHarness(t, func(d *server.Deps) { d.Registry = reg })
rec := request(h.srv, "GET", "/"+h.guid, h.cookie())
if rec.Code != http.StatusOK {
t.Fatalf("GET /{guid} = %d, want 200 (body %q)", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "Your day, at a glance.") {
t.Errorf("the door did not dispatch to the discovered home module:\n%s", rec.Body.String())
}
// /{GUID}/home reaches the same module default (SPEC-002 §4.4).
rec = request(h.srv, "GET", "/"+h.guid+"/home", h.cookie())
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Your day, at a glance.") {
t.Errorf("GET /{guid}/home did not reach the home module: %d %q", rec.Code, rec.Body.String())
}
}
// TestSpec008_ModulesPageIsAdminOnly: the core Modules page and its toggle are
// gated to the admin by the door's role gate (SPEC-008 §7, D-038/AC-4).
func TestSpec008_ModulesPageIsAdminOnly(t *testing.T) {
mods, err := module.Load(nil)
if err != nil {
t.Fatalf("module.Load: %v", err)
}
reg := server.NewMapRegistry()
mods.Mount(reg)
pageCfg := buildConsole(t, mods, reg, nil)
h := newHarness(t, func(d *server.Deps) {
d.Registry = reg
d.RoleGate = consoleGate()
})
// The admin may open it.
rec := request(h.srv, "GET", "/"+h.guid+"/core/modules", h.cookie())
if rec.Code != http.StatusOK {
t.Fatalf("admin GET /{guid}/core/modules = %d, want 200 (body %q)", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "home") {
t.Errorf("the Modules page does not list the discovered modules:\n%s", rec.Body.String())
}
// A team member may not (403).
teamID, err := h.users.Create(h.ctx, h.adminID, "worker@example.com", "Worker", users.RoleTeam)
if err != nil {
t.Fatalf("create team: %v", err)
}
idTeam, guidTeam, err := h.ids.Mint(h.ctx, teamID)
if err != nil {
t.Fatalf("Mint team: %v", err)
}
if err := h.ids.Activate(h.ctx, idTeam); err != nil {
t.Fatalf("Activate team: %v", err)
}
sess, err := h.sessions.Mint(h.ctx, idTeam, "127.0.0.1", "test")
if err != nil {
t.Fatalf("session Mint team: %v", err)
}
rec = request(h.srv, "GET", "/"+guidTeam+"/core/modules",
&http.Cookie{Name: httpx.SessionCookieName, Value: sess.ID})
if rec.Code != http.StatusForbidden {
t.Errorf("team GET /{guid}/core/modules = %d, want 403 (admin only)", rec.Code)
}
// A required module cannot be disabled, even by the admin (AC-4).
form := url.Values{
"module": {"home"},
"enabled": {"false"},
"csrf": {h.csrf.Token(h.session.ID)},
}
if rec := serveForm(h.srv, "/"+h.guid+"?do=core&action=modules.toggle", form, h.cookie()); rec.Code != http.StatusForbidden {
t.Errorf("admin disabling a required module = %d, want 403 (AC-4)", rec.Code)
}
if pageCfg.Modules["home"] {
t.Error("a required module was written as disabled (AC-4)")
}
}
// TestSpec008_ModulesPageToggleAudited: an admin toggling an optional module
// writes the config and is audited (D-035, AC-6).
func TestSpec008_ModulesPageToggleAudited(t *testing.T) {
mods, err := module.Load(nil)
if err != nil {
t.Fatalf("module.Load: %v", err)
}
if err := mods.Add(&testModule{name: "expense"}); err != nil {
t.Fatalf("add expense: %v", err)
}
reg := server.NewMapRegistry()
mods.Mount(reg)
rec := &doorAudit{}
pageCfg := buildConsole(t, mods, reg, rec)
h := newHarness(t, func(d *server.Deps) {
d.Registry = reg
d.RoleGate = consoleGate()
})
form := url.Values{
"module": {"expense"},
"enabled": {"false"},
"csrf": {h.csrf.Token(h.session.ID)},
}
res := serveForm(h.srv, "/"+h.guid+"?do=core&action=modules.toggle", form, h.cookie())
if res.Code != http.StatusOK {
t.Fatalf("toggle = %d, want 200 (body %q)", res.Code, res.Body.String())
}
if pageCfg.Modules["expense"] {
t.Error("the config was not updated for the optional module")
}
if !rec.has("module.disable") {
t.Errorf("the toggle was not audited (AC-6): %v", rec.actions)
}
}

View file

@ -0,0 +1,475 @@
package server_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"regexp"
"strings"
"testing"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/module"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/server"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/web"
_ "git.lohmar.co.uk/cclohmar/NextMyDay/internal/modules"
)
// ── SPEC-009 · the web layer & PWA ───────────────────────────────────────────
// staticRegistry is the door registry as the entrypoint wires it for the
// reserved workflows (SPEC-009 §3): `home` plus the embedded `static` assets.
func staticRegistry() *server.MapRegistry {
reg := server.NewMapRegistry()
reg.RegisterCore("home", server.Home)
reg.RegisterCore(server.StaticWorkflow, server.StaticHandler())
return reg
}
// ── AC-3: no inline scripts anywhere ─────────────────────────────────────────
var (
htmlScriptTag = regexp.MustCompile(`(?is)<script\b[^>]*>`)
htmlScriptSrc = regexp.MustCompile(`(?is)<script\b[^>]*\bsrc\s*=\s*["'][^"']*["']`)
htmlInlineHandler = regexp.MustCompile(`(?is)\son(click|change|input|submit|load|error)\s*=`)
htmlJavascriptURL = regexp.MustCompile(`(?is)javascript:`)
)
// assertNoInlineScripts fails when a rendered page would force the CSP
// `script-src 'self'` (SPEC-003 §4) to be weakened: an inline <script>, an
// inline event handler, or a javascript: URL (AC-3).
func assertNoInlineScripts(t *testing.T, screen, body string) {
t.Helper()
for _, tag := range htmlScriptTag.FindAllString(body, -1) {
if !strings.Contains(strings.ToLower(tag), "src=") {
t.Errorf("%s emitted an inline <script> (AC-3): %q", screen, tag)
}
}
for _, tag := range htmlScriptTag.FindAllString(body, -1) {
if !htmlScriptSrc.MatchString(tag) {
t.Errorf("%s emitted a <script> without a same-origin src (AC-3): %q", screen, tag)
}
}
if m := htmlInlineHandler.FindString(body); m != "" {
t.Errorf("%s emitted an inline event handler %q (AC-3)", screen, m)
}
if htmlJavascriptURL.MatchString(body) {
t.Errorf("%s emitted a javascript: URL (AC-3)", screen)
}
// Every script reference is SAME-ORIGIN (the assets live under /static/).
for _, tag := range htmlScriptTag.FindAllString(body, -1) {
if !strings.Contains(tag, `src="/`) {
t.Errorf("%s references a script that is not same-origin (AC-3): %q", screen, tag)
}
}
}
// TestAC3_NoInlineScriptsOnEveryScreen renders every human-facing screen
// through the door and asserts the CSP can stay strict (AC-3).
func TestAC3_NoInlineScriptsOnEveryScreen(t *testing.T) {
h := newHarness(t)
_, _, pendingGUID := h.pendingUser(t, "ac3-web@example.com")
// A door wired as main wires it: the reserved `static` workflow plus the
// discovered modules (home) and the core Modules page.
mods, err := module.Load(nil)
if err != nil {
t.Fatalf("module.Load: %v", err)
}
reg := staticRegistry()
mods.Mount(reg)
buildConsole(t, mods, reg, nil)
web.SetNav(mods)
t.Cleanup(func() { web.SetNav(nil) })
dh := newHarness(t, func(d *server.Deps) {
d.Registry = reg
d.RoleGate = consoleGate()
})
// The bootstrap wizard, in setup mode.
sh := newSetupHarness(t)
screens := []struct {
name string
srv http.Handler
target string
cookie *http.Cookie
}{
{"login", h.srv, "/" + h.guid, nil},
{"enrollment", h.srv, "/" + pendingGUID, nil},
{"the passkey nudge", h.srv, "/" + h.guid + "/auth/passkey", h.cookie()},
{"the /home landing", h.srv, "/home", nil},
{"the recovery entry", h.srv, "/access", nil},
{"the dashboard", dh.srv, "/" + dh.guid, dh.cookie()},
{"the Modules page", dh.srv, "/" + dh.guid + "/core/modules", dh.cookie()},
{"the /setup wizard", sh.srv, "/setup", nil},
}
for _, screen := range screens {
var rec *httptest.ResponseRecorder
if screen.cookie == nil {
rec = request(screen.srv, "GET", screen.target)
} else {
rec = request(screen.srv, "GET", screen.target, screen.cookie)
}
if rec.Code != http.StatusOK {
t.Errorf("%s = %d, want 200", screen.name, rec.Code)
continue
}
assertNoInlineScripts(t, screen.name, rec.Body.String())
}
// The ceremonies must publish their data-* contract, or no human can
// complete one in a browser (SPEC-009 §5).
if !strings.Contains(dhBody(t, h, "/"+pendingGUID), `data-webauthn="enroll"`) {
t.Error("the enrollment screen carries no enroll ceremony contract (SPEC-009 §5)")
}
if !strings.Contains(dhBody(t, h, "/"+h.guid), `data-webauthn="login"`) {
t.Error("the login screen carries no login ceremony contract (SPEC-009 §5)")
}
if body := dhBody(t, h, "/"+h.guid+"/auth/passkey", h.cookie()); !strings.Contains(body, `data-webauthn="passkey"`) {
t.Error("the nudge screen carries no passkey ceremony contract (SPEC-009 §5)")
}
}
// dhBody issues a GET against a door and returns the body (test sugar).
func dhBody(t *testing.T, h *harness, target string, cookies ...*http.Cookie) string {
t.Helper()
rec := request(h.srv, "GET", target, cookies...)
if rec.Code != http.StatusOK {
t.Fatalf("GET %s = %d, want 200", target, rec.Code)
}
return rec.Body.String()
}
// TestAC3_AuthFormsCarryTheirCSRF: the anonymous ceremonies and the
// session-gated nudge submit a token the door accepts — the contract the
// vanilla client relies on (A15/A8).
func TestAC3_AuthFormsCarryTheirCSRF(t *testing.T) {
h := newHarness(t)
// Anonymous: the pre-session double-submit token, in the form AND in the
// shell's meta (which webauthn.js reads).
rec := request(h.srv, "GET", "/"+h.guid)
body := rec.Body.String()
if !strings.Contains(body, `name="csrf"`) {
t.Error("the login form carries no csrf field")
}
if !strings.Contains(body, `meta name="csrf-token"`) {
t.Error("the login screen exposes no csrf-token meta for the WebAuthn client")
}
if !hasCookie(rec, "myday_csrf") {
t.Error("the login screen issued no pre-session CSRF cookie (A15)")
}
// Session-gated: the nudge's token is the session-bound one (A15 does not
// apply) and it is exposed to the client the same way.
nudge := request(h.srv, "GET", "/"+h.guid+"/auth/passkey", h.cookie())
if nudge.Code != http.StatusOK {
t.Fatalf("the nudge screen = %d, want 200", nudge.Code)
}
if !strings.Contains(nudge.Body.String(), h.csrf.Token(h.session.ID)) {
t.Error("the nudge screen does not carry the session-bound CSRF token")
}
if !strings.Contains(nudge.Body.String(), "passkey.add") {
t.Error("the nudge screen does not offer passkey.add (SPEC-006 §8)")
}
}
// ── AC-3/§4: the nav is driven by the module registry (D-038) ────────────────
// TestSpec009_NavComesFromTheRegistry: the shell renders the ENABLED modules —
// and a disabled module's entry disappears (D-038: disabling hides nav).
func TestSpec009_NavComesFromTheRegistry(t *testing.T) {
mods, err := module.Load(nil)
if err != nil {
t.Fatalf("module.Load: %v", err)
}
if err := mods.Add(&testModule{name: "expense"}); err != nil {
t.Fatalf("add expense: %v", err)
}
reg := staticRegistry()
mods.Mount(reg)
web.SetNav(mods)
t.Cleanup(func() { web.SetNav(nil) })
h := newHarness(t, func(d *server.Deps) { d.Registry = reg })
body := dhBody(t, h, "/"+h.guid, h.cookie())
if !strings.Contains(body, `href="/`+h.guid+`/home"`) {
t.Errorf("the nav is missing the discovered `home` module:\n%s", body)
}
if !strings.Contains(body, `href="/`+h.guid+`/expense"`) {
t.Errorf("the nav is missing the discovered `expense` module:\n%s", body)
}
// Disable it (D-038): its nav entry must be gone, its data untouched.
if err := mods.SetEnabled("expense", false); err != nil {
t.Fatalf("SetEnabled: %v", err)
}
body = dhBody(t, h, "/"+h.guid, h.cookie())
if strings.Contains(body, `href="/`+h.guid+`/expense"`) {
t.Errorf("a disabled module is still in the nav (D-038):\n%s", body)
}
}
// The module view contract (SPEC-009 §3): a module renders INSIDE the shell —
// it supplies content, the core owns the document, header, nav and flash.
func TestSpec009_ModuleRendersInsideTheShell(t *testing.T) {
mods, err := module.Load(nil)
if err != nil {
t.Fatalf("module.Load: %v", err)
}
reg := staticRegistry()
mods.Mount(reg)
web.SetNav(mods)
t.Cleanup(func() { web.SetNav(nil) })
h := newHarness(t, func(d *server.Deps) { d.Registry = reg })
body := dhBody(t, h, "/"+h.guid, h.cookie())
for _, want := range []string{
"<!doctype html>", // the shell owns the document
"Your day, at a glance.", // the module's content
`href="/static/app.css"`, // the shell's stylesheet
`href="/static/manifest.webmanifest"`,
"<nav",
} {
if !strings.Contains(body, want) {
t.Errorf("the dashboard is missing %q — a module must render inside the shell:\n%s", want, body)
}
}
}
// ── §3: the reserved `static` workflow ───────────────────────────────────────
// TestSpec009_StaticWorkflowServesTheEmbeddedAssets exercises the reserved
// `static` workflow through the door (D-004).
func TestSpec009_StaticWorkflowServesTheEmbeddedAssets(t *testing.T) {
h := newHarness(t, func(d *server.Deps) { d.Registry = staticRegistry() })
cases := []struct {
path string
status int
ctype string
contains string
}{
{"/static/app.css", http.StatusOK, "text/css", ".app-header"},
{"/static/webauthn.js", http.StatusOK, "text/javascript", "navigator.credentials.create"},
{"/static/app.js", http.StatusOK, "text/javascript", "serviceWorker"},
{"/static/sw.js", http.StatusOK, "text/javascript", "isCacheable"},
{"/static/manifest.webmanifest", http.StatusOK, "application/manifest+json", "start_url"},
{"/static/icon-192.png", http.StatusOK, "image/png", ""},
{"/static/icon-512.png", http.StatusOK, "image/png", ""},
{"/static/nope.css", http.StatusNotFound, "text/plain", ""},
{"/static", http.StatusNotFound, "text/plain", ""},
}
for _, tc := range cases {
t.Run(tc.path, func(t *testing.T) {
rec := request(h.srv, "GET", tc.path)
if rec.Code != tc.status {
t.Fatalf("GET %s = %d, want %d", tc.path, rec.Code, tc.status)
}
if got := rec.Header().Get("Content-Type"); !strings.HasPrefix(got, tc.ctype) {
t.Errorf("GET %s Content-Type = %q, want %q", tc.path, got, tc.ctype)
}
if tc.contains != "" && !strings.Contains(rec.Body.String(), tc.contains) {
t.Errorf("GET %s body is missing %q", tc.path, tc.contains)
}
})
}
}
// TestSpec009_StaticIsReadOnlyAndTraversalSafe: the handler is GET/HEAD only,
// and a traversal cannot escape the embedded directory.
func TestSpec009_StaticIsReadOnlyAndTraversalSafe(t *testing.T) {
h := newHarness(t, func(d *server.Deps) { d.Registry = staticRegistry() })
if rec := request(h.srv, "POST", "/static/app.css"); rec.Code != http.StatusMethodNotAllowed {
t.Errorf("POST /static/app.css = %d, want 405", rec.Code)
}
if rec := request(h.srv, "HEAD", "/static/app.css"); rec.Code != http.StatusOK {
t.Errorf("HEAD /static/app.css = %d, want 200", rec.Code)
}
for _, path := range []string{
"/static/../config.yaml",
"/static/..%2f..%2fconfig.yaml",
"/static/./app.css",
} {
rec := request(h.srv, "GET", path)
if rec.Code == http.StatusOK {
t.Errorf("GET %s = 200, want a refusal (traversal must not escape the assets)", path)
}
}
}
// TestSpec009_ServiceWorkerAllowedHeader: a worker served from /static/ is
// registered with scope "/" (the manifest's start_url is "/", D-011), which the
// browser refuses unless the response grants it. Without this header the
// registration silently fails and the slice's PWA half never takes effect.
func TestSpec009_ServiceWorkerAllowedHeader(t *testing.T) {
h := newHarness(t, func(d *server.Deps) { d.Registry = staticRegistry() })
rec := request(h.srv, "GET", "/static/sw.js")
if got := rec.Header().Get("Service-Worker-Allowed"); got != "/" {
t.Errorf("Service-Worker-Allowed on sw.js = %q, want %q", got, "/")
}
// Every other asset is served plainly: the header is the worker's alone.
for _, path := range []string{"/static/app.js", "/static/app.css", "/static/manifest.webmanifest"} {
if got := request(h.srv, "GET", path).Header().Get("Service-Worker-Allowed"); got != "" {
t.Errorf("Service-Worker-Allowed leaked onto %s: %q", path, got)
}
}
}
// TestSpec009_StaticIsServedInSetupMode: the bootstrap wizard has a stylesheet
// before the app exists — `static` is served in setup mode (SPEC-005 §3).
func TestSpec009_StaticIsServedInSetupMode(t *testing.T) {
h := newHarness(t, func(d *server.Deps) {
d.Registry = staticRegistry()
d.SetupMode = func() bool { return true }
})
if rec := request(h.srv, "GET", "/static/app.css"); rec.Code != http.StatusOK {
t.Errorf("GET /static/app.css in setup mode = %d, want 200 (SPEC-005 §3)", rec.Code)
}
if rec := request(h.srv, "GET", "/home"); rec.Code != http.StatusNotFound {
t.Errorf("GET /home in setup mode = %d, want 404 (SPEC-005 §3)", rec.Code)
}
if rec := request(h.srv, "GET", "/"+h.guid); rec.Code != http.StatusNotFound {
t.Errorf("a GUID branch in setup mode = %d, want 404 (SPEC-005 §3)", rec.Code)
}
}
// TestSpec009_WizardRendersInsideTheShell: the /setup wizard is restyled into
// the shell with the behaviour unchanged (SPEC-009 §7).
func TestSpec009_WizardRendersInsideTheShell(t *testing.T) {
h := newSetupHarness(t)
rec := request(h.srv, "GET", "/setup")
if rec.Code != http.StatusOK {
t.Fatalf("GET /setup = %d, want 200", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{
"<!doctype html>",
"MyDay setup",
`href="/static/app.css"`,
"domain.save",
} {
if !strings.Contains(body, want) {
t.Errorf("the wizard page is missing %q:\n%s", want, body)
}
}
assertNoInlineScripts(t, "the /setup wizard", body)
// A guest screen: no identity, so no nav.
if strings.Contains(body, "<nav") {
t.Error("the wizard rendered a nav — there is no identity in setup mode")
}
}
// ── AC-4: the manifest, served ───────────────────────────────────────────────
// TestAC4_ManifestServedAndValid: the door serves the manifest with the right
// media type, and it installs at "/" (D-011).
func TestAC4_ManifestServedAndValid(t *testing.T) {
h := newHarness(t, func(d *server.Deps) { d.Registry = staticRegistry() })
rec := request(h.srv, "GET", "/static/manifest.webmanifest")
if rec.Code != http.StatusOK {
t.Fatalf("GET the manifest = %d, want 200", rec.Code)
}
if got := rec.Header().Get("Content-Type"); !strings.HasPrefix(got, "application/manifest+json") {
t.Errorf("manifest Content-Type = %q, want application/manifest+json", got)
}
var m struct {
Name string `json:"name"`
StartURL string `json:"start_url"`
Icons []struct {
Sizes string `json:"sizes"`
} `json:"icons"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &m); err != nil {
t.Fatalf("the served manifest is not valid JSON: %v", err)
}
if m.StartURL != "/" {
t.Errorf("served start_url = %q, want / (D-011)", m.StartURL)
}
if m.Name == "" || len(m.Icons) < 2 {
t.Errorf("the served manifest is incomplete: %+v", m)
}
}
// ── AC-5: the service worker, served (A5) ────────────────────────────────────
// TestAC5_ServiceWorkerServedAndNeverCachesAuthenticated: the worker the door
// serves declares only /static/ as cacheable, and the policy rejects an
// authenticated response and a /{GUID} page.
func TestAC5_ServiceWorkerServedAndNeverCachesAuthenticated(t *testing.T) {
h := newHarness(t, func(d *server.Deps) { d.Registry = staticRegistry() })
rec := request(h.srv, "GET", "/static/sw.js")
if rec.Code != http.StatusOK {
t.Fatalf("GET /static/sw.js = %d, want 200", rec.Code)
}
if got := rec.Header().Get("Content-Type"); !strings.HasPrefix(got, "text/javascript") {
t.Errorf("sw.js Content-Type = %q, want text/javascript", got)
}
sw := rec.Body.String()
if !strings.Contains(sw, `var CACHEABLE_PREFIXES = ["/static/"];`) {
t.Errorf("the served worker's allowlist is not the static namespace alone (A5):\n%s", sw)
}
if !strings.Contains(sw, "if (!isCacheable(event.request)) {") {
t.Error("the served worker does not refuse non-cacheable requests before any cache access (A5)")
}
// The policy, applied to THIS instance's paths.
for _, path := range []string{
"/" + h.guid,
"/" + h.guid + "/home",
"/" + h.guid + "/core/modules",
"/access",
"/setup",
"/home",
"/",
} {
if web.CacheablePath("GET", path) {
t.Errorf("the service worker would cache %q (A5)", path)
}
}
for _, path := range []string{"/static/app.css", "/static/webauthn.js", "/static/sw.js"} {
if !web.CacheablePath("GET", path) {
t.Errorf("the service worker would not cache the static asset %q", path)
}
}
}
// ── AC-6: the collision guard, through the loader main uses ──────────────────
// TestAC6_CollidingModuleFailsTheLoadTheEntrypointUses proves Step 0 on the
// real load path: a discovered module claiming a core dispatch name makes
// module.Load fail, so the process refuses to start instead of silently
// dropping (or shadowing) the module (SPEC-008 §9 D-9).
func TestAC6_CollidingModuleFailsTheLoadTheEntrypointUses(t *testing.T) {
if _, err := module.LoadModules([]module.Module{&collidingModule{}}, nil); err == nil {
t.Fatal("a module colliding with a core dispatch name loaded without error (AC-6)")
}
// The discovered set the binary actually ships is unaffected.
if _, err := module.Load(nil); err != nil {
t.Fatalf("the real discovered set failed to load: %v", err)
}
}
// collidingModule is a discovered module claiming the core `auth` namespace.
type collidingModule struct{}
func (*collidingModule) Name() string { return "auth" }
func (*collidingModule) Required() bool { return false }
func (*collidingModule) Migrations() []module.Migration { return nil }
func (*collidingModule) Routes() []module.Route { return nil }
func (*collidingModule) Actions() []module.Action { return nil }
func (*collidingModule) Start(context.Context) error { return nil }
func (*collidingModule) Register(r *module.Registry) error { return r.Add(&collidingModule{}) }

View file

@ -0,0 +1,127 @@
package server
import (
"io/fs"
"net/http"
"path"
"strings"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/web"
)
// StaticWorkflow is the reserved level-1 workflow that serves the embedded
// assets (D-004: `static` is reserved from the very first commit). It is core
// owned — it carries no module and no identity — and it is served in setup mode
// too, so the bootstrap wizard has a stylesheet (SPEC-005 §3).
const StaticWorkflow = "static"
// StaticCacheControl is the cache lifetime for an asset. The assets are
// versionless static files, so the window is deliberately short.
const StaticCacheControl = "public, max-age=300"
// StaticWorkerName is the one asset that needs a wider service-worker scope.
// A worker script served from /static/sw.js may only control /static/ unless
// the response says otherwise, and MyDay registers it with scope "/" (the
// manifest's start_url is "/", D-011). Service-Worker-Allowed grants that, and
// the worker's own cache policy still refuses anything that is not a static
// asset (A5).
const StaticWorkerName = "sw.js"
// StaticWorkerAllowed is the scope granted to the worker script.
const StaticWorkerAllowed = "/"
// StaticHandler serves the assets embedded by internal/core/web under
// /static/{asset} (SPEC-009 §3). The door has already classified the reserved
// `static` workflow and handed off the path remainder; the handler never parses
// a raw path (the rule of the door — rc.Page is the joined remainder).
func StaticHandler() Handler {
return func(w http.ResponseWriter, r *http.Request, rc *RequestContext) error {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", "GET, HEAD")
writeStaticError(w, http.StatusMethodNotAllowed, "method not allowed")
return nil
}
name := strings.TrimSpace(rc.Page)
if !validStaticName(name) {
writeStaticError(w, http.StatusNotFound, "not found")
return nil
}
data, err := fs.ReadFile(web.StaticFS, web.StaticDir+"/"+name)
if err != nil {
// Absent asset, or a name the embedded FS refuses (it rejects any
// path containing "..") — a 404 either way, never a directory list.
writeStaticError(w, http.StatusNotFound, "not found")
return nil
}
h := w.Header()
h.Set("Content-Type", staticContentType(name))
h.Set("Cache-Control", StaticCacheControl)
if name == StaticWorkerName {
// Without this the browser refuses `scope: "/"` on a worker served
// from a sub-directory (SecurityError) and the registration fails.
h.Set("Service-Worker-Allowed", StaticWorkerAllowed)
}
w.WriteHeader(http.StatusOK)
if r.Method == http.MethodHead {
return nil
}
_, err = w.Write(data)
return err
}
}
// validStaticName accepts a clean, relative asset path only: no absolute path,
// no traversal, no empty segment. The embedded FS is already traversal-safe;
// this is defence in depth and keeps the 404 shape honest.
func validStaticName(name string) bool {
if name == "" || strings.HasPrefix(name, "/") || strings.ContainsAny(name, "\\\x00") {
return false
}
if name != path.Clean(name) {
return false // catches "..", "./x", "a//b", "a/"
}
for _, segment := range strings.Split(name, "/") {
if segment == "" || segment == "." || segment == ".." {
return false
}
}
return true
}
// staticContentType maps an asset's extension to its media type. The door
// always sends X-Content-Type-Options: nosniff, so an unknown extension is
// served as opaque bytes rather than something a browser might sniff.
func staticContentType(name string) string {
switch strings.ToLower(path.Ext(name)) {
case ".css":
return "text/css; charset=utf-8"
case ".js", ".mjs":
return "text/javascript; charset=utf-8"
case ".json":
return "application/json; charset=utf-8"
case ".webmanifest":
return "application/manifest+json"
case ".png":
return "image/png"
case ".svg":
return "image/svg+xml"
case ".ico":
return "image/vnd.microsoft.icon"
case ".txt":
return "text/plain; charset=utf-8"
case ".woff2":
return "font/woff2"
default:
return "application/octet-stream"
}
}
// writeStaticError writes a terse plain-text error response (matching the
// door's).
func writeStaticError(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(status)
_, _ = w.Write([]byte(msg + "\n"))
}

View file

@ -0,0 +1,9 @@
package store
import "embed"
// coreMigrations holds MyDay's core schema, embedded into the binary so a
// single-file deploy needs no external SQL (D-003, D-028).
//
//go:embed migrations/*.sql
var coreMigrations embed.FS

View file

@ -0,0 +1,144 @@
package store
import (
"database/sql"
"fmt"
"io/fs"
"path"
"sort"
"strings"
"time"
)
// Migration is one ordered, named SQL migration — one file = one version. A
// migration may contain multiple statements; the driver executes the whole
// script atomically inside a transaction.
type Migration struct {
Version string // stable key, e.g. "0001_init" (the file name minus .sql)
SQL string
}
// LoadMigrations reads every <dir>/*.sql in fsys, sorted by file name so the
// order is deterministic (D-028 guardrail: "never map iteration").
func LoadMigrations(fsys fs.FS, dir string) ([]Migration, error) {
pattern := path.Join(dir, "*.sql")
names, err := fs.Glob(fsys, pattern)
if err != nil {
return nil, fmt.Errorf("store: glob %q: %w", pattern, err)
}
sort.Strings(names)
out := make([]Migration, 0, len(names))
for _, name := range names {
b, err := fs.ReadFile(fsys, name)
if err != nil {
return nil, fmt.Errorf("store: read migration %q: %w", name, err)
}
version := strings.TrimSuffix(path.Base(name), ".sql")
out = append(out, Migration{Version: version, SQL: string(b)})
}
return out, nil
}
// Migrate applies the core schema to the database. It is idempotent: already
// applied versions are skipped.
func (db *DB) Migrate() error {
migrations, err := LoadMigrations(coreMigrations, "migrations")
if err != nil {
return err
}
return RunMigrations(db.DB, migrations)
}
// RunMigrations applies each migration not yet recorded, in order, each inside
// its own transaction. It is the runner the core owns; modules will call it
// with their own embedded migration sets later (D-028).
func RunMigrations(db *sql.DB, migrations []Migration) error {
if err := ensureMigrationsTable(db); err != nil {
return err
}
applied, err := appliedVersions(db)
if err != nil {
return err
}
for _, m := range migrations {
if applied[m.Version] {
continue
}
if err := applyMigration(db, m); err != nil {
return err
}
}
return nil
}
// ensureMigrationsTable creates the bookkeeping table if it does not exist.
func ensureMigrationsTable(db *sql.DB) error {
const ddl = `CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at TEXT NOT NULL
)`
if _, err := db.Exec(ddl); err != nil {
return fmt.Errorf("store: create schema_migrations: %w", err)
}
return nil
}
// appliedVersions returns the set of versions already recorded.
func appliedVersions(db *sql.DB) (map[string]bool, error) {
rows, err := db.Query(`SELECT version FROM schema_migrations`)
if err != nil {
return nil, fmt.Errorf("store: read schema_migrations: %w", err)
}
defer rows.Close()
applied := map[string]bool{}
for rows.Next() {
var v string
if err := rows.Scan(&v); err != nil {
return nil, fmt.Errorf("store: scan schema_migrations: %w", err)
}
applied[v] = true
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: iterate schema_migrations: %w", err)
}
return applied, nil
}
// applyMigration runs one migration and records it in a single transaction —
// SQLite DDL is transactional, so a failure leaves no partial schema.
func applyMigration(db *sql.DB, m Migration) error {
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("store: begin migration %q: %w", m.Version, err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.Exec(m.SQL); err != nil {
return fmt.Errorf("store: apply migration %q: %w", m.Version, err)
}
if _, err := tx.Exec(
`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`,
m.Version, FormatTime(time.Now()),
); err != nil {
return fmt.Errorf("store: record migration %q: %w", m.Version, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("store: commit migration %q: %w", m.Version, err)
}
return nil
}
// FormatTime renders a timestamp in the canonical storage format: RFC 3339,
// UTC, second precision. Fixed width and UTC mean lexicographic order equals
// chronological order, so SQL range comparisons are sound.
func FormatTime(t time.Time) string {
return t.UTC().Truncate(time.Second).Format(time.RFC3339)
}
// ParseTime parses the canonical storage format produced by FormatTime.
func ParseTime(s string) (time.Time, error) {
return time.Parse(time.RFC3339, s)
}

View file

@ -0,0 +1,103 @@
-- 0001_init.sql — SPEC-004 §4: the identity & session schema.
--
-- Storage conventions:
-- * timestamps are TEXT, RFC 3339, always UTC, second precision — so
-- lexicographic comparison equals chronological comparison;
-- * booleans are INTEGER 0/1;
-- * secret material (TOTP) is stored ENCRYPTED (A9) — never plaintext.
-- ── users ───────────────────────────────────────────────────────────────────
CREATE TABLE users (
user_id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('admin','director','manager','team')),
manager_user_id INTEGER REFERENCES users(user_id),
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','disabled')),
created_at TEXT NOT NULL,
-- The chain (D-020/D-034): only the admin sits at the root, so only the
-- admin has a NULL manager.
CHECK ((role = 'admin') = (manager_user_id IS NULL)),
-- user_id 1 IS the top admin (D-013, D-021).
CHECK (user_id <> 1 OR role = 'admin')
);
-- ── identities ──────────────────────────────────────────────────────────────
-- Credentials hang off an identity, never the user; the GUID is the rotating
-- username and is NEVER recycled (D-008) — revoked rows remain as tombstones.
CREATE TABLE identities (
identity_id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(user_id),
guid TEXT NOT NULL UNIQUE,
status TEXT NOT NULL CHECK (status IN ('pending_enrollment','active','revoked')),
created_at TEXT NOT NULL,
enrolled_at TEXT,
revoked_at TEXT
);
-- Invariant (SPEC-004 §4, AC-1): exactly one ACTIVE identity per user.
CREATE UNIQUE INDEX identities_one_active_per_user
ON identities(user_id) WHERE status = 'active';
CREATE INDEX identities_user ON identities(user_id);
-- ── webauthn_credentials ────────────────────────────────────────────────────
CREATE TABLE webauthn_credentials (
credential_id BLOB PRIMARY KEY,
identity_id INTEGER NOT NULL REFERENCES identities(identity_id) ON DELETE CASCADE,
public_key BLOB NOT NULL,
sign_count INTEGER NOT NULL DEFAULT 0,
transports TEXT NOT NULL DEFAULT '',
aaguid BLOB,
created_at TEXT NOT NULL
);
CREATE INDEX webauthn_credentials_identity ON webauthn_credentials(identity_id);
-- ── totp_secrets ────────────────────────────────────────────────────────────
-- A9: secret_encrypted holds ciphertext (nonce || AES-GCM), never plaintext.
CREATE TABLE totp_secrets (
identity_id INTEGER PRIMARY KEY REFERENCES identities(identity_id) ON DELETE CASCADE,
secret_encrypted BLOB NOT NULL,
verified_at TEXT
);
-- ── sessions ────────────────────────────────────────────────────────────────
CREATE TABLE sessions (
session_id TEXT PRIMARY KEY,
identity_id INTEGER NOT NULL REFERENCES identities(identity_id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(user_id),
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
revoked_at TEXT,
client_ip TEXT,
user_agent TEXT
);
CREATE INDEX sessions_identity ON sessions(identity_id);
CREATE INDEX sessions_expires ON sessions(expires_at);
-- ── recovery_requests ───────────────────────────────────────────────────────
CREATE TABLE recovery_requests (
request_id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(user_id),
status TEXT NOT NULL CHECK (status IN ('pending','approved','denied','expired')),
requested_at TEXT NOT NULL,
decided_by INTEGER REFERENCES users(user_id),
decided_at TEXT,
client_ip TEXT
);
CREATE INDEX recovery_requests_user_status ON recovery_requests(user_id, status);
-- ── audit_log ───────────────────────────────────────────────────────────────
-- Append-only security trail (A11). No FK on the actor: history outlives users.
CREATE TABLE audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
actor_user_id INTEGER,
action TEXT NOT NULL,
subject TEXT,
detail TEXT,
client_ip TEXT,
location TEXT,
correlation_id TEXT
);
CREATE INDEX audit_log_at ON audit_log(at);
CREATE INDEX audit_log_action ON audit_log(action);

View file

@ -0,0 +1,7 @@
-- 0002_passkey_opt_out.sql — SPEC-004 §4 / D-041: the passkey-nudge opt-out.
--
-- A user preference, NOT a credential: it hangs off the stable user record so
-- it survives identity rotation (a recovered user is not re-nudged). 0001_init
-- is never edited — migrations are ordered and append-only.
ALTER TABLE users ADD COLUMN passkey_opt_out INTEGER NOT NULL DEFAULT 0;

View file

@ -0,0 +1,99 @@
// Package store owns MyDay's SQLite connection and migration runner
// (PROJECT_PLAN.md §12.1: "the core owns the DB connection and the migration
// runner; each module owns its own tables, queries, and migrations").
//
// It uses the CGO-free pure-Go driver modernc.org/sqlite (D-012), so the
// shipped binary stays static — no mattn/go-sqlite3, no libc dependency — and
// runs with WAL journaling and foreign keys ON.
//
// Spec: docs/specs/spec-004-identity-sessions.md §3–§4.
package store
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"strings"
_ "modernc.org/sqlite" // registers the "sqlite" driver (D-012)
)
// Driver is the SQLite driver name registered by modernc.org/sqlite (D-012).
const Driver = "sqlite"
// DB wraps the standard connection pool. The embedded *sql.DB is what the core
// services (users, auth, audit) consume.
type DB struct {
*sql.DB
path string
}
// Open opens (creating if needed) the SQLite database at path.
//
// The DSN carries the connection pragmas so they apply to every connection the
// pool opens: WAL journaling, foreign keys ON, a busy timeout, and NORMAL
// synchronous (safe with WAL, faster than FULL).
//
// SQLite is a single-writer store, so the pool is pinned to ONE connection:
// this removes lock contention (D-012: "one instance only") and keeps an
// in-memory database coherent across the whole process.
//
// A non-empty ":memory:" (or file:...mode=memory) path opens a private
// in-memory database — used by the unit tests.
func Open(path string) (*DB, error) {
if path == "" {
return nil, fmt.Errorf("store: database path is empty")
}
if !isMemory(path) {
if dir := filepath.Dir(path); dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o750); err != nil {
return nil, fmt.Errorf("store: create data directory %q: %w", dir, err)
}
}
}
db, err := sql.Open(Driver, buildDSN(path))
if err != nil {
return nil, fmt.Errorf("store: open %q: %w", path, err)
}
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
db.SetConnMaxLifetime(0)
if err := db.Ping(); err != nil {
_ = db.Close()
return nil, fmt.Errorf("store: ping %q: %w", path, err)
}
return &DB{DB: db, path: path}, nil
}
// Path returns the database path this connection was opened with.
func (db *DB) Path() string { return db.path }
// isMemory reports whether path names an in-memory database.
func isMemory(path string) bool {
return path == ":memory:" || strings.Contains(path, "mode=memory")
}
// buildDSN assembles the modernc.org/sqlite DSN. The `_pragma` parameters are
// applied on every connection open (D-012: WAL · foreign keys ON).
func buildDSN(path string) string {
pragmas := []string{
"journal_mode(WAL)",
"foreign_keys(1)",
"busy_timeout(5000)",
"synchronous(NORMAL)",
}
var b strings.Builder
if isMemory(path) {
b.WriteString("file::memory:")
} else {
b.WriteString("file:")
b.WriteString(path)
}
b.WriteString("?_pragma=")
b.WriteString(strings.Join(pragmas, "&_pragma="))
return b.String()
}

View file

@ -0,0 +1,139 @@
package store
import (
"path/filepath"
"testing"
"time"
)
// openTestDB opens a fresh temp-file database and applies the core schema.
func openTestDB(t *testing.T) *DB {
t.Helper()
db, err := Open(filepath.Join(t.TempDir(), "myday.db"))
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := db.Migrate(); err != nil {
t.Fatalf("Migrate: %v", err)
}
return db
}
func TestOpenSetsPragmas(t *testing.T) {
db := openTestDB(t)
var journal string
if err := db.QueryRow(`PRAGMA journal_mode`).Scan(&journal); err != nil {
t.Fatalf("PRAGMA journal_mode: %v", err)
}
if journal != "wal" {
t.Errorf("journal_mode = %q, want wal (D-012)", journal)
}
var fk int
if err := db.QueryRow(`PRAGMA foreign_keys`).Scan(&fk); err != nil {
t.Fatalf("PRAGMA foreign_keys: %v", err)
}
if fk != 1 {
t.Errorf("foreign_keys = %d, want 1 (D-012)", fk)
}
}
func TestMigrateIsIdempotentAndCreatesSchema(t *testing.T) {
db := openTestDB(t)
// A second Migrate must be a no-op, not an error.
if err := db.Migrate(); err != nil {
t.Fatalf("second Migrate: %v", err)
}
want := []string{
"users", "identities", "webauthn_credentials",
"totp_secrets", "sessions", "recovery_requests",
"audit_log", "schema_migrations",
}
for _, table := range want {
var name string
err := db.QueryRow(
`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`, table,
).Scan(&name)
if err != nil {
t.Errorf("table %q missing after Migrate: %v", table, err)
}
}
// The one-active-identity partial index is part of the schema (AC-1).
var name string
err := db.QueryRow(
`SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'identities_one_active_per_user'`,
).Scan(&name)
if err != nil {
t.Errorf("partial unique index missing after Migrate: %v", err)
}
}
func TestMigrateForeignKeysEnforced(t *testing.T) {
db := openTestDB(t)
// An identity for a non-existent user must be rejected — foreign keys ON.
_, err := db.Exec(
`INSERT INTO identities (user_id, guid, status, created_at) VALUES (999, 'abc', 'pending_enrollment', '2026-01-01T00:00:00Z')`,
)
if err == nil {
t.Fatal("insert with dangling user_id succeeded, want FK violation")
}
}
func TestOpenInMemory(t *testing.T) {
db, err := Open(":memory:")
if err != nil {
t.Fatalf("Open(:memory:): %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := db.Migrate(); err != nil {
t.Fatalf("Migrate(:memory:): %v", err)
}
if _, err := db.Exec(`INSERT INTO users (email, display_name, role, status, created_at) VALUES ('a@b.c','A','admin','active','2026-01-01T00:00:00Z')`); err != nil {
t.Fatalf("insert into in-memory db: %v", err)
}
}
func TestLoadMigrationsSorted(t *testing.T) {
ms, err := LoadMigrations(coreMigrations, "migrations")
if err != nil {
t.Fatalf("LoadMigrations: %v", err)
}
if len(ms) == 0 {
t.Fatal("no migrations loaded")
}
for i := 1; i < len(ms); i++ {
if ms[i-1].Version >= ms[i].Version {
t.Errorf("migrations not sorted: %q before %q", ms[i-1].Version, ms[i].Version)
}
}
if ms[0].Version != "0001_init" {
t.Errorf("first migration = %q, want 0001_init", ms[0].Version)
}
}
func TestFormatParseTimeRoundTrip(t *testing.T) {
now := time.Date(2026, 10, 13, 9, 30, 15, 0, time.UTC)
s := FormatTime(now)
if s != "2026-10-13T09:30:15Z" {
t.Errorf("FormatTime = %q, want %q", s, "2026-10-13T09:30:15Z")
}
got, err := ParseTime(s)
if err != nil {
t.Fatalf("ParseTime: %v", err)
}
if !got.Equal(now) {
t.Errorf("ParseTime round-trip = %v, want %v", got, now)
}
// A non-UTC time is normalised to UTC.
loc := time.FixedZone("BST", 3600)
s2 := FormatTime(time.Date(2026, 7, 1, 12, 0, 0, 0, loc))
if s2 != "2026-07-01T11:00:00Z" {
t.Errorf("FormatTime(non-UTC) = %q, want 2026-07-01T11:00:00Z", s2)
}
}

View file

@ -0,0 +1,256 @@
package users
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store"
)
// Store is the SQLite-backed Users service.
type Store struct {
db *sql.DB
audit audit.Audit
now func() time.Time
}
// New returns a Users service over db. aud may be nil (tests) — audit is then
// skipped rather than panicking.
func New(db *sql.DB, aud audit.Audit) *Store {
return &Store{db: db, audit: aud, now: time.Now}
}
var _ Users = (*Store)(nil)
// Create implements Users.Create (SPEC-004 §5, AC-6, D-019).
func (s *Store) Create(ctx context.Context, creatorID int64, email, displayName string, role Role) (UserID, error) {
email = strings.ToLower(strings.TrimSpace(email))
displayName = strings.TrimSpace(displayName)
if email == "" {
return 0, ErrEmailRequired
}
if displayName == "" {
return 0, ErrNameRequired
}
if !role.Valid() {
return 0, ErrInvalidRole
}
var manager Optional[UserID]
switch {
case creatorID == 0:
// Bootstrap (D-021/D-022): the first user IS the top admin.
n, err := s.Count(ctx)
if err != nil {
return 0, err
}
if n != 0 {
return 0, ErrBootstrapClosed
}
if role != RoleAdmin {
return 0, ErrBootstrapAdmin
}
manager = None[UserID]()
default:
creator, err := s.Get(ctx, creatorID)
if err != nil {
return 0, err
}
// One admin only — user 1 already exists (AC-6).
if role == RoleAdmin {
return 0, ErrOneAdmin
}
// Creation authority: a role strictly above the new user's (D-019).
if !creator.Role.Above(role) {
return 0, fmt.Errorf("%w: %s cannot create %s", ErrRoleNotAllowed, creator.Role, role)
}
manager = Some(creatorID)
}
createdAt := store.FormatTime(s.now())
res, err := s.db.Exec(
`INSERT INTO users (email, display_name, role, manager_user_id, status, created_at)
VALUES (?, ?, ?, ?, ?, ?)`,
email, displayName, string(role), nullableID(manager), StatusActive, createdAt,
)
if err != nil {
if isUniqueViolation(err) {
return 0, fmt.Errorf("%w: %s", ErrDuplicateEmail, email)
}
return 0, fmt.Errorf("users: insert: %w", err)
}
id, err := res.LastInsertId()
if err != nil {
return 0, fmt.Errorf("users: last insert id: %w", err)
}
s.record(ctx, audit.Event{
ActorUserID: creatorID,
Action: "user.create",
Subject: fmt.Sprintf("user:%d", id),
Detail: fmt.Sprintf("email=%s role=%s", email, role),
})
return id, nil
}
// Get implements Users.Get.
func (s *Store) Get(ctx context.Context, id int64) (User, error) {
row := s.db.QueryRow(`
SELECT user_id, email, display_name, role, manager_user_id, passkey_opt_out, status, created_at
FROM users WHERE user_id = ?`, id)
return scanUser(row)
}
// SetRole implements Users.SetRole. The role ripples down (D-034); the
// one-admin invariant is preserved (AC-6) and user 1 is protected (A14).
func (s *Store) SetRole(ctx context.Context, id int64, role Role) error {
if !role.Valid() {
return ErrInvalidRole
}
switch {
case id == 1 && role != RoleAdmin:
return ErrTopAdmin
case id != 1 && role == RoleAdmin:
return ErrOneAdmin
}
if _, err := s.Get(ctx, id); err != nil {
return err
}
if _, err := s.db.Exec(`UPDATE users SET role = ? WHERE user_id = ?`, string(role), id); err != nil {
return fmt.Errorf("users: update role: %w", err)
}
// The actor of a role change is whoever the door authenticated, not the
// user being changed — it comes from the audit.Meta on ctx (R-1/R-3).
s.record(ctx, audit.Event{
Action: "user.set_role",
Subject: fmt.Sprintf("user:%d", id),
Detail: fmt.Sprintf("role=%s", role),
})
return nil
}
// Manager implements Users.Manager — the chain link above id.
func (s *Store) Manager(ctx context.Context, id int64) (Optional[UserID], error) {
var m sql.NullInt64
err := s.db.QueryRow(`SELECT manager_user_id FROM users WHERE user_id = ?`, id).Scan(&m)
if errors.Is(err, sql.ErrNoRows) {
return None[UserID](), ErrNotFound
}
if err != nil {
return None[UserID](), fmt.Errorf("users: manager lookup: %w", err)
}
if m.Valid {
return Some(m.Int64), nil
}
return None[UserID](), nil
}
// Count returns the number of users. It answers "is the instance in setup
// mode?" (SPEC-005 §3: no user exists).
func (s *Store) Count(ctx context.Context) (int, error) {
var n int
if err := s.db.QueryRow(`SELECT count(*) FROM users`).Scan(&n); err != nil {
return 0, fmt.Errorf("users: count: %w", err)
}
return n, nil
}
// ByEmail returns the user with the given email (recovery lookup, SPEC-007).
// The match is case-insensitive.
func (s *Store) ByEmail(ctx context.Context, email string) (User, error) {
row := s.db.QueryRow(`
SELECT user_id, email, display_name, role, manager_user_id, passkey_opt_out, status, created_at
FROM users WHERE email = ?`, strings.ToLower(strings.TrimSpace(email)))
return scanUser(row)
}
// PasskeyOptOut implements Users.PasskeyOptOut (D-041): the user's stored
// preference about the post-login passkey nudge.
func (s *Store) PasskeyOptOut(ctx context.Context, id int64) (bool, error) {
var optOut int
err := s.db.QueryRow(`SELECT passkey_opt_out FROM users WHERE user_id = ?`, id).Scan(&optOut)
if errors.Is(err, sql.ErrNoRows) {
return false, ErrNotFound
}
if err != nil {
return false, fmt.Errorf("users: passkey opt-out lookup: %w", err)
}
return optOut != 0, nil
}
// SetPasskeyOptOut implements Users.SetPasskeyOptOut (D-041). It is a plain
// preference write — no one-admin or role rule applies — and is audited (A11).
// The actor comes from the context (ruling R-1); the user is the subject.
func (s *Store) SetPasskeyOptOut(ctx context.Context, id int64, optOut bool) error {
if _, err := s.Get(ctx, id); err != nil {
return err
}
v := 0
if optOut {
v = 1
}
if _, err := s.db.Exec(`UPDATE users SET passkey_opt_out = ? WHERE user_id = ?`, v, id); err != nil {
return fmt.Errorf("users: set passkey opt-out: %w", err)
}
s.record(ctx, audit.Event{
Action: "user.passkey_opt_out",
Subject: fmt.Sprintf("user:%d", id),
Detail: fmt.Sprintf("passkey_opt_out=%t", optOut),
})
return nil
}
// rowScanner is satisfied by *sql.Row and *sql.Rows.
type rowScanner interface {
Scan(dest ...any) error
}
func scanUser(row rowScanner) (User, error) {
var (
u User
role string
manager sql.NullInt64
optOut int
created string
)
err := row.Scan(&u.ID, &u.Email, &u.DisplayName, &role, &manager, &optOut, &u.Status, &created)
if errors.Is(err, sql.ErrNoRows) {
return User{}, ErrNotFound
}
if err != nil {
return User{}, fmt.Errorf("users: scan: %w", err)
}
u.Role = Role(role)
u.PasskeyOptOut = optOut != 0
if manager.Valid {
u.Manager = Some(manager.Int64)
}
if u.CreatedAt, err = store.ParseTime(created); err != nil {
return User{}, fmt.Errorf("users: parse created_at: %w", err)
}
return u, nil
}
func nullableID(o Optional[UserID]) any {
if !o.Set {
return nil
}
return o.Value
}
func (s *Store) record(ctx context.Context, e audit.Event) {
if s.audit != nil {
s.audit.Record(ctx, e)
}
}
// isUniqueViolation reports whether err is a SQLite UNIQUE-constraint failure.
func isUniqueViolation(err error) bool {
return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed")
}

View file

@ -0,0 +1,168 @@
// Package users is MyDay's user service: the stable person record, the role
// ladder, and the manager chain (PROJECT_PLAN.md §7, D-020/D-024/D-034).
//
// The user is the stable identity anchor; credentials hang off a rotating
// identity owned by the auth package (D-008). This package never touches
// credentials.
//
// Spec: docs/specs/spec-004-identity-sessions.md §3, §5.
package users
import (
"context"
"errors"
"fmt"
"time"
)
// Role is a user's single tier in the ladder admin > director > manager > team
// (D-034: one role per user, no ACL — the role ripples down).
type Role string
const (
RoleAdmin Role = "admin"
RoleDirector Role = "director"
RoleManager Role = "manager"
RoleTeam Role = "team"
)
// roleRank orders the ladder. A higher number implies every lower role.
var roleRank = map[Role]int{
RoleTeam: 1,
RoleManager: 2,
RoleDirector: 3,
RoleAdmin: 4,
}
// Valid reports whether r is one of the four defined roles.
func (r Role) Valid() bool {
_, ok := roleRank[r]
return ok
}
// Rank returns the ladder position (higher = more authority); 0 for invalid.
func (r Role) Rank() int { return roleRank[r] }
// Above reports whether r is strictly higher than other on the ladder.
func (r Role) Above(other Role) bool { return r.Rank() > other.Rank() }
// String renders the role.
func (r Role) String() string { return string(r) }
// ParseRole validates a stored/entered role string.
func ParseRole(s string) (Role, error) {
r := Role(s)
if !r.Valid() {
return "", fmt.Errorf("%w: %q", ErrInvalidRole, s)
}
return r, nil
}
// Optional carries a value that may be absent (Go has no nullable generics).
// It models the manager chain: the top admin has NO manager (D-021).
type Optional[T any] struct {
Value T
Set bool
}
// Some returns a set Optional.
func Some[T any](v T) Optional[T] { return Optional[T]{Value: v, Set: true} }
// None returns an unset Optional.
func None[T any]() Optional[T] { return Optional[T]{} }
// UserID is the internal, stable person id. The first user is 1 (D-013).
type UserID = int64
// User statuses. A disabled user keeps their record and history but may not act.
const (
StatusActive = "active"
StatusDisabled = "disabled"
)
// CanApprove reports whether approver may decide a recovery request for
// requester (SPEC-007 §4, ruling R-7). The authority is the CHAIN, not the
// ladder: the approver must be an ANCESTOR of the requester — on their manager
// line, upward — or the admin. The requester may never approve their own
// request (AC-2).
//
// approverIsAncestor is computed by the caller, which owns the chain lookup
// (users.Users.Manager); this predicate stays pure. A role merely *above* the
// requester is NOT sufficient — that was R-7's correction: an unrelated
// director must not be able to recover a team member outside their subtree.
func CanApprove(approver, requester User, approverIsAncestor bool) bool {
if approver.ID == requester.ID {
return false
}
if approver.Role == RoleAdmin {
return true // the admin is the root of the chain and approves anyone (§4)
}
return approverIsAncestor
}
// User is the stable person record (SPEC-004 §4 `users`).
type User struct {
ID UserID
Email string
DisplayName string
Role Role
// Manager is the chain link: who approves this user (D-020). Unset only
// for the top admin.
Manager Optional[UserID]
// PasskeyOptOut records that the user declined the passkey nudge (D-041).
// It is a USER preference (not a credential), so it survives identity
// rotation (D-008): an opted-out user is never re-nudged after recovery.
PasskeyOptOut bool
Status string
CreatedAt time.Time
}
// Users is the service contract (SPEC-004 §5).
//
// Per ruling R-1 every method takes the request context: the door attaches
// audit.Meta (actor · client IP · correlation) before calling, and each
// mutation writes its audit row through it (A11).
type Users interface {
// Create creates a user below the creator on the chain.
//
// * creatorID != 0 — the creator must hold a role strictly ABOVE role
// (D-019/§7.3); the new user's manager defaults to the creator. A
// second admin is always rejected (AC-6).
// * creatorID == 0 — bootstrap: allowed ONLY while no user exists, and
// it must create the top admin (user 1, role admin, manager NULL).
Create(ctx context.Context, creatorID int64, email, displayName string, role Role) (UserID int64, err error)
// Get returns one user.
Get(ctx context.Context, id int64) (User, error)
// SetRole changes a user's role (ripple-down model, D-034). It preserves
// the one-admin invariant: only user 1 may be admin, and user 1 may not be
// demoted (A14).
SetRole(ctx context.Context, id int64, role Role) error
// Manager returns the user above id in the chain.
Manager(ctx context.Context, id int64) (Optional[UserID], error)
// PasskeyOptOut reports whether the user has declined the passkey nudge
// (D-041). The marker is a USER preference, so it survives identity
// rotation.
PasskeyOptOut(ctx context.Context, id int64) (bool, error)
// SetPasskeyOptOut records the user's choice about the passkey nudge
// (D-041) and audits it (A11).
SetPasskeyOptOut(ctx context.Context, id int64, optOut bool) error
}
// Sentinel errors.
var (
ErrNotFound = errors.New("users: user not found")
ErrInvalidRole = errors.New("users: invalid role")
ErrRoleNotAllowed = errors.New("users: creator's role is not above the new user's role")
ErrOneAdmin = errors.New("users: exactly one admin is allowed")
ErrTopAdmin = errors.New("users: the top admin (user 1) cannot be demoted")
ErrDuplicateEmail = errors.New("users: email already in use")
ErrEmailRequired = errors.New("users: email is required")
ErrNameRequired = errors.New("users: display name is required")
ErrBootstrapClosed = errors.New("users: bootstrap is only allowed while no users exist")
ErrBootstrapAdmin = errors.New("users: the bootstrap user must be the admin")
)

View file

@ -0,0 +1,405 @@
package users_test
import (
"context"
"database/sql"
"errors"
"path/filepath"
"testing"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users"
)
// testCtx is the request context every service call needs under ruling R-1.
var testCtx = context.Background()
func newUsers(t *testing.T) (*users.Store, *store.DB) {
t.Helper()
db, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := db.Migrate(); err != nil {
t.Fatalf("Migrate: %v", err)
}
return users.New(db.DB, audit.New(db.DB)), db
}
// bootstrap creates the top admin (user 1) and returns its id.
func bootstrap(t *testing.T, s *users.Store) int64 {
t.Helper()
id, err := s.Create(testCtx, 0, "admin@example.com", "Admin", users.RoleAdmin)
if err != nil {
t.Fatalf("bootstrap Create: %v", err)
}
if id != 1 {
t.Fatalf("bootstrap created user %d, want 1 (D-013)", id)
}
return id
}
// TestCanApproveChainRule pins ruling R-7: recovery authority is the CHAIN —
// the approver must be an ancestor of the requester (their manager upward) or
// the admin, and never the requester. A role merely ABOVE the requester is not
// enough.
func TestCanApproveChainRule(t *testing.T) {
admin := users.User{ID: 1, Role: users.RoleAdmin}
director := users.User{ID: 2, Role: users.RoleDirector}
manager := users.User{ID: 3, Role: users.RoleManager}
team := users.User{ID: 4, Role: users.RoleTeam}
otherDirector := users.User{ID: 5, Role: users.RoleDirector} // a different subtree
cases := []struct {
name string
approver users.User
requester users.User
isAncestor bool
want bool
}{
{"self (team)", team, team, false, false},
{"self (admin)", admin, admin, false, false},
{"manager is an ancestor", manager, team, true, true},
{"director is an ancestor", director, team, true, true},
{"admin is the root (walked)", admin, team, true, true},
{"admin with no walked chain still approves", admin, team, false, true},
{"peer (same role, not ancestor)", team, users.User{ID: 9, Role: users.RoleTeam}, false, false},
{"role above but NOT an ancestor", otherDirector, team, false, false},
{"ancestor flag cannot override self", manager, manager, true, false},
}
for _, c := range cases {
if got := users.CanApprove(c.approver, c.requester, c.isAncestor); got != c.want {
t.Errorf("%s: CanApprove = %v, want %v", c.name, got, c.want)
}
}
}
func TestRoleLadder(t *testing.T) {
cases := []struct {
higher, lower users.Role
}{
{users.RoleAdmin, users.RoleDirector},
{users.RoleAdmin, users.RoleManager},
{users.RoleAdmin, users.RoleTeam},
{users.RoleDirector, users.RoleManager},
{users.RoleDirector, users.RoleTeam},
{users.RoleManager, users.RoleTeam},
}
for _, c := range cases {
if !c.higher.Above(c.lower) {
t.Errorf("%s should be above %s", c.higher, c.lower)
}
}
for _, r := range []users.Role{users.RoleAdmin, users.RoleDirector, users.RoleManager, users.RoleTeam} {
if !r.Valid() {
t.Errorf("%s should be valid", r)
}
}
if users.Role("boss").Valid() {
t.Error("unknown role reported valid")
}
if _, err := users.ParseRole("nope"); !errors.Is(err, users.ErrInvalidRole) {
t.Errorf("ParseRole(nope) = %v, want ErrInvalidRole", err)
}
}
func TestBootstrapRules(t *testing.T) {
s, _ := newUsers(t)
// A bootstrap must be the admin.
if _, err := s.Create(testCtx, 0, "x@example.com", "X", users.RoleTeam); !errors.Is(err, users.ErrBootstrapAdmin) {
t.Errorf("bootstrap non-admin = %v, want ErrBootstrapAdmin", err)
}
id := bootstrap(t, s)
u, err := s.Get(testCtx, id)
if err != nil {
t.Fatalf("Get(1): %v", err)
}
if u.Role != users.RoleAdmin {
t.Errorf("user 1 role = %s, want admin", u.Role)
}
if u.Manager.Set {
t.Errorf("admin manager = %v, want none (root of the chain)", u.Manager)
}
// A second bootstrap is refused once users exist.
if _, err := s.Create(testCtx, 0, "y@example.com", "Y", users.RoleAdmin); !errors.Is(err, users.ErrBootstrapClosed) {
t.Errorf("second bootstrap = %v, want ErrBootstrapClosed", err)
}
}
// AC-6: creating an admin once user 1 exists is rejected.
func TestAC6_SecondAdminRejected(t *testing.T) {
s, _ := newUsers(t)
admin := bootstrap(t, s)
if _, err := s.Create(testCtx, admin, "admin2@example.com", "Admin2", users.RoleAdmin); !errors.Is(err, users.ErrOneAdmin) {
t.Fatalf("Create(role=admin) = %v, want ErrOneAdmin (AC-6)", err)
}
// A second director is fine — only admin is unique.
director, err := s.Create(testCtx, admin, "director@example.com", "Director", users.RoleDirector)
if err != nil {
t.Fatalf("admin creates director: %v", err)
}
// Promoting that director to admin is also rejected (one admin).
if err := s.SetRole(testCtx, director, users.RoleAdmin); !errors.Is(err, users.ErrOneAdmin) {
t.Errorf("SetRole(admin) = %v, want ErrOneAdmin", err)
}
// Demoting the top admin is refused (A14).
if err := s.SetRole(testCtx, admin, users.RoleDirector); !errors.Is(err, users.ErrTopAdmin) {
t.Errorf("demote top admin = %v, want ErrTopAdmin", err)
}
}
func TestCreationAuthorityAndChain(t *testing.T) {
s, _ := newUsers(t)
admin := bootstrap(t, s)
director, err := s.Create(testCtx, admin, "d@example.com", "D", users.RoleDirector)
if err != nil {
t.Fatalf("admin -> director: %v", err)
}
// The new user's manager defaults to the creator.
mgr, err := s.Manager(testCtx, director)
if err != nil {
t.Fatalf("Manager(director): %v", err)
}
if !mgr.Set || mgr.Value != admin {
t.Errorf("director manager = %+v, want admin %d", mgr, admin)
}
// A peer cannot be created: a director may not create a director.
if _, err := s.Create(testCtx, director, "d2@example.com", "D2", users.RoleDirector); !errors.Is(err, users.ErrRoleNotAllowed) {
t.Errorf("director -> director = %v, want ErrRoleNotAllowed", err)
}
// Nor may a director create an admin.
if _, err := s.Create(testCtx, director, "a2@example.com", "A2", users.RoleAdmin); !errors.Is(err, users.ErrOneAdmin) {
t.Errorf("director -> admin = %v, want ErrOneAdmin", err)
}
// A manager below the director is fine.
manager, err := s.Create(testCtx, director, "m@example.com", "M", users.RoleManager)
if err != nil {
t.Fatalf("director -> manager: %v", err)
}
// A team member below the manager is fine.
if _, err := s.Create(testCtx, manager, "t@example.com", "T", users.RoleTeam); err != nil {
t.Fatalf("manager -> team: %v", err)
}
// A manager may not create a manager.
if _, err := s.Create(testCtx, manager, "m2@example.com", "M2", users.RoleManager); !errors.Is(err, users.ErrRoleNotAllowed) {
t.Errorf("manager -> manager = %v, want ErrRoleNotAllowed", err)
}
}
// TestSpec007AC1_CreationAuthorityAndDefaultManager restates D-019 as the
// SPEC-007 acceptance criterion: creation requires a role strictly ABOVE the
// new user's, and the creator is the new user's default manager.
func TestSpec007AC1_CreationAuthorityAndDefaultManager(t *testing.T) {
s, _ := newUsers(t)
admin := bootstrap(t, s)
// Each level creates the next; the creator becomes the default manager.
director, err := s.Create(testCtx, admin, "ac1-director@example.com", "D", users.RoleDirector)
if err != nil {
t.Fatalf("admin -> director: %v", err)
}
manager, err := s.Create(testCtx, director, "ac1-manager@example.com", "M", users.RoleManager)
if err != nil {
t.Fatalf("director -> manager: %v", err)
}
team, err := s.Create(testCtx, manager, "ac1-team@example.com", "T", users.RoleTeam)
if err != nil {
t.Fatalf("manager -> team: %v", err)
}
for want, id := range map[int64]int64{director: admin, manager: director, team: manager} {
mgr, err := s.Manager(testCtx, want)
if err != nil {
t.Fatalf("Manager(%d): %v", want, err)
}
if !mgr.Set || mgr.Value != id {
t.Errorf("user %d manager = %+v, want the creator %d (AC-1)", want, mgr, id)
}
}
// A role may NOT create its own tier (a peer) — only strictly below.
if _, err := s.Create(testCtx, director, "ac1-peer@example.com", "P", users.RoleDirector); !errors.Is(err, users.ErrRoleNotAllowed) {
t.Errorf("director -> director = %v, want ErrRoleNotAllowed (AC-1)", err)
}
// Nor a role ABOVE (a second admin).
if _, err := s.Create(testCtx, director, "ac1-admin@example.com", "A", users.RoleAdmin); !errors.Is(err, users.ErrOneAdmin) {
t.Errorf("director -> admin = %v, want ErrOneAdmin (AC-1)", err)
}
}
func TestSetRoleValid(t *testing.T) {
s, _ := newUsers(t)
admin := bootstrap(t, s)
team, err := s.Create(testCtx, admin, "t@example.com", "T", users.RoleTeam)
if err != nil {
t.Fatalf("create team: %v", err)
}
if err := s.SetRole(testCtx, team, users.RoleManager); err != nil {
t.Fatalf("SetRole(manager): %v", err)
}
u, err := s.Get(testCtx, team)
if err != nil {
t.Fatalf("Get: %v", err)
}
if u.Role != users.RoleManager {
t.Errorf("role = %s, want manager", u.Role)
}
if err := s.SetRole(testCtx, team, users.Role("boss")); !errors.Is(err, users.ErrInvalidRole) {
t.Errorf("SetRole(invalid) = %v, want ErrInvalidRole", err)
}
if err := s.SetRole(testCtx, 999, users.RoleTeam); !errors.Is(err, users.ErrNotFound) {
t.Errorf("SetRole(unknown) = %v, want ErrNotFound", err)
}
}
func TestCreateValidation(t *testing.T) {
s, _ := newUsers(t)
admin := bootstrap(t, s)
if _, err := s.Create(testCtx, admin, " ", "X", users.RoleTeam); !errors.Is(err, users.ErrEmailRequired) {
t.Errorf("empty email = %v, want ErrEmailRequired", err)
}
if _, err := s.Create(testCtx, admin, "x@example.com", " ", users.RoleTeam); !errors.Is(err, users.ErrNameRequired) {
t.Errorf("empty name = %v, want ErrNameRequired", err)
}
if _, err := s.Create(testCtx, admin, "x@example.com", "X", users.Role("boss")); !errors.Is(err, users.ErrInvalidRole) {
t.Errorf("invalid role = %v, want ErrInvalidRole", err)
}
if _, err := s.Create(testCtx, 999, "x@example.com", "X", users.RoleTeam); !errors.Is(err, users.ErrNotFound) {
t.Errorf("unknown creator = %v, want ErrNotFound", err)
}
// Email is unique (and normalised to lower case).
if _, err := s.Create(testCtx, admin, "Team@Example.com", "T", users.RoleTeam); err != nil {
t.Fatalf("create team: %v", err)
}
if _, err := s.Create(testCtx, admin, "team@example.com", "T2", users.RoleTeam); !errors.Is(err, users.ErrDuplicateEmail) {
t.Errorf("duplicate email = %v, want ErrDuplicateEmail", err)
}
}
// TestAuditCarriesContextMeta proves ruling R-1 end to end: the door attaches
// audit.Meta to the context, and a service mutation WITHOUT an explicit actor
// records the context's actor · client IP · correlation in `audit_log`.
func TestAuditCarriesContextMeta(t *testing.T) {
s, db := newUsers(t)
admin := bootstrap(t, s)
ctx := audit.WithMeta(context.Background(), audit.Meta{
ActorUserID: admin,
ClientIP: "203.0.113.9",
CorrelationID: "corr-42",
})
team, err := s.Create(ctx, admin, "t@example.com", "T", users.RoleTeam)
if err != nil {
t.Fatalf("Create: %v", err)
}
// SetRole carries no explicit actor: it must come from the context.
if err := s.SetRole(ctx, team, users.RoleManager); err != nil {
t.Fatalf("SetRole: %v", err)
}
var (
actor sql.NullInt64
ip sql.NullString
corr sql.NullString
)
err = db.QueryRow(`
SELECT actor_user_id, client_ip, correlation_id
FROM audit_log WHERE action = 'user.set_role' ORDER BY id DESC LIMIT 1`,
).Scan(&actor, &ip, &corr)
if err != nil {
t.Fatalf("read audit row: %v", err)
}
if !actor.Valid || actor.Int64 != admin {
t.Errorf("actor_user_id = %+v, want %d (from context meta, R-1)", actor, admin)
}
if ip.String != "203.0.113.9" {
t.Errorf("client_ip = %q, want 203.0.113.9", ip.String)
}
if corr.String != "corr-42" {
t.Errorf("correlation_id = %q, want corr-42", corr.String)
}
}
// TestPasskeyOptOut (D-041): the marker is a USER preference — default false,
// settable, readable through Get, and audited.
func TestPasskeyOptOut(t *testing.T) {
s, db := newUsers(t)
admin := bootstrap(t, s)
// Default: not opted out, and reflected on the user record.
opt, err := s.PasskeyOptOut(testCtx, admin)
if err != nil {
t.Fatalf("PasskeyOptOut: %v", err)
}
if opt {
t.Error("passkey_opt_out defaults to true, want false (D-041)")
}
if err := s.SetPasskeyOptOut(testCtx, admin, true); err != nil {
t.Fatalf("SetPasskeyOptOut(true): %v", err)
}
if opt, err = s.PasskeyOptOut(testCtx, admin); err != nil || !opt {
t.Errorf("after SetPasskeyOptOut(true) opt-out = %v (err %v), want true", opt, err)
}
u, err := s.Get(testCtx, admin)
if err != nil {
t.Fatalf("Get: %v", err)
}
if !u.PasskeyOptOut {
t.Error("Get did not carry passkey_opt_out (D-041)")
}
// The write is audited (A11).
var n int
if err := db.QueryRow(
`SELECT count(*) FROM audit_log WHERE action = 'user.passkey_opt_out'`).Scan(&n); err != nil {
t.Fatalf("count audit: %v", err)
}
if n != 1 {
t.Errorf("opt-out audit rows = %d, want 1 (A11)", n)
}
// Unknown user -> ErrNotFound.
if _, err := s.PasskeyOptOut(testCtx, 999); !errors.Is(err, users.ErrNotFound) {
t.Errorf("PasskeyOptOut(unknown) = %v, want ErrNotFound", err)
}
if err := s.SetPasskeyOptOut(testCtx, 999, true); !errors.Is(err, users.ErrNotFound) {
t.Errorf("SetPasskeyOptOut(unknown) = %v, want ErrNotFound", err)
}
}
func TestByEmailAndCount(t *testing.T) {
s, _ := newUsers(t)
admin := bootstrap(t, s)
if _, err := s.Create(testCtx, admin, "team@example.com", "T", users.RoleTeam); err != nil {
t.Fatalf("create: %v", err)
}
u, err := s.ByEmail(testCtx, "TEAM@example.com")
if err != nil {
t.Fatalf("ByEmail: %v", err)
}
if u.Role != users.RoleTeam {
t.Errorf("ByEmail role = %s, want team", u.Role)
}
if _, err := s.ByEmail(testCtx, "nobody@example.com"); !errors.Is(err, users.ErrNotFound) {
t.Errorf("ByEmail(unknown) = %v, want ErrNotFound", err)
}
n, err := s.Count(testCtx)
if err != nil {
t.Fatalf("Count: %v", err)
}
if n != 2 {
t.Errorf("Count = %d, want 2", n)
}
}