diff --git a/CHANGELOG.md b/CHANGELOG.md index 372dbc8..3b256b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,4 +5,5 @@ All notable changes to Noema are recorded here. Format follows Keep a Changelog; ## [Unreleased] ### Added +- Phase 1 foundation: configuration, structured logging, metrics, migrations, local authentication (Argon2id, sessions, CSRF, lockout, roles, API tokens), audit log, JSON API skeleton, server-rendered admin UI with light/dark theme, `noemad` and `noemactl`. - Repository foundation: plan, architecture, security model, threat model, ADRs, Makefile, container files. diff --git a/PLAN.md b/PLAN.md index f172e3a..784b3f4 100644 --- a/PLAN.md +++ b/PLAN.md @@ -35,20 +35,20 @@ An administrator can: start Noema → log in → create a mind → configure per - ☑ `go.mod`, compose and Dockerfile skeletons - ☑ First commit -### Phase 1 – Foundation: config, logging, storage, migrations, auth, base UI -- ☐ `internal/config`: defaults → JSON file → env; validation; no secrets in `String()` -- ☐ `internal/observability`: slog setup, redacting handler, request logging, Prometheus registry, `/metrics` -- ☐ `pkg/ids`: UUIDv7 -- ☐ `internal/storage`: pgxpool, migration runner (advisory lock, checksums, up/down/status), test helper with per-run schema -- ☐ `migrations/0001_foundation`: users, sessions, api_tokens, login_attempts, audit_log (append-only trigger), settings -- ☐ `internal/auth`: Argon2id, sessions, CSRF, rate limit + lockout, roles, middleware, bootstrap admin -- ☐ `internal/audit`: audit writer used by all admin mutations -- ☐ `internal/api`: server, router, middleware chain (request id, recover, logging, security headers, CSP, body limits), JSON error format, health endpoints -- ☐ `web/`: layout, theme (light/dark), navigation, login, users, sessions, system page skeleton, `app.js` (theme, SSE helper) -- ☐ `cmd/noemad`, `cmd/noemactl` (`status`, `migrate up|down|status`, `user create`) -- ☐ `deploy/`: compose (db + app), multi-stage Dockerfile -- ☐ Tests: config precedence, migration up/down, auth hashing, session lifecycle, CSRF, lockout, role middleware -- ☐ Commit(s) +### Phase 1 – Foundation: config, logging, storage, migrations, auth, base UI ☑ +- ☑ `internal/config`: defaults → JSON file → env; validation; redaction +- ☑ `internal/observability`: slog with redacting handler, Prometheus registry, `/metrics` (token-protected) +- ☑ `pkg/ids`: UUIDv7 +- ☑ `internal/storage`: pgxpool with statement timeout, migration runner (advisory lock, checksums, up/down/status), per-test schema helper +- ☑ `migrations/0001_foundation`: append-only guard trigger, users, sessions, api_tokens, recovery_codes, audit_log, settings +- ☑ `internal/auth`: Argon2id, opaque hashed sessions with idle/absolute expiry, CSRF (token + Origin check), per-IP limiter, per-account lockout, roles, middleware, API tokens +- ☑ `internal/audit`: audit writer and listing +- ☑ `internal/api`: server, middleware chain (request id, recover, CSP/security headers, body limits, logging/metrics, auth), JSON error envelope, health, status, users, sessions, tokens, audit +- ☑ `web/` + `internal/ui`: layout, light/dark theme, navigation, login, first-run setup, dashboard, system, users, account (sessions, tokens, password), audit, about; `app.js` (theme, CSRF fetch, SSE helper, keyboard shortcuts) +- ☑ `cmd/noemad` (graceful shutdown, bootstrap admin, supervised background tasks), `cmd/noemactl` (`status`, `migrate up|down|status`, `user create`, exit codes) +- ☑ `deploy/`: compose (pgvector db + app), multi-stage Dockerfile +- ☑ Tests: config precedence, migration up/down + append-only trigger, hashing, session lifecycle, lockout, CSRF, roles, API envelope +- ☐ TOTP + recovery codes (moved to Phase 16 with the rest of hardening) ### Phase 2 – Mind model, identity, personality, self-model - ☐ `migrations/0002_minds`: minds, mind_config, personalities, personality_versions (append-only), personality_templates, self_models diff --git a/cmd/noemactl/db.go b/cmd/noemactl/db.go new file mode 100644 index 0000000..f5e4d16 --- /dev/null +++ b/cmd/noemactl/db.go @@ -0,0 +1,11 @@ +package main + +import "github.com/jackc/pgx/v5/pgxpool" + +type dbPool struct{ Pool *pgxpool.Pool } + +func (d dbPool) Close() { + if d.Pool != nil { + d.Pool.Close() + } +} diff --git a/cmd/noemactl/main.go b/cmd/noemactl/main.go new file mode 100644 index 0000000..95df015 --- /dev/null +++ b/cmd/noemactl/main.go @@ -0,0 +1,253 @@ +// Command noemactl administers a running Noema daemon over its HTTP API and +// performs a few offline operations (migrations, first user) directly against +// the database. +// +// Environment: NOEMA_URL (default http://127.0.0.1:8080), NOEMA_API_TOKEN for +// API commands; NOEMA_DATABASE_URL for offline commands. +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" + + "github.com/andydixon/noema/internal/auth" + "github.com/andydixon/noema/internal/storage" + "github.com/andydixon/noema/internal/version" + "github.com/andydixon/noema/migrations" +) + +// Exit codes: 0 ok, 1 general failure, 2 usage error, 3 not found, 4 unauthorised. +const ( + exitOK = iota + exitFail + exitUsage + exitNotFound + exitUnauth +) + +type command struct { + name, help string + run func(args []string) int +} + +var commands []command + +func main() { + commands = []command{ + {"status", "show daemon status", cmdStatus}, + {"migrate", "up|down|status – manage database schema (offline)", cmdMigrate}, + {"user", "create [-role r] – manage users (offline)", cmdUser}, + {"version", "print version", func([]string) int { fmt.Println(version.Version); return exitOK }}, + } + if len(os.Args) < 2 || os.Args[1] == "-h" || os.Args[1] == "--help" || os.Args[1] == "help" { + usage() + os.Exit(exitUsage) + } + for _, c := range commands { + if c.name == os.Args[1] { + os.Exit(c.run(os.Args[2:])) + } + } + fmt.Fprintf(os.Stderr, "unknown command %q\n\n", os.Args[1]) + usage() + os.Exit(exitUsage) +} + +func usage() { + fmt.Fprintln(os.Stderr, "usage: noemactl [args]\n\ncommands:") + for _, c := range commands { + fmt.Fprintf(os.Stderr, " %-10s %s\n", c.name, c.help) + } + fmt.Fprintln(os.Stderr, "\nenvironment: NOEMA_URL, NOEMA_API_TOKEN (API commands); NOEMA_DATABASE_URL (offline commands)") +} + +// ---- API client ---- + +func apiRequest(method, path string, body any, out any) (int, error) { + base := os.Getenv("NOEMA_URL") + if base == "" { + base = "http://127.0.0.1:8080" + } + var rd io.Reader + if body != nil { + b, _ := json.Marshal(body) + rd = strings.NewReader(string(b)) + } + req, err := http.NewRequest(method, strings.TrimRight(base, "/")+path, rd) + if err != nil { + return 0, err + } + if tok := os.Getenv("NOEMA_API_TOKEN"); tok != "" { + req.Header.Set("Authorization", "Bearer "+tok) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + res, err := (&http.Client{Timeout: 30 * time.Second}).Do(req) + if err != nil { + return 0, err + } + defer res.Body.Close() + data, _ := io.ReadAll(io.LimitReader(res.Body, 8<<20)) + if res.StatusCode >= 400 { + var e struct { + Error struct{ Code, Message string } + } + json.Unmarshal(data, &e) //nolint:errcheck + return res.StatusCode, fmt.Errorf("%s: %s", res.Status, e.Error.Message) + } + if out != nil { + return res.StatusCode, json.Unmarshal(data, out) + } + return res.StatusCode, nil +} + +func exitFor(status int, err error) int { + fmt.Fprintln(os.Stderr, "error:", err) + switch status { + case http.StatusNotFound: + return exitNotFound + case http.StatusUnauthorized, http.StatusForbidden: + return exitUnauth + } + return exitFail +} + +func cmdStatus(args []string) int { + fs := flag.NewFlagSet("status", flag.ContinueOnError) + asJSON := fs.Bool("json", false, "raw JSON output") + if fs.Parse(args) != nil { + return exitUsage + } + var st map[string]any + if code, err := apiRequest("GET", "/api/v1/system/status", nil, &st); err != nil { + return exitFor(code, err) + } + if *asJSON { + json.NewEncoder(os.Stdout).Encode(st) //nolint:errcheck + return exitOK + } + db, _ := st["database"].(map[string]any) + fmt.Printf("version: %v\nuptime: %vs\ngoroutines: %v\ndatabase: ok=%v pgvector=%v\n", st["version"], st["uptime_seconds"], st["goroutines"], db["ok"], db["pgvector"]) + return exitOK +} + +// ---- offline commands ---- + +func openDB(ctx context.Context) (dbPool, int) { + url := os.Getenv("NOEMA_DATABASE_URL") + if url == "" { + fmt.Fprintln(os.Stderr, "NOEMA_DATABASE_URL is required for this command") + return dbPool{}, exitUsage + } + pool, err := storage.Open(ctx, url) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return dbPool{}, exitFail + } + return dbPool{pool}, exitOK +} + +func cmdMigrate(args []string) int { + if len(args) < 1 { + fmt.Fprintln(os.Stderr, "usage: noemactl migrate up|down|status") + return exitUsage + } + ctx := context.Background() + db, code := openDB(ctx) + if code != exitOK { + return code + } + defer db.Close() + switch args[0] { + case "up": + applied, err := storage.MigrateUp(ctx, db.Pool, migrations.FS) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return exitFail + } + if len(applied) == 0 { + fmt.Println("database is up to date") + } + for _, a := range applied { + fmt.Println("applied", a) + } + case "down": + name, err := storage.MigrateDown(ctx, db.Pool, migrations.FS) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return exitFail + } + if name == "" { + fmt.Println("nothing to roll back") + } else { + fmt.Println("rolled back", name) + } + case "status": + st, err := storage.Status(ctx, db.Pool, migrations.FS) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return exitFail + } + for _, m := range st { + state := "pending" + if m.Applied { + state = "applied " + m.AppliedAt + } + if m.Drift { + state += " DRIFT" + } + fmt.Printf("%04d %-30s %s\n", m.Version, m.Name, state) + } + default: + fmt.Fprintln(os.Stderr, "usage: noemactl migrate up|down|status") + return exitUsage + } + return exitOK +} + +func cmdUser(args []string) int { + if len(args) < 2 || args[0] != "create" { + fmt.Fprintln(os.Stderr, "usage: noemactl user create [-role administrator|operator|observer]\n password is read from NOEMA_PASSWORD or stdin") + return exitUsage + } + fs := flag.NewFlagSet("user create", flag.ContinueOnError) + role := fs.String("role", "administrator", "role") + if fs.Parse(args[2:]) != nil { + return exitUsage + } + password := os.Getenv("NOEMA_PASSWORD") + if password == "" { + fmt.Fprint(os.Stderr, "password: ") + b, _ := io.ReadAll(io.LimitReader(os.Stdin, 1024)) + password = strings.TrimRight(string(b), "\r\n") + } + ctx := context.Background() + db, code := openDB(ctx) + if code != exitOK { + return code + } + defer db.Close() + svc := auth.NewService(db.Pool, time.Hour, time.Hour) + u, err := svc.CreateUser(ctx, args[1], password, auth.Role(*role)) + if err != nil { + if errors.Is(err, storage.ErrConflict) { + fmt.Fprintln(os.Stderr, "error: username already exists") + } else { + fmt.Fprintln(os.Stderr, "error:", err) + } + return exitFail + } + db.Pool.Exec(ctx, `INSERT INTO audit_log (id, actor_kind, actor_name, action, target_kind, target_id, details) VALUES (gen_random_uuid(),'system','noemactl','user.create','user',$1,$2)`, //nolint:errcheck + u.ID, fmt.Sprintf(`{"username":%q,"role":%q}`, u.Username, u.Role)) + fmt.Println("created user", u.Username, "with role", u.Role, "id", u.ID) + return exitOK +} diff --git a/cmd/noemad/main.go b/cmd/noemad/main.go new file mode 100644 index 0000000..be24d15 --- /dev/null +++ b/cmd/noemad/main.go @@ -0,0 +1,133 @@ +// Command noemad is the Noema daemon: API server, web UI, scheduler, +// integration manager and cognitive workers in one process. +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/andydixon/noema/internal/api" + "github.com/andydixon/noema/internal/app" + "github.com/andydixon/noema/internal/audit" + "github.com/andydixon/noema/internal/auth" + "github.com/andydixon/noema/internal/config" + "github.com/andydixon/noema/internal/observability" + "github.com/andydixon/noema/internal/scheduler" + "github.com/andydixon/noema/internal/storage" + "github.com/andydixon/noema/internal/ui" + "github.com/andydixon/noema/internal/version" + "github.com/andydixon/noema/migrations" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, "noemad:", err) + os.Exit(1) + } +} + +func run() error { + var ( + cfgPath = flag.String("config", "", "path to JSON config file (optional)") + migrate = flag.Bool("migrate", true, "apply pending migrations at start") + show = flag.Bool("version", false, "print version and exit") + ) + flag.Parse() + if *show { + fmt.Println(version.Version) + return nil + } + cfg, err := config.Load(*cfgPath, os.LookupEnv) + if err != nil { + return err + } + log := observability.NewLogger(os.Stderr, cfg.LogLevel, cfg.LogFormat) + log.Info("starting noemad", "version", version.Version, "env", cfg.Env, "addr", cfg.HTTPAddr) + log.Debug("configuration", "config", fmt.Sprintf("%+v", cfg.Redacted())) + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + db, err := storage.Open(ctx, cfg.DatabaseURL) + if err != nil { + return err + } + defer db.Close() + if *migrate { + applied, err := storage.MigrateUp(ctx, db, migrations.FS) + if err != nil { + return fmt.Errorf("migrate: %w", err) + } + if len(applied) > 0 { + log.Info("applied migrations", "migrations", applied) + } + } + + metrics := observability.NewMetrics() + authSvc := auth.NewService(db, cfg.SessionIdleTTL, cfg.SessionAbsoluteTTL) + auditLog := audit.New(db, log) + deps := app.Deps{Config: cfg, Log: log, DB: db, Auth: authSvc, Audit: auditLog, Metrics: metrics, Migrations: migrations.FS, Started: time.Now()} + + if err := bootstrapAdmin(ctx, deps); err != nil { + return err + } + + var srv *api.Server + uiHandler, err := ui.New(deps, func(r *http.Request) any { return srv.Status(r.Context()) }) + if err != nil { + return err + } + srv = api.New(deps, uiHandler) + + group := scheduler.NewGroup(ctx, log) + httpSrv := &http.Server{Addr: cfg.HTTPAddr, Handler: srv.Handler(), ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, IdleTimeout: 120 * time.Second, MaxHeaderBytes: 64 << 10} + group.Go("http", func(ctx context.Context) error { + errc := make(chan error, 1) + go func() { errc <- httpSrv.ListenAndServe() }() // owned: joined via errc below + select { + case err := <-errc: + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return err + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + return httpSrv.Shutdown(shutdownCtx) + } + }) + group.Every("auth.sweep", 5*time.Minute, func(context.Context) error { authSvc.SweepLimiter(); return nil }) + group.Every("auth.purge", time.Hour, authSvc.PurgeExpiredSessions) + + log.Info("noemad ready", "url", "http://"+cfg.HTTPAddr) + <-ctx.Done() + log.Info("shutting down") + return group.Stop() +} + +// bootstrapAdmin creates the first administrator from the environment when the +// users table is empty, so headless deployments can start without the setup page. +func bootstrapAdmin(ctx context.Context, d app.Deps) error { + n, err := d.Auth.CountUsers(ctx) + if err != nil { + return err + } + if n > 0 || d.Config.BootstrapAdminPassword == "" { + return nil + } + u, err := d.Auth.CreateUser(ctx, d.Config.BootstrapAdminUser, d.Config.BootstrapAdminPassword, auth.RoleAdministrator) + if err != nil { + return fmt.Errorf("bootstrap admin: %w", err) + } + d.Audit.Log(ctx, "user.bootstrap", "user", u.ID.String(), map[string]any{"username": u.Username, "via": "environment"}) + d.Log.Info("created bootstrap administrator", "username", u.Username) + return nil +} diff --git a/go.mod b/go.mod index 741fc42..f877780 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.26.1 require ( github.com/jackc/pgx/v5 v5.11.0 github.com/prometheus/client_golang v1.24.1 + golang.org/x/crypto v0.57.0 ) require ( @@ -17,8 +18,8 @@ require ( github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.70.1 // indirect github.com/prometheus/procfs v0.21.1 // indirect - golang.org/x/sync v0.22.0 // indirect + golang.org/x/sync v0.23.0 // indirect golang.org/x/sys v0.48.0 // indirect - golang.org/x/text v0.40.0 // indirect + golang.org/x/text v0.42.0 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/go.sum b/go.sum index c0032fa..e4029b3 100644 --- a/go.sum +++ b/go.sum @@ -36,12 +36,14 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= -golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= -golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= -golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= -golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/api/api_test.go b/internal/api/api_test.go new file mode 100644 index 0000000..251d532 --- /dev/null +++ b/internal/api/api_test.go @@ -0,0 +1,81 @@ +package api + +import ( + "context" + "encoding/json" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/andydixon/noema/internal/app" + "github.com/andydixon/noema/internal/audit" + "github.com/andydixon/noema/internal/auth" + "github.com/andydixon/noema/internal/config" + "github.com/andydixon/noema/internal/observability" + "github.com/andydixon/noema/internal/storage" + "github.com/andydixon/noema/migrations" +) + +func testServer(t *testing.T) (*httptest.Server, string) { + t.Helper() + pool := storage.TestPool(t, migrations.FS) + cfg := config.Defaults() + cfg.DatabaseURL = "postgres://test" + log := slog.New(slog.DiscardHandler) + deps := app.Deps{Config: cfg, Log: log, DB: pool, Auth: auth.NewService(pool, time.Hour, 2*time.Hour), + Audit: audit.New(pool, log), Metrics: observability.NewMetrics(), Migrations: migrations.FS, Started: time.Now()} + ctx := context.Background() + u, err := deps.Auth.CreateUser(ctx, "admin", "administrator-pass", auth.RoleAdministrator) + if err != nil { + t.Fatal(err) + } + tok, _ := deps.Auth.CreateAPIToken(ctx, u.ID, "t", nil) + srv := httptest.NewServer(New(deps, nil).Handler()) + t.Cleanup(srv.Close) + return srv, tok +} + +func TestStatusAuthAndErrorEnvelope(t *testing.T) { + srv, tok := testServer(t) + res, _ := http.Get(srv.URL + "/api/v1/system/status") + if res.StatusCode != 401 { + t.Fatalf("expected 401, got %d", res.StatusCode) + } + var e ErrorBody + json.NewDecoder(res.Body).Decode(&e) //nolint:errcheck + if e.Error.Code != "unauthenticated" || e.Error.RequestID == "" { + t.Fatalf("bad error envelope: %+v", e) + } + req, _ := http.NewRequest("GET", srv.URL+"/api/v1/system/status", nil) + req.Header.Set("Authorization", "Bearer "+tok) + res, _ = http.DefaultClient.Do(req) + if res.StatusCode != 200 { + t.Fatalf("expected 200, got %d", res.StatusCode) + } + var st SystemStatus + json.NewDecoder(res.Body).Decode(&st) //nolint:errcheck + if !st.Database.OK || len(st.Migrations) == 0 { + t.Fatalf("status incomplete: %+v", st) + } + if res.Header.Get("Content-Security-Policy") == "" { + t.Fatal("missing CSP header") + } + + // Unknown JSON fields and body limits are rejected as 400. + req, _ = http.NewRequest("POST", srv.URL+"/api/v1/users", strings.NewReader(`{"username":"x","password":"long-enough-pass","role":"observer","bogus":1}`)) + req.Header.Set("Authorization", "Bearer "+tok) + res, _ = http.DefaultClient.Do(req) + if res.StatusCode != 400 { + t.Fatalf("expected 400 for unknown field, got %d", res.StatusCode) + } + // Bearer requests are exempt from CSRF but still role-checked. + req, _ = http.NewRequest("POST", srv.URL+"/api/v1/users", strings.NewReader(`{"username":"obs","password":"long-enough-pass","role":"observer"}`)) + req.Header.Set("Authorization", "Bearer "+tok) + res, _ = http.DefaultClient.Do(req) + if res.StatusCode != 201 { + t.Fatalf("expected 201, got %d", res.StatusCode) + } +} diff --git a/internal/api/errors_util.go b/internal/api/errors_util.go new file mode 100644 index 0000000..c0cf424 --- /dev/null +++ b/internal/api/errors_util.go @@ -0,0 +1,11 @@ +package api + +import ( + "errors" + + "github.com/andydixon/noema/internal/storage" +) + +func isStorageErr(err error) bool { + return errors.Is(err, storage.ErrNotFound) || errors.Is(err, storage.ErrConflict) +} diff --git a/internal/api/middleware.go b/internal/api/middleware.go new file mode 100644 index 0000000..c0adf0b --- /dev/null +++ b/internal/api/middleware.go @@ -0,0 +1,140 @@ +package api + +import ( + "context" + "crypto/rand" + "encoding/hex" + "log/slog" + "net/http" + "runtime/debug" + "strconv" + "time" + + "github.com/andydixon/noema/internal/audit" + "github.com/andydixon/noema/internal/auth" + "github.com/andydixon/noema/internal/observability" +) + +type requestIDKey struct{} + +// RequestID returns the id assigned to the request. +func RequestID(r *http.Request) string { + id, _ := r.Context().Value(requestIDKey{}).(string) + return id +} + +func logger(r *http.Request) *slog.Logger { return observability.Logger(r.Context()) } + +// chain applies middleware in the order given (first is outermost). +func chain(h http.Handler, mws ...func(http.Handler) http.Handler) http.Handler { + for i := len(mws) - 1; i >= 0; i-- { + h = mws[i](h) + } + return h +} + +func (s *Server) requestID(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var b [8]byte + rand.Read(b[:]) //nolint:errcheck + id := hex.EncodeToString(b[:]) + ip := auth.ClientIP(r, false) + ctx := context.WithValue(r.Context(), requestIDKey{}, id) + ctx = observability.WithLogger(ctx, s.deps.Log.With("request_id", id)) + ctx = audit.WithIP(ctx, ip) + w.Header().Set("X-Request-ID", id) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +type statusWriter struct { + http.ResponseWriter + status int +} + +func (w *statusWriter) WriteHeader(code int) { w.status = code; w.ResponseWriter.WriteHeader(code) } +func (w *statusWriter) Write(b []byte) (int, error) { + if w.status == 0 { + w.status = 200 + } + return w.ResponseWriter.Write(b) +} + +// Flush lets SSE handlers flush through the wrapper. +func (w *statusWriter) Flush() { + if f, ok := w.ResponseWriter.(http.Flusher); ok { + f.Flush() + } +} + +func (s *Server) logging(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + sw := &statusWriter{ResponseWriter: w} + next.ServeHTTP(sw, r) + route := r.Pattern + if route == "" { + route = "unmatched" + } + d := time.Since(start) + s.deps.Metrics.HTTPRequests.WithLabelValues(route, strconv.Itoa(sw.status)).Inc() + s.deps.Metrics.HTTPDuration.WithLabelValues(route).Observe(d.Seconds()) + logger(r).Debug("http", "method", r.Method, "path", r.URL.Path, "status", sw.status, "ms", d.Milliseconds()) + }) +} + +func (s *Server) recoverer(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if rec := recover(); rec != nil { + if rec == http.ErrAbortHandler { + panic(rec) + } + logger(r).Error("handler panicked", "panic", rec, "stack", string(debug.Stack())) + writeError(w, r, http.StatusInternalServerError, "internal", "internal error") + } + }() + next.ServeHTTP(w, r) + }) +} + +// securityHeaders sets a strict CSP (no inline scripts, self only) and the +// usual hardening headers (T10). +func (s *Server) securityHeaders(next http.Handler) http.Handler { + csp := "default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'" + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := w.Header() + h.Set("Content-Security-Policy", csp) + h.Set("X-Content-Type-Options", "nosniff") + h.Set("X-Frame-Options", "DENY") + h.Set("Referrer-Policy", "same-origin") + h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()") + if s.deps.Config.Production() { + h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") + } + next.ServeHTTP(w, r) + }) +} + +func (s *Server) bodyLimit(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Body != nil { + r.Body = http.MaxBytesReader(w, r.Body, s.deps.Config.MaxRequestBody) + } + next.ServeHTTP(w, r) + }) +} + +// apiAuthFail is the RequireRole failure handler for JSON routes. +func apiAuthFail(w http.ResponseWriter, r *http.Request, authenticated bool) { + if authenticated { + writeError(w, r, http.StatusForbidden, "forbidden", "insufficient role") + return + } + writeError(w, r, http.StatusUnauthorized, "unauthenticated", "authentication required") +} + +// require wraps a handler with a role check for API routes. +func require(min auth.Role, h http.HandlerFunc) http.Handler { + return auth.RequireRole(min, apiAuthFail)(h) +} diff --git a/internal/api/respond.go b/internal/api/respond.go new file mode 100644 index 0000000..00bc0b3 --- /dev/null +++ b/internal/api/respond.go @@ -0,0 +1,75 @@ +// Package api serves the versioned JSON API under /api/v1 and composes the +// middleware chain shared with the web UI. +package api + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/andydixon/noema/internal/storage" +) + +// ErrorBody is the single error envelope used by every endpoint. +type ErrorBody struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + RequestID string `json:"request_id,omitempty"` + } `json:"error"` +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + enc := json.NewEncoder(w) + enc.SetEscapeHTML(true) + enc.Encode(v) //nolint:errcheck // client gone; nothing to do +} + +func writeError(w http.ResponseWriter, r *http.Request, status int, code, msg string) { + var b ErrorBody + b.Error.Code, b.Error.Message, b.Error.RequestID = code, msg, RequestID(r) + writeJSON(w, status, b) +} + +// fail maps common errors to HTTP statuses; unknown errors become 500 without +// leaking internals. +func fail(w http.ResponseWriter, r *http.Request, err error) { + switch { + case errors.Is(err, storage.ErrNotFound): + writeError(w, r, http.StatusNotFound, "not_found", "resource not found") + case errors.Is(err, storage.ErrConflict): + writeError(w, r, http.StatusConflict, "conflict", "resource already exists") + default: + var ve ValidationError + if errors.As(err, &ve) { + writeError(w, r, http.StatusBadRequest, "invalid", ve.Error()) + return + } + logger(r).Error("request failed", "err", err) + writeError(w, r, http.StatusInternalServerError, "internal", "internal error") + } +} + +// ValidationError marks user-correctable input problems. +type ValidationError string + +func (v ValidationError) Error() string { return string(v) } + +// decode reads a JSON body with unknown fields rejected and size already +// limited by middleware. +func decode(r *http.Request, v any) error { + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + if err := dec.Decode(v); err != nil { + return ValidationError("invalid JSON body: " + err.Error()) + } + return nil +} + +// page is the standard list envelope. +type page[T any] struct { + Items []T `json:"items"` + NextCursor string `json:"next_cursor,omitempty"` +} diff --git a/internal/api/server.go b/internal/api/server.go new file mode 100644 index 0000000..7e13921 --- /dev/null +++ b/internal/api/server.go @@ -0,0 +1,57 @@ +package api + +import ( + "net/http" + + "github.com/prometheus/client_golang/prometheus/promhttp" + + "github.com/andydixon/noema/internal/app" + "github.com/andydixon/noema/internal/auth" +) + +// Server composes the API routes, the web UI and the middleware chain. +type Server struct { + deps app.Deps + mux *http.ServeMux +} + +// New builds the server. ui registers the HTML routes on the same mux so that +// a single middleware chain (auth, CSRF, headers, limits) covers both. +func New(deps app.Deps, ui interface{ Register(*http.ServeMux) }) *Server { + s := &Server{deps: deps, mux: http.NewServeMux()} + s.routes() + if ui != nil { + ui.Register(s.mux) + } + return s +} + +// Handler returns the fully wrapped root handler. +func (s *Server) Handler() http.Handler { + return chain(s.mux, s.requestID, s.recoverer, s.securityHeaders, s.bodyLimit, s.logging, s.deps.Auth.Middleware) +} + +func (s *Server) routes() { + m := s.mux + m.HandleFunc("GET /healthz", s.healthz) + m.HandleFunc("GET /readyz", s.readyz) + // Metrics need a token with at least observer role; Prometheus supports bearer_token. + m.Handle("GET /metrics", require(auth.RoleObserver, promhttp.HandlerFor(s.deps.Metrics.Registry, promhttp.HandlerOpts{}).ServeHTTP)) + + m.Handle("GET /api/v1/system/status", require(auth.RoleObserver, s.systemStatus)) + m.Handle("GET /api/v1/audit", require(auth.RoleOperator, s.auditList)) + + m.Handle("GET /api/v1/users", require(auth.RoleAdministrator, s.usersList)) + m.Handle("POST /api/v1/users", require(auth.RoleAdministrator, s.usersCreate)) + m.Handle("PATCH /api/v1/users/{id}", require(auth.RoleAdministrator, s.usersPatch)) + m.Handle("GET /api/v1/me", require(auth.RoleObserver, s.me)) + m.Handle("GET /api/v1/me/sessions", require(auth.RoleObserver, s.mySessions)) + m.Handle("DELETE /api/v1/me/sessions/{id}", require(auth.RoleObserver, s.mySessionRevoke)) + m.Handle("POST /api/v1/me/tokens", require(auth.RoleObserver, s.myTokenCreate)) + m.Handle("GET /api/v1/me/tokens", require(auth.RoleObserver, s.myTokens)) + m.Handle("DELETE /api/v1/me/tokens/{id}", require(auth.RoleObserver, s.myTokenRevoke)) + + m.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { + writeError(w, r, http.StatusNotFound, "not_found", "no such endpoint") + }) +} diff --git a/internal/api/system.go b/internal/api/system.go new file mode 100644 index 0000000..6884d64 --- /dev/null +++ b/internal/api/system.go @@ -0,0 +1,99 @@ +package api + +import ( + "context" + "net/http" + "runtime" + "strconv" + "time" + + "github.com/andydixon/noema/internal/storage" + "github.com/andydixon/noema/internal/version" +) + +// SystemStatus is the payload of GET /api/v1/system/status. +type SystemStatus struct { + Version string `json:"version"` + UptimeSeconds int64 `json:"uptime_seconds"` + Database DatabaseStatus `json:"database"` + Migrations []MigrationInfo `json:"migrations"` + Goroutines int `json:"goroutines"` + HeapBytes uint64 `json:"heap_bytes"` + SysBytes uint64 `json:"sys_bytes"` + NumCPU int `json:"num_cpu"` + GoVersion string `json:"go_version"` +} + +// DatabaseStatus reports connectivity and pool usage. +type DatabaseStatus struct { + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + Version string `json:"version,omitempty"` + PoolTotal int32 `json:"pool_total"` + PoolIdle int32 `json:"pool_idle"` + PoolMax int32 `json:"pool_max"` + PGVector bool `json:"pgvector"` +} + +// MigrationInfo is one migration's state. +type MigrationInfo struct { + Version int `json:"version"` + Name string `json:"name"` + Applied bool `json:"applied"` + AppliedAt string `json:"applied_at,omitempty"` + Drift bool `json:"drift"` +} + +// Status collects the system status; shared with the UI system page. +func (s *Server) Status(ctx context.Context) SystemStatus { + var ms runtime.MemStats + runtime.ReadMemStats(&ms) + st := SystemStatus{Version: version.Version, UptimeSeconds: int64(time.Since(s.deps.Started).Seconds()), + Goroutines: runtime.NumGoroutine(), HeapBytes: ms.HeapAlloc, SysBytes: ms.Sys, NumCPU: runtime.NumCPU(), GoVersion: runtime.Version()} + ctx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + if err := s.deps.DB.QueryRow(ctx, `SELECT version()`).Scan(&st.Database.Version); err != nil { + st.Database.Error = err.Error() + } else { + st.Database.OK = true + s.deps.DB.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname='vector')`).Scan(&st.Database.PGVector) //nolint:errcheck + } + stat := s.deps.DB.Stat() + st.Database.PoolTotal, st.Database.PoolIdle, st.Database.PoolMax = stat.TotalConns(), stat.IdleConns(), stat.MaxConns() + if rows, err := storage.Status(ctx, s.deps.DB, s.deps.Migrations); err == nil { + for _, m := range rows { + st.Migrations = append(st.Migrations, MigrationInfo{Version: m.Version, Name: m.Name, Applied: m.Applied, AppliedAt: m.AppliedAt, Drift: m.Drift}) + } + } + return st +} + +func (s *Server) systemStatus(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, s.Status(r.Context())) +} + +func (s *Server) healthz(w http.ResponseWriter, _ *http.Request) { + w.Write([]byte("ok")) //nolint:errcheck +} + +func (s *Server) readyz(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) + defer cancel() + if err := s.deps.DB.Ping(ctx); err != nil { + http.Error(w, "database unavailable", http.StatusServiceUnavailable) + return + } + w.Write([]byte("ready")) //nolint:errcheck +} + +func (s *Server) auditList(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + before, _ := strconv.ParseInt(q.Get("before"), 10, 64) + limit, _ := strconv.Atoi(q.Get("limit")) + entries, err := s.deps.Audit.List(r.Context(), before, limit, q.Get("target_kind"), q.Get("target_id")) + if err != nil { + fail(w, r, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"items": entries}) +} diff --git a/internal/api/users.go b/internal/api/users.go new file mode 100644 index 0000000..72788c2 --- /dev/null +++ b/internal/api/users.go @@ -0,0 +1,199 @@ +package api + +import ( + "net/http" + "time" + + "github.com/andydixon/noema/internal/auth" + "github.com/andydixon/noema/pkg/ids" +) + +type userOut struct { + ID ids.ID `json:"id"` + Username string `json:"username"` + Role auth.Role `json:"role"` + Disabled bool `json:"disabled"` + TOTPEnabled bool `json:"totp_enabled"` + CreatedAt time.Time `json:"created_at"` +} + +func toUserOut(u auth.User) userOut { + return userOut{ID: u.ID, Username: u.Username, Role: u.Role, Disabled: u.Disabled, TOTPEnabled: u.TOTPEnabled, CreatedAt: u.CreatedAt} +} + +func (s *Server) usersList(w http.ResponseWriter, r *http.Request) { + users, err := s.deps.Auth.ListUsers(r.Context()) + if err != nil { + fail(w, r, err) + return + } + out := make([]userOut, 0, len(users)) + for _, u := range users { + out = append(out, toUserOut(u)) + } + writeJSON(w, http.StatusOK, page[userOut]{Items: out}) +} + +func (s *Server) usersCreate(w http.ResponseWriter, r *http.Request) { + var in struct { + Username string `json:"username"` + Password string `json:"password"` + Role auth.Role `json:"role"` + } + if err := decode(r, &in); err != nil { + fail(w, r, err) + return + } + u, err := s.deps.Auth.CreateUser(r.Context(), in.Username, in.Password, in.Role) + if err != nil { + fail(w, r, wrapInput(err)) + return + } + s.deps.Audit.Log(r.Context(), "user.create", "user", u.ID.String(), map[string]any{"username": u.Username, "role": u.Role}) + writeJSON(w, http.StatusCreated, toUserOut(u)) +} + +func (s *Server) usersPatch(w http.ResponseWriter, r *http.Request) { + id, err := ids.Parse(r.PathValue("id")) + if err != nil { + fail(w, r, ValidationError("invalid id")) + return + } + var in struct { + Role *auth.Role `json:"role"` + Disabled *bool `json:"disabled"` + } + if err := decode(r, &in); err != nil { + fail(w, r, err) + return + } + p, _ := auth.PrincipalFrom(r.Context()) + if p.User.ID == id && ((in.Disabled != nil && *in.Disabled) || (in.Role != nil && *in.Role != auth.RoleAdministrator)) { + fail(w, r, ValidationError("you cannot disable or demote your own account")) + return + } + if in.Role != nil { + if err := s.deps.Auth.SetUserRole(r.Context(), id, *in.Role); err != nil { + fail(w, r, wrapInput(err)) + return + } + s.deps.Audit.Log(r.Context(), "user.role", "user", id.String(), map[string]any{"role": *in.Role}) + } + if in.Disabled != nil { + if err := s.deps.Auth.SetUserDisabled(r.Context(), id, *in.Disabled); err != nil { + fail(w, r, err) + return + } + s.deps.Audit.Log(r.Context(), "user.disabled", "user", id.String(), map[string]any{"disabled": *in.Disabled}) + } + u, err := s.deps.Auth.GetUser(r.Context(), id) + if err != nil { + fail(w, r, err) + return + } + writeJSON(w, http.StatusOK, toUserOut(u)) +} + +func (s *Server) me(w http.ResponseWriter, r *http.Request) { + p, _ := auth.PrincipalFrom(r.Context()) + writeJSON(w, http.StatusOK, toUserOut(p.User)) +} + +func (s *Server) mySessions(w http.ResponseWriter, r *http.Request) { + p, _ := auth.PrincipalFrom(r.Context()) + list, err := s.deps.Auth.ListSessions(r.Context(), p.User.ID) + if err != nil { + fail(w, r, err) + return + } + type out struct { + ID ids.ID `json:"id"` + Current bool `json:"current"` + CreatedAt time.Time `json:"created_at"` + LastSeenAt time.Time `json:"last_seen_at"` + ExpiresAt time.Time `json:"expires_at"` + IP string `json:"ip"` + UserAgent string `json:"user_agent"` + } + items := make([]out, 0, len(list)) + for _, x := range list { + items = append(items, out{x.ID, x.ID == p.SessionID, x.CreatedAt, x.LastSeenAt, x.ExpiresAt, x.IP, x.UserAgent}) + } + writeJSON(w, http.StatusOK, page[out]{Items: items}) +} + +func (s *Server) mySessionRevoke(w http.ResponseWriter, r *http.Request) { + p, _ := auth.PrincipalFrom(r.Context()) + id, err := ids.Parse(r.PathValue("id")) + if err != nil { + fail(w, r, ValidationError("invalid id")) + return + } + if err := s.deps.Auth.RevokeSession(r.Context(), p.User.ID, id); err != nil { + fail(w, r, err) + return + } + s.deps.Audit.Log(r.Context(), "session.revoke", "session", id.String(), nil) + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) myTokenCreate(w http.ResponseWriter, r *http.Request) { + p, _ := auth.PrincipalFrom(r.Context()) + var in struct { + Name string `json:"name"` + ExpiresAt *time.Time `json:"expires_at"` + } + if err := decode(r, &in); err != nil { + fail(w, r, err) + return + } + if in.Name == "" { + fail(w, r, ValidationError("name is required")) + return + } + tok, err := s.deps.Auth.CreateAPIToken(r.Context(), p.User.ID, in.Name, in.ExpiresAt) + if err != nil { + fail(w, r, err) + return + } + s.deps.Audit.Log(r.Context(), "token.create", "user", p.User.ID.String(), map[string]any{"name": in.Name}) + writeJSON(w, http.StatusCreated, map[string]string{"token": tok, "note": "store this now; it is not shown again"}) +} + +func (s *Server) myTokens(w http.ResponseWriter, r *http.Request) { + p, _ := auth.PrincipalFrom(r.Context()) + list, err := s.deps.Auth.ListAPITokens(r.Context(), p.User.ID) + if err != nil { + fail(w, r, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"items": list}) +} + +func (s *Server) myTokenRevoke(w http.ResponseWriter, r *http.Request) { + p, _ := auth.PrincipalFrom(r.Context()) + id, err := ids.Parse(r.PathValue("id")) + if err != nil { + fail(w, r, ValidationError("invalid id")) + return + } + if err := s.deps.Auth.RevokeAPIToken(r.Context(), p.User.ID, id); err != nil { + fail(w, r, err) + return + } + s.deps.Audit.Log(r.Context(), "token.revoke", "token", id.String(), nil) + w.WriteHeader(http.StatusNoContent) +} + +// wrapInput turns validation-style errors from services into 400s. Services +// return plain errors for bad input; anything wrapping storage sentinels is +// left alone. +func wrapInput(err error) error { + if err == nil { + return nil + } + if isStorageErr(err) { + return err + } + return ValidationError(err.Error()) +} diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..d08c530 --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,29 @@ +// Package app holds the dependency bundle shared by the HTTP API, the web UI +// and the CLI-facing services. It exists so that constructors take one typed +// value instead of a growing parameter list, without resorting to globals. +package app + +import ( + "io/fs" + "log/slog" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/andydixon/noema/internal/audit" + "github.com/andydixon/noema/internal/auth" + "github.com/andydixon/noema/internal/config" + "github.com/andydixon/noema/internal/observability" +) + +// Deps is everything a handler may need. Fields are added per phase. +type Deps struct { + Config config.Config + Log *slog.Logger + DB *pgxpool.Pool + Auth *auth.Service + Audit *audit.Logger + Metrics *observability.Metrics + Migrations fs.FS + Started time.Time +} diff --git a/internal/audit/audit.go b/internal/audit/audit.go new file mode 100644 index 0000000..0be2ca0 --- /dev/null +++ b/internal/audit/audit.go @@ -0,0 +1,103 @@ +// Package audit records administrative actions in the append-only audit_log. +// Every mutation performed by an operator through the UI, API or CLI goes +// through Log so that the Audit page is complete. +package audit + +import ( + "context" + "encoding/json" + "log/slog" + + "github.com/andydixon/noema/internal/auth" + "github.com/andydixon/noema/internal/storage" + "github.com/andydixon/noema/pkg/ids" +) + +// Entry is one audit record. +type Entry struct { + ID ids.ID `json:"id"` + Seq int64 `json:"seq"` + At string `json:"at"` + ActorKind string `json:"actor_kind"` + ActorID string `json:"actor_id"` + ActorName string `json:"actor_name"` + Action string `json:"action"` + TargetKind string `json:"target_kind"` + TargetID string `json:"target_id"` + Details json.RawMessage `json:"details"` + IP string `json:"ip"` +} + +// Logger writes audit entries. +type Logger struct { + db storage.Querier + log *slog.Logger +} + +// New creates an audit logger. +func New(db storage.Querier, log *slog.Logger) *Logger { return &Logger{db: db, log: log} } + +// Log records an action. The actor is taken from the request principal when +// present, otherwise the action is attributed to the system. Failures are +// logged but never block the underlying operation. Details must not contain +// secrets; callers pass structured, already-redacted values. +func (l *Logger) Log(ctx context.Context, action, targetKind, targetID string, details any) { + l.LogAs(ctx, "", "", action, targetKind, targetID, details) +} + +// LogAs records an action on behalf of a named actor kind ("mind" for +// cognitive actions surfaced to the audit trail) when no principal applies. +func (l *Logger) LogAs(ctx context.Context, actorKind, actorID, action, targetKind, targetID string, details any) { + kind, id, name, ip := "system", "", "", "" + if p, ok := auth.PrincipalFrom(ctx); ok { + kind, id, name = "user", p.User.ID.String(), p.User.Username + } + if actorKind != "" { + kind, id = actorKind, actorID + } + if v, ok := ctx.Value(ipKey{}).(string); ok { + ip = v + } + if details == nil { + details = map[string]any{} + } + raw, err := json.Marshal(details) + if err != nil { + raw = []byte(`{"marshal_error":true}`) + } + _, err = l.db.Exec(ctx, `INSERT INTO audit_log (id, actor_kind, actor_id, actor_name, action, target_kind, target_id, details, ip) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, ids.New(), kind, id, name, action, targetKind, targetID, raw, ip) + if err != nil { + l.log.Error("audit write failed", "action", action, "err", err) + } +} + +type ipKey struct{} + +// WithIP attaches the client address so entries record where actions came from. +func WithIP(ctx context.Context, ip string) context.Context { + return context.WithValue(ctx, ipKey{}, ip) +} + +// List returns entries newest first, paginated by seq cursor (0 = latest). +func (l *Logger) List(ctx context.Context, beforeSeq int64, limit int, targetKind, targetID string) ([]Entry, error) { + if limit <= 0 || limit > 500 { + limit = 100 + } + rows, err := l.db.Query(ctx, `SELECT id, seq, at::text, actor_kind, actor_id, actor_name, action, target_kind, target_id, details, ip + FROM audit_log WHERE ($1 = 0 OR seq < $1) AND ($3 = '' OR target_kind = $3) AND ($4 = '' OR target_id = $4) + ORDER BY seq DESC LIMIT $2`, beforeSeq, limit, targetKind, targetID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Entry + for rows.Next() { + var e Entry + if err := rows.Scan(&e.ID, &e.Seq, &e.At, &e.ActorKind, &e.ActorID, &e.ActorName, &e.Action, &e.TargetKind, &e.TargetID, &e.Details, &e.IP); err != nil { + return nil, err + } + out = append(out, e) + } + return out, rows.Err() +} diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 0000000..980f574 --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,174 @@ +package auth + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/andydixon/noema/internal/storage" + "github.com/andydixon/noema/migrations" +) + +func TestPasswordHashing(t *testing.T) { + h, err := HashPassword("correct horse battery") + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(h, "$argon2id$") { + t.Fatal("not argon2id") + } + if ok, _ := VerifyPassword(h, "correct horse battery"); !ok { + t.Fatal("should verify") + } + if ok, _ := VerifyPassword(h, "wrong"); ok { + t.Fatal("should not verify") + } + if _, err := HashPassword("short"); err == nil { + t.Fatal("short password accepted") + } +} + +func TestLockoutSchedule(t *testing.T) { + if lockoutDuration(4) != 0 || lockoutDuration(5) != 30*time.Second || lockoutDuration(7) != 2*time.Minute || lockoutDuration(40) != time.Hour { + t.Fatal("unexpected lockout schedule") + } +} + +func TestIPLimiter(t *testing.T) { + l := newIPLimiter(60, 3) + now := time.Now() + l.now = func() time.Time { return now } + for i := 0; i < 3; i++ { + if !l.Allow("a") { + t.Fatal("burst should allow") + } + } + if l.Allow("a") { + t.Fatal("should be limited") + } + if !l.Allow("b") { + t.Fatal("other key unaffected") + } + now = now.Add(2 * time.Second) + if !l.Allow("a") { + t.Fatal("should refill") + } +} + +func TestLoginSessionLifecycle(t *testing.T) { + pool := storage.TestPool(t, migrations.FS) + ctx := context.Background() + svc := NewService(pool, time.Hour, 2*time.Hour) + if _, err := svc.CreateUser(ctx, "andy", "a very long password", RoleAdministrator); err != nil { + t.Fatal(err) + } + if _, err := svc.CreateUser(ctx, "ANDY", "a very long password", RoleObserver); !errors.Is(err, storage.ErrConflict) { + t.Fatalf("expected conflict on case-insensitive duplicate, got %v", err) + } + if _, _, err := svc.Login(ctx, "andy", "nope nope nope", "10.0.0.1", "t"); !errors.Is(err, ErrInvalidCredentials) { + t.Fatalf("expected invalid credentials, got %v", err) + } + sess, token, err := svc.Login(ctx, "Andy", "a very long password", "10.0.0.1", "t") + if err != nil { + t.Fatal(err) + } + p, err := svc.Authenticate(ctx, token) + if err != nil || p.User.Username != "andy" || p.SessionID != sess.ID || !p.ViaCookie { + t.Fatalf("authenticate: %v %+v", err, p) + } + if _, err := svc.Authenticate(ctx, "bogus"); !errors.Is(err, ErrUnauthenticated) { + t.Fatal("bogus token accepted") + } + + // Lockout after repeated failures. + for i := 0; i < 5; i++ { + svc.Login(ctx, "andy", "wrong wrong wrong", "10.0.0.2", "t") //nolint:errcheck + } + if _, _, err := svc.Login(ctx, "andy", "a very long password", "10.0.0.2", "t"); !errors.Is(err, ErrLocked) { + t.Fatalf("expected lock, got %v", err) + } + + // Idle expiry. + svc.now = func() time.Time { return time.Now().Add(61 * time.Minute) } + if _, err := svc.Authenticate(ctx, token); !errors.Is(err, ErrUnauthenticated) { + t.Fatal("expired session accepted") + } + svc.now = time.Now + + // API token. + raw, err := svc.CreateAPIToken(ctx, p.User.ID, "cli", nil) + if err != nil { + t.Fatal(err) + } + ap, err := svc.AuthenticateToken(ctx, strings.TrimPrefix(raw, "noema_")) + if err != nil || ap.ViaCookie || ap.User.Role != RoleAdministrator { + t.Fatalf("api token: %v %+v", err, ap) + } + + // Disabling revokes sessions. + sess2, token2, _ := svc.Login(ctx, "andy", "a very long password", "10.0.0.3", "t") + _ = sess2 + if err := svc.SetUserDisabled(ctx, p.User.ID, true); err != nil { + t.Fatal(err) + } + if _, err := svc.Authenticate(ctx, token2); err == nil { + t.Fatal("disabled user session accepted") + } +} + +func TestMiddlewareCSRFAndRoles(t *testing.T) { + pool := storage.TestPool(t, migrations.FS) + ctx := context.Background() + svc := NewService(pool, time.Hour, 2*time.Hour) + svc.CreateUser(ctx, "op", "a very long password", RoleOperator) //nolint:errcheck + sess, token, err := svc.Login(ctx, "op", "a very long password", "127.0.0.1", "t") + if err != nil { + t.Fatal(err) + } + var failed bool + onFail := func(w http.ResponseWriter, r *http.Request, authed bool) { failed = true; w.WriteHeader(401) } + ok := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }) + h := svc.Middleware(RequireRole(RoleOperator, onFail)(ok)) + adminOnly := svc.Middleware(RequireRole(RoleAdministrator, onFail)(ok)) + + do := func(handler http.Handler, method, csrf, origin string, cookie bool) int { + req := httptest.NewRequest(method, "http://noema.local/x", nil) + if cookie { + req.AddCookie(&http.Cookie{Name: CookieName, Value: token}) + } + if csrf != "" { + req.Header.Set(CSRFHeader, csrf) + } + if origin != "" { + req.Header.Set("Origin", origin) + } + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + return rec.Code + } + if do(h, "GET", "", "", false) != 401 || !failed { + t.Fatal("unauthenticated GET should fail") + } + if do(h, "GET", "", "", true) != 200 { + t.Fatal("authenticated GET should pass") + } + if do(h, "POST", "", "", true) != 403 { + t.Fatal("POST without CSRF should be forbidden") + } + if do(h, "POST", sess.CSRFToken, "", true) != 200 { + t.Fatal("POST with CSRF should pass") + } + if do(h, "POST", sess.CSRFToken, "http://evil.example", true) != 403 { + t.Fatal("cross-origin POST should be forbidden") + } + if do(h, "POST", "wrong", "", true) != 403 { + t.Fatal("wrong CSRF should be forbidden") + } + if do(adminOnly, "GET", "", "", true) != 401 { + t.Fatal("operator should not reach admin route") + } +} diff --git a/internal/auth/limiter.go b/internal/auth/limiter.go new file mode 100644 index 0000000..522a9e0 --- /dev/null +++ b/internal/auth/limiter.go @@ -0,0 +1,76 @@ +package auth + +import ( + "sync" + "time" +) + +// ipLimiter is a token bucket per client address for login attempts. It lives +// in memory because it only needs to slow down a single daemon's attackers; +// per-account lockout is persisted in the database. +type ipLimiter struct { + mu sync.Mutex + buckets map[string]*bucket + rate float64 // tokens per second + burst float64 + now func() time.Time +} + +type bucket struct { + tokens float64 + last time.Time +} + +func newIPLimiter(perMinute, burst int) *ipLimiter { + return &ipLimiter{buckets: map[string]*bucket{}, rate: float64(perMinute) / 60, burst: float64(burst), now: time.Now} +} + +// Allow consumes a token for key if one is available. +func (l *ipLimiter) Allow(key string) bool { + l.mu.Lock() + defer l.mu.Unlock() + now := l.now() + b := l.buckets[key] + if b == nil { + b = &bucket{tokens: l.burst, last: now} + l.buckets[key] = b + } + b.tokens += now.Sub(b.last).Seconds() * l.rate + if b.tokens > l.burst { + b.tokens = l.burst + } + b.last = now + if b.tokens < 1 { + return false + } + b.tokens-- + return true +} + +// Sweep drops buckets that have refilled, bounding memory (T18). +func (l *ipLimiter) Sweep() { + l.mu.Lock() + defer l.mu.Unlock() + now := l.now() + for k, b := range l.buckets { + if b.tokens+now.Sub(b.last).Seconds()*l.rate >= l.burst { + delete(l.buckets, k) + } + } +} + +// lockoutDuration grows exponentially with consecutive failures, capped at +// one hour, and starts after five failures so typos are not punished. +func lockoutDuration(failed int) time.Duration { + if failed < 5 { + return 0 + } + if failed > 12 { // 2^7 * 30s already exceeds the cap; avoid shift overflow + return time.Hour + } + d := time.Duration(1< time.Hour { + d = time.Hour + } + return d +} diff --git a/internal/auth/middleware.go b/internal/auth/middleware.go new file mode 100644 index 0000000..8e35341 --- /dev/null +++ b/internal/auth/middleware.go @@ -0,0 +1,136 @@ +package auth + +import ( + "context" + "crypto/subtle" + "net" + "net/http" + "net/url" + "strings" +) + +// CookieName is the session cookie. +const CookieName = "noema_session" + +// CSRFHeader carries the CSRF token for fetch/XHR requests; forms use the +// hidden field named CSRFField. +const ( + CSRFHeader = "X-CSRF-Token" + CSRFField = "_csrf" +) + +type principalKey struct{} + +// PrincipalFrom returns the authenticated principal, if any. +func PrincipalFrom(ctx context.Context) (Principal, bool) { + p, ok := ctx.Value(principalKey{}).(Principal) + return p, ok +} + +// WithPrincipal is exported for tests and internal callers that construct contexts. +func WithPrincipal(ctx context.Context, p Principal) context.Context { + return context.WithValue(ctx, principalKey{}, p) +} + +// Middleware resolves the session cookie or bearer token into a Principal and +// enforces CSRF for cookie sessions on unsafe methods. Unauthenticated requests +// pass through without a principal; route guards decide what to do. +func (s *Service) Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var p Principal + var authed bool + if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer noema_") { + if pp, err := s.AuthenticateToken(r.Context(), strings.TrimPrefix(h, "Bearer noema_")); err == nil { + p, authed = pp, true + } + } else if c, err := r.Cookie(CookieName); err == nil && c.Value != "" { + if pp, err := s.Authenticate(r.Context(), c.Value); err == nil { + p, authed = pp, true + } + } + if authed && p.ViaCookie && !safeMethod(r.Method) { + if !s.csrfOK(r, p) { + http.Error(w, "CSRF token missing or invalid", http.StatusForbidden) + return + } + } + if authed { + r = r.WithContext(WithPrincipal(r.Context(), p)) + } + next.ServeHTTP(w, r) + }) +} + +func safeMethod(m string) bool { + return m == http.MethodGet || m == http.MethodHead || m == http.MethodOptions +} + +func (s *Service) csrfOK(r *http.Request, p Principal) bool { + // Origin/Referer must match the host when present (defence in depth). + if o := r.Header.Get("Origin"); o != "" { + if u, err := url.Parse(o); err != nil || !strings.EqualFold(u.Host, r.Host) { + return false + } + } else if ref := r.Header.Get("Referer"); ref != "" { + if u, err := url.Parse(ref); err != nil || !strings.EqualFold(u.Host, r.Host) { + return false + } + } + want, err := s.CSRFToken(r.Context(), p.SessionID) + if err != nil { + return false + } + got := r.Header.Get(CSRFHeader) + if got == "" { + if ct := r.Header.Get("Content-Type"); strings.HasPrefix(ct, "application/x-www-form-urlencoded") || strings.HasPrefix(ct, "multipart/form-data") { + got = r.PostFormValue(CSRFField) + } + } + return got != "" && subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1 +} + +// RequireRole returns middleware that rejects requests below min. onFail is +// invoked for unauthenticated or under-privileged requests so the UI can +// redirect to login while the API returns JSON. +func RequireRole(min Role, onFail func(w http.ResponseWriter, r *http.Request, authenticated bool)) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + p, ok := PrincipalFrom(r.Context()) + if !ok { + onFail(w, r, false) + return + } + if !p.User.Role.AtLeast(min) { + onFail(w, r, true) + return + } + next.ServeHTTP(w, r) + }) + } +} + +// ClientIP extracts the peer address. Proxy headers are deliberately ignored +// unless the daemon is told it sits behind a trusted proxy; secure default. +func ClientIP(r *http.Request, trustProxy bool) string { + if trustProxy { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + return strings.TrimSpace(strings.Split(xff, ",")[0]) + } + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} + +// SetSessionCookie writes the session cookie with secure attributes. +func SetSessionCookie(w http.ResponseWriter, token string, secure bool, maxAge int) { + http.SetCookie(w, &http.Cookie{Name: CookieName, Value: token, Path: "/", HttpOnly: true, Secure: secure, + SameSite: http.SameSiteLaxMode, MaxAge: maxAge}) +} + +// ClearSessionCookie expires the session cookie. +func ClearSessionCookie(w http.ResponseWriter, secure bool) { + SetSessionCookie(w, "", secure, -1) +} diff --git a/internal/auth/password.go b/internal/auth/password.go new file mode 100644 index 0000000..84ef2a1 --- /dev/null +++ b/internal/auth/password.go @@ -0,0 +1,72 @@ +// Package auth implements local authentication: Argon2id passwords, opaque +// sessions, CSRF tokens, login throttling, roles and HTTP middleware +// (ADR-0006). +package auth + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "errors" + "fmt" + "strings" + + "golang.org/x/crypto/argon2" +) + +// Argon2id parameters: 64 MiB, 3 passes, 2 lanes. Roughly 100 ms on the +// development machine, which throttles offline guessing without hurting login. +const ( + argonTime = 3 + argonMemory = 64 * 1024 + argonThreads = 2 + argonKeyLen = 32 + saltLen = 16 +) + +// MinPasswordLength is the shortest password accepted anywhere. +const MinPasswordLength = 12 + +// HashPassword returns a PHC-formatted Argon2id hash. +func HashPassword(password string) (string, error) { + if len(password) < MinPasswordLength { + return "", fmt.Errorf("password must be at least %d characters", MinPasswordLength) + } + salt := make([]byte, saltLen) + if _, err := rand.Read(salt); err != nil { + return "", err + } + key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen) + return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s", argonMemory, argonTime, argonThreads, + base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(key)), nil +} + +// VerifyPassword reports whether password matches the PHC hash. It parses the +// parameters from the hash so they can be raised later without invalidating +// existing users. +func VerifyPassword(hash, password string) (bool, error) { + parts := strings.Split(hash, "$") + if len(parts) != 6 || parts[1] != "argon2id" { + return false, errors.New("unsupported hash format") + } + var m uint32 + var t uint32 + var p uint8 + if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &m, &t, &p); err != nil { + return false, fmt.Errorf("bad hash params: %w", err) + } + salt, err := base64.RawStdEncoding.DecodeString(parts[4]) + if err != nil { + return false, err + } + want, err := base64.RawStdEncoding.DecodeString(parts[5]) + if err != nil { + return false, err + } + got := argon2.IDKey([]byte(password), salt, t, m, p, uint32(len(want))) + return subtle.ConstantTimeCompare(got, want) == 1, nil +} + +// dummyHash is verified against when a username does not exist so that the +// response time does not reveal whether the account exists. +var dummyHash, _ = HashPassword("noema-timing-equalisation-password") diff --git a/internal/auth/role.go b/internal/auth/role.go new file mode 100644 index 0000000..1dc75bd --- /dev/null +++ b/internal/auth/role.go @@ -0,0 +1,22 @@ +package auth + +// Role is a coarse permission level. Higher roles include lower ones. +type Role string + +// Roles in ascending order of privilege. +const ( + RoleObserver Role = "observer" + RoleOperator Role = "operator" + RoleAdministrator Role = "administrator" +) + +var roleRank = map[Role]int{RoleObserver: 1, RoleOperator: 2, RoleAdministrator: 3} + +// Valid reports whether r is a known role. +func (r Role) Valid() bool { return roleRank[r] > 0 } + +// AtLeast reports whether r grants everything min grants. +func (r Role) AtLeast(min Role) bool { return roleRank[r] >= roleRank[min] } + +// AllRoles lists valid roles for forms and validation. +func AllRoles() []Role { return []Role{RoleObserver, RoleOperator, RoleAdministrator} } diff --git a/internal/auth/store.go b/internal/auth/store.go new file mode 100644 index 0000000..007afaf --- /dev/null +++ b/internal/auth/store.go @@ -0,0 +1,393 @@ +package auth + +import ( + "context" + "errors" + "fmt" + "net/netip" + "strings" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/andydixon/noema/internal/storage" + "github.com/andydixon/noema/pkg/ids" +) + +// User is an operator account. +type User struct { + ID ids.ID + Username string + Role Role + Disabled bool + TOTPEnabled bool + LockedUntil *time.Time + CreatedAt time.Time +} + +// Session is an authenticated browser or CLI session. +type Session struct { + ID ids.ID + UserID ids.ID + CSRFToken string + CreatedAt time.Time + LastSeenAt time.Time + ExpiresAt time.Time + IP string + UserAgent string +} + +// Principal is the authenticated identity attached to a request context. +type Principal struct { + User User + SessionID ids.ID // empty for API-token authentication + ViaCookie bool // cookie sessions need CSRF checks; bearer tokens do not +} + +// Errors returned by the service. +var ( + ErrInvalidCredentials = errors.New("invalid credentials") + ErrLocked = errors.New("account temporarily locked") + ErrDisabled = errors.New("account disabled") + ErrRateLimited = errors.New("too many attempts") + ErrUnauthenticated = errors.New("unauthenticated") +) + +// Service performs authentication operations against the database. +type Service struct { + db *pgxpool.Pool + idleTTL time.Duration + absoluteTTL time.Duration + limiter *ipLimiter + now func() time.Time +} + +// NewService creates the authentication service. +func NewService(db *pgxpool.Pool, idleTTL, absoluteTTL time.Duration) *Service { + return &Service{db: db, idleTTL: idleTTL, absoluteTTL: absoluteTTL, limiter: newIPLimiter(20, 10), now: time.Now} +} + +// SweepLimiter releases idle rate-limit buckets; call periodically. +func (s *Service) SweepLimiter() { s.limiter.Sweep() } + +// CreateUser adds an account. Usernames are unique case-insensitively. +func (s *Service) CreateUser(ctx context.Context, username, password string, role Role) (User, error) { + username = strings.TrimSpace(username) + if len(username) < 2 || len(username) > 64 { + return User{}, errors.New("username must be 2-64 characters") + } + if !role.Valid() { + return User{}, fmt.Errorf("invalid role %q", role) + } + hash, err := HashPassword(password) + if err != nil { + return User{}, err + } + u := User{ID: ids.New(), Username: username, Role: role, CreatedAt: s.now()} + _, err = s.db.Exec(ctx, `INSERT INTO users (id, username, password_hash, role, created_at) VALUES ($1,$2,$3,$4,$5)`, + u.ID, u.Username, hash, string(u.Role), u.CreatedAt) + if err != nil { + return User{}, storage.Translate(err) + } + return u, nil +} + +// CountUsers is used to decide whether bootstrap is needed. +func (s *Service) CountUsers(ctx context.Context) (int, error) { + var n int + err := s.db.QueryRow(ctx, `SELECT count(*) FROM users`).Scan(&n) + return n, err +} + +// ListUsers returns all accounts ordered by name. +func (s *Service) ListUsers(ctx context.Context) ([]User, error) { + rows, err := s.db.Query(ctx, `SELECT id, username, role, disabled, totp_enabled, locked_until, created_at FROM users ORDER BY lower(username)`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []User + for rows.Next() { + var u User + var role string + if err := rows.Scan(&u.ID, &u.Username, &role, &u.Disabled, &u.TOTPEnabled, &u.LockedUntil, &u.CreatedAt); err != nil { + return nil, err + } + u.Role = Role(role) + out = append(out, u) + } + return out, rows.Err() +} + +// GetUser fetches one account. +func (s *Service) GetUser(ctx context.Context, id ids.ID) (User, error) { + var u User + var role string + err := s.db.QueryRow(ctx, `SELECT id, username, role, disabled, totp_enabled, locked_until, created_at FROM users WHERE id=$1`, id). + Scan(&u.ID, &u.Username, &role, &u.Disabled, &u.TOTPEnabled, &u.LockedUntil, &u.CreatedAt) + u.Role = Role(role) + return u, storage.Translate(err) +} + +// SetUserRole changes an account's role. +func (s *Service) SetUserRole(ctx context.Context, id ids.ID, role Role) error { + if !role.Valid() { + return fmt.Errorf("invalid role %q", role) + } + tag, err := s.db.Exec(ctx, `UPDATE users SET role=$2, updated_at=now() WHERE id=$1`, id, string(role)) + if err == nil && tag.RowsAffected() == 0 { + return storage.ErrNotFound + } + return err +} + +// SetUserDisabled enables or disables an account and revokes its sessions when disabling. +func (s *Service) SetUserDisabled(ctx context.Context, id ids.ID, disabled bool) error { + tag, err := s.db.Exec(ctx, `UPDATE users SET disabled=$2, updated_at=now() WHERE id=$1`, id, disabled) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return storage.ErrNotFound + } + if disabled { + _, err = s.db.Exec(ctx, `UPDATE sessions SET revoked_at=now() WHERE user_id=$1 AND revoked_at IS NULL`, id) + } + return err +} + +// ChangePassword sets a new password and revokes every other session (T12). +func (s *Service) ChangePassword(ctx context.Context, id ids.ID, newPassword string, keepSession ids.ID) error { + hash, err := HashPassword(newPassword) + if err != nil { + return err + } + if _, err := s.db.Exec(ctx, `UPDATE users SET password_hash=$2, password_changed_at=now(), updated_at=now() WHERE id=$1`, id, hash); err != nil { + return err + } + _, err = s.db.Exec(ctx, `UPDATE sessions SET revoked_at=now() WHERE user_id=$1 AND id<>$2 AND revoked_at IS NULL`, id, keepSession) + return err +} + +// Login verifies credentials, applying per-IP and per-account throttling, and +// returns a new session plus the raw token to set as a cookie. +func (s *Service) Login(ctx context.Context, username, password, ip, userAgent string) (Session, string, error) { + if !s.limiter.Allow(clientKey(ip)) { + return Session{}, "", ErrRateLimited + } + var ( + u User + role string + hash string + failed int + disabled bool + ) + err := s.db.QueryRow(ctx, `SELECT id, username, role, password_hash, disabled, failed_attempts, locked_until, totp_enabled FROM users WHERE lower(username)=lower($1)`, strings.TrimSpace(username)). + Scan(&u.ID, &u.Username, &role, &hash, &disabled, &failed, &u.LockedUntil, &u.TOTPEnabled) + if err != nil { + VerifyPassword(dummyHash, password) //nolint:errcheck // timing equalisation only + return Session{}, "", ErrInvalidCredentials + } + u.Role = Role(role) + if u.LockedUntil != nil && u.LockedUntil.After(s.now()) { + return Session{}, "", ErrLocked + } + ok, err := VerifyPassword(hash, password) + if err != nil { + return Session{}, "", err + } + if !ok { + failed++ + var until *time.Time + if d := lockoutDuration(failed); d > 0 { + t := s.now().Add(d) + until = &t + } + s.db.Exec(ctx, `UPDATE users SET failed_attempts=$2, locked_until=$3 WHERE id=$1`, u.ID, failed, until) //nolint:errcheck + if until != nil { + return Session{}, "", ErrLocked + } + return Session{}, "", ErrInvalidCredentials + } + if disabled { + return Session{}, "", ErrDisabled + } + if _, err := s.db.Exec(ctx, `UPDATE users SET failed_attempts=0, locked_until=NULL WHERE id=$1`, u.ID); err != nil { + return Session{}, "", err + } + return s.createSession(ctx, u.ID, ip, userAgent) +} + +func (s *Service) createSession(ctx context.Context, userID ids.ID, ip, userAgent string) (Session, string, error) { + token, hash := newToken() + csrf, _ := newToken() + now := s.now() + sess := Session{ID: ids.New(), UserID: userID, CSRFToken: csrf, CreatedAt: now, LastSeenAt: now, + ExpiresAt: now.Add(s.idleTTL), IP: clientKey(ip), UserAgent: truncate(userAgent, 256)} + _, err := s.db.Exec(ctx, `INSERT INTO sessions (id, user_id, token_hash, csrf_token, created_at, last_seen_at, expires_at, ip, user_agent) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, sess.ID, sess.UserID, hash, sess.CSRFToken, sess.CreatedAt, sess.LastSeenAt, sess.ExpiresAt, sess.IP, sess.UserAgent) + if err != nil { + return Session{}, "", err + } + return sess, token, nil +} + +// Authenticate resolves a session token to a principal, sliding the idle +// expiry forward. Absolute expiry is measured from session creation. +func (s *Service) Authenticate(ctx context.Context, token string) (Principal, error) { + var ( + p Principal + role string + created time.Time + expires time.Time + lastSeen time.Time + ) + err := s.db.QueryRow(ctx, `SELECT s.id, s.csrf_token, s.created_at, s.last_seen_at, s.expires_at, u.id, u.username, u.role, u.disabled, u.totp_enabled, u.created_at + FROM sessions s JOIN users u ON u.id=s.user_id WHERE s.token_hash=$1 AND s.revoked_at IS NULL`, hashToken(token)). + Scan(&p.SessionID, new(string), &created, &lastSeen, &expires, &p.User.ID, &p.User.Username, &role, &p.User.Disabled, &p.User.TOTPEnabled, &p.User.CreatedAt) + if err != nil { + return Principal{}, ErrUnauthenticated + } + now := s.now() + if now.After(expires) || now.After(created.Add(s.absoluteTTL)) || p.User.Disabled { + return Principal{}, ErrUnauthenticated + } + p.User.Role = Role(role) + p.ViaCookie = true + // Touch at most once a minute to avoid a write per request. + if now.Sub(lastSeen) > time.Minute { + s.db.Exec(ctx, `UPDATE sessions SET last_seen_at=$2, expires_at=$3 WHERE id=$1`, p.SessionID, now, now.Add(s.idleTTL)) //nolint:errcheck + } + return p, nil +} + +// AuthenticateToken resolves a personal API token. +func (s *Service) AuthenticateToken(ctx context.Context, token string) (Principal, error) { + var ( + p Principal + role string + tokenID ids.ID + expires *time.Time + ) + err := s.db.QueryRow(ctx, `SELECT t.id, t.expires_at, u.id, u.username, u.role, u.disabled, u.totp_enabled, u.created_at + FROM api_tokens t JOIN users u ON u.id=t.user_id WHERE t.token_hash=$1 AND t.revoked_at IS NULL`, hashToken(token)). + Scan(&tokenID, &expires, &p.User.ID, &p.User.Username, &role, &p.User.Disabled, &p.User.TOTPEnabled, &p.User.CreatedAt) + if err != nil || p.User.Disabled || (expires != nil && expires.Before(s.now())) { + return Principal{}, ErrUnauthenticated + } + p.User.Role = Role(role) + s.db.Exec(ctx, `UPDATE api_tokens SET last_used_at=now() WHERE id=$1`, tokenID) //nolint:errcheck + return p, nil +} + +// CSRFToken returns the CSRF token bound to a session. +func (s *Service) CSRFToken(ctx context.Context, sessionID ids.ID) (string, error) { + var t string + err := s.db.QueryRow(ctx, `SELECT csrf_token FROM sessions WHERE id=$1 AND revoked_at IS NULL`, sessionID).Scan(&t) + return t, storage.Translate(err) +} + +// Logout revokes one session. +func (s *Service) Logout(ctx context.Context, sessionID ids.ID) error { + _, err := s.db.Exec(ctx, `UPDATE sessions SET revoked_at=now() WHERE id=$1 AND revoked_at IS NULL`, sessionID) + return err +} + +// RevokeSession revokes a session belonging to userID; returns ErrNotFound otherwise. +func (s *Service) RevokeSession(ctx context.Context, userID, sessionID ids.ID) error { + tag, err := s.db.Exec(ctx, `UPDATE sessions SET revoked_at=now() WHERE id=$1 AND user_id=$2 AND revoked_at IS NULL`, sessionID, userID) + if err == nil && tag.RowsAffected() == 0 { + return storage.ErrNotFound + } + return err +} + +// ListSessions returns a user's live sessions, newest first. +func (s *Service) ListSessions(ctx context.Context, userID ids.ID) ([]Session, error) { + rows, err := s.db.Query(ctx, `SELECT id, user_id, created_at, last_seen_at, expires_at, ip, user_agent FROM sessions + WHERE user_id=$1 AND revoked_at IS NULL AND expires_at > now() ORDER BY last_seen_at DESC`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Session + for rows.Next() { + var x Session + if err := rows.Scan(&x.ID, &x.UserID, &x.CreatedAt, &x.LastSeenAt, &x.ExpiresAt, &x.IP, &x.UserAgent); err != nil { + return nil, err + } + out = append(out, x) + } + return out, rows.Err() +} + +// CreateAPIToken issues a personal token; the raw value is returned once. +func (s *Service) CreateAPIToken(ctx context.Context, userID ids.ID, name string, expires *time.Time) (string, error) { + token, hash := newToken() + _, err := s.db.Exec(ctx, `INSERT INTO api_tokens (id, user_id, name, token_hash, expires_at) VALUES ($1,$2,$3,$4,$5)`, + ids.New(), userID, truncate(name, 64), hash, expires) + if err != nil { + return "", err + } + return "noema_" + token, nil +} + +// APIToken is a personal token's metadata (never the token itself). +type APIToken struct { + ID ids.ID + Name string + CreatedAt time.Time + LastUsedAt *time.Time + ExpiresAt *time.Time +} + +// ListAPITokens lists a user's live tokens. +func (s *Service) ListAPITokens(ctx context.Context, userID ids.ID) ([]APIToken, error) { + rows, err := s.db.Query(ctx, `SELECT id, name, created_at, last_used_at, expires_at FROM api_tokens WHERE user_id=$1 AND revoked_at IS NULL ORDER BY created_at DESC`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []APIToken + for rows.Next() { + var t APIToken + if err := rows.Scan(&t.ID, &t.Name, &t.CreatedAt, &t.LastUsedAt, &t.ExpiresAt); err != nil { + return nil, err + } + out = append(out, t) + } + return out, rows.Err() +} + +// RevokeAPIToken revokes a token owned by userID. +func (s *Service) RevokeAPIToken(ctx context.Context, userID, tokenID ids.ID) error { + tag, err := s.db.Exec(ctx, `UPDATE api_tokens SET revoked_at=now() WHERE id=$1 AND user_id=$2 AND revoked_at IS NULL`, tokenID, userID) + if err == nil && tag.RowsAffected() == 0 { + return storage.ErrNotFound + } + return err +} + +// PurgeExpiredSessions deletes sessions long past expiry. Sessions are not +// cognitive history, so removing them is fine; audit_log keeps the record. +func (s *Service) PurgeExpiredSessions(ctx context.Context) error { + _, err := s.db.Exec(ctx, `DELETE FROM sessions WHERE expires_at < now() - interval '30 days' OR revoked_at < now() - interval '30 days'`) + return err +} + +func clientKey(ip string) string { + if a, err := netip.ParseAddr(ip); err == nil { + if a.Is6() { + return netip.PrefixFrom(a, 64).Masked().String() // one bucket per /64 + } + return a.String() + } + return ip +} + +func truncate(s string, n int) string { + if len(s) > n { + return s[:n] + } + return s +} diff --git a/internal/auth/token.go b/internal/auth/token.go new file mode 100644 index 0000000..a00d5dc --- /dev/null +++ b/internal/auth/token.go @@ -0,0 +1,23 @@ +package auth + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" +) + +// newToken returns a 256-bit random token in URL-safe base64 and its SHA-256, +// which is what gets stored. A database leak therefore does not leak sessions. +func newToken() (token string, hash []byte) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + panic("auth: crypto/rand unavailable: " + err.Error()) + } + token = base64.RawURLEncoding.EncodeToString(b) + return token, hashToken(token) +} + +func hashToken(token string) []byte { + h := sha256.Sum256([]byte(token)) + return h[:] +} diff --git a/internal/ui/pages.go b/internal/ui/pages.go new file mode 100644 index 0000000..ea03a61 --- /dev/null +++ b/internal/ui/pages.go @@ -0,0 +1,261 @@ +package ui + +import ( + "errors" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/andydixon/noema/internal/auth" + "github.com/andydixon/noema/internal/storage" + "github.com/andydixon/noema/pkg/ids" +) + +func (u *UI) loginForm(w http.ResponseWriter, r *http.Request) { + if _, ok := auth.PrincipalFrom(r.Context()); ok { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + if n, err := u.deps.Auth.CountUsers(r.Context()); err == nil && n == 0 { + http.Redirect(w, r, "/setup", http.StatusSeeOther) + return + } + u.render(w, r, "login", "Sign in", map[string]any{"Next": safeNext(r.URL.Query().Get("next"))}) +} + +func (u *UI) loginSubmit(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + u.errorPage(w, r, http.StatusBadRequest, "Bad form.") + return + } + ip := auth.ClientIP(r, false) + sess, token, err := u.deps.Auth.Login(r.Context(), r.PostFormValue("username"), r.PostFormValue("password"), ip, r.UserAgent()) + if err != nil { + u.deps.Metrics.AuthFailures.Inc() + msg := "Invalid username or password." + switch { + case errors.Is(err, auth.ErrLocked): + msg = "Account temporarily locked after repeated failures. Try again later." + case errors.Is(err, auth.ErrRateLimited): + msg = "Too many attempts from this address. Slow down." + case errors.Is(err, auth.ErrDisabled): + msg = "This account is disabled." + } + u.deps.Audit.Log(r.Context(), "login.failed", "user", "", map[string]any{"username": r.PostFormValue("username"), "reason": err.Error()}) + w.WriteHeader(http.StatusUnauthorized) + u.render(w, r, "login", "Sign in", map[string]any{"Error": msg, "Next": safeNext(r.PostFormValue("next"))}) + return + } + auth.SetSessionCookie(w, token, u.deps.Config.Production(), int(u.deps.Config.SessionAbsoluteTTL.Seconds())) + ctx := auth.WithPrincipal(r.Context(), auth.Principal{User: auth.User{ID: sess.UserID, Username: r.PostFormValue("username")}, SessionID: sess.ID}) + u.deps.Audit.Log(ctx, "login.success", "session", sess.ID.String(), nil) + to := safeNext(r.PostFormValue("next")) + if to == "" { + to = "/" + } + http.Redirect(w, r, to, http.StatusSeeOther) +} + +func (u *UI) logout(w http.ResponseWriter, r *http.Request) { + if p, ok := auth.PrincipalFrom(r.Context()); ok && !p.SessionID.IsZero() { + u.deps.Auth.Logout(r.Context(), p.SessionID) //nolint:errcheck + u.deps.Audit.Log(r.Context(), "logout", "session", p.SessionID.String(), nil) + } + auth.ClearSessionCookie(w, u.deps.Config.Production()) + http.Redirect(w, r, "/login", http.StatusSeeOther) +} + +// setup is only reachable while no users exist; it is the browser equivalent +// of the bootstrap environment variables. +func (u *UI) setupForm(w http.ResponseWriter, r *http.Request) { + if n, err := u.deps.Auth.CountUsers(r.Context()); err != nil || n > 0 { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + u.render(w, r, "setup", "Setup", nil) +} + +func (u *UI) setupSubmit(w http.ResponseWriter, r *http.Request) { + if n, err := u.deps.Auth.CountUsers(r.Context()); err != nil || n > 0 { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + r.ParseForm() //nolint:errcheck + if r.PostFormValue("password") != r.PostFormValue("confirm") { + u.render(w, r, "setup", "Setup", map[string]any{"Error": "Passwords do not match."}) + return + } + user, err := u.deps.Auth.CreateUser(r.Context(), r.PostFormValue("username"), r.PostFormValue("password"), auth.RoleAdministrator) + if err != nil { + u.render(w, r, "setup", "Setup", map[string]any{"Error": err.Error()}) + return + } + u.deps.Audit.Log(r.Context(), "user.bootstrap", "user", user.ID.String(), map[string]any{"username": user.Username, "via": "setup page"}) + u.flash(w, r, "ok", "Administrator created. Sign in.", "/login") +} + +func (u *UI) dashboard(w http.ResponseWriter, r *http.Request) { + entries, _ := u.deps.Audit.List(r.Context(), 0, 8, "", "") + u.render(w, r, "dashboard", "Dashboard", map[string]any{"Minds": 0, "Status": u.statusF(r), "Audit": entries}) +} + +func (u *UI) system(w http.ResponseWriter, r *http.Request) { + u.render(w, r, "system", "System", u.statusF(r)) +} + +func (u *UI) audit(w http.ResponseWriter, r *http.Request) { + before, _ := strconv.ParseInt(r.URL.Query().Get("before"), 10, 64) + entries, err := u.deps.Audit.List(r.Context(), before, 100, r.URL.Query().Get("target_kind"), r.URL.Query().Get("target_id")) + if err != nil { + u.errorPage(w, r, 500, "Could not load audit log.") + return + } + var next int64 + if len(entries) == 100 { + next = entries[len(entries)-1].Seq + } + u.render(w, r, "audit", "Audit", map[string]any{"Entries": entries, "Next": next}) +} + +func (u *UI) account(w http.ResponseWriter, r *http.Request) { + p, _ := auth.PrincipalFrom(r.Context()) + sessions, _ := u.deps.Auth.ListSessions(r.Context(), p.User.ID) + tokens, _ := u.deps.Auth.ListAPITokens(r.Context(), p.User.ID) + var newToken string + if c, err := r.Cookie("noema_newtoken"); err == nil { + newToken = c.Value + http.SetCookie(w, &http.Cookie{Name: "noema_newtoken", Path: "/account", MaxAge: -1, HttpOnly: true}) + } + u.render(w, r, "account", "Account", map[string]any{"Sessions": sessions, "Tokens": tokens, "NewToken": newToken}) +} + +func (u *UI) accountPassword(w http.ResponseWriter, r *http.Request) { + p, _ := auth.PrincipalFrom(r.Context()) + r.ParseForm() //nolint:errcheck + if r.PostFormValue("password") != r.PostFormValue("confirm") { + u.flash(w, r, "error", "New passwords do not match.", "/account") + return + } + // Re-verify the current password before changing it. + if _, _, err := u.deps.Auth.Login(r.Context(), p.User.Username, r.PostFormValue("current"), auth.ClientIP(r, false), "password-change"); err != nil { + u.flash(w, r, "error", "Current password incorrect.", "/account") + return + } + if err := u.deps.Auth.ChangePassword(r.Context(), p.User.ID, r.PostFormValue("password"), p.SessionID); err != nil { + u.flash(w, r, "error", err.Error(), "/account") + return + } + u.deps.Audit.Log(r.Context(), "user.password_change", "user", p.User.ID.String(), nil) + u.flash(w, r, "ok", "Password changed. Other sessions were signed out.", "/account") +} + +func (u *UI) accountSessionRevoke(w http.ResponseWriter, r *http.Request) { + p, _ := auth.PrincipalFrom(r.Context()) + id, err := ids.Parse(r.PathValue("id")) + if err != nil || u.deps.Auth.RevokeSession(r.Context(), p.User.ID, id) != nil { + u.flash(w, r, "error", "Session not found.", "/account") + return + } + u.deps.Audit.Log(r.Context(), "session.revoke", "session", id.String(), nil) + u.flash(w, r, "ok", "Session revoked.", "/account") +} + +func (u *UI) accountTokenCreate(w http.ResponseWriter, r *http.Request) { + p, _ := auth.PrincipalFrom(r.Context()) + r.ParseForm() //nolint:errcheck + name := strings.TrimSpace(r.PostFormValue("name")) + if name == "" { + u.flash(w, r, "error", "Token name required.", "/account") + return + } + tok, err := u.deps.Auth.CreateAPIToken(r.Context(), p.User.ID, name, nil) + if err != nil { + u.flash(w, r, "error", "Could not create token.", "/account") + return + } + u.deps.Audit.Log(r.Context(), "token.create", "user", p.User.ID.String(), map[string]any{"name": name}) + // Shown exactly once on the next page load, then the cookie is cleared. + http.SetCookie(w, &http.Cookie{Name: "noema_newtoken", Value: tok, Path: "/account", MaxAge: 60, HttpOnly: true, SameSite: http.SameSiteStrictMode, Secure: u.deps.Config.Production()}) + http.Redirect(w, r, "/account", http.StatusSeeOther) +} + +func (u *UI) accountTokenRevoke(w http.ResponseWriter, r *http.Request) { + p, _ := auth.PrincipalFrom(r.Context()) + id, err := ids.Parse(r.PathValue("id")) + if err != nil || u.deps.Auth.RevokeAPIToken(r.Context(), p.User.ID, id) != nil { + u.flash(w, r, "error", "Token not found.", "/account") + return + } + u.deps.Audit.Log(r.Context(), "token.revoke", "token", id.String(), nil) + u.flash(w, r, "ok", "Token revoked.", "/account") +} + +func (u *UI) users(w http.ResponseWriter, r *http.Request) { + users, err := u.deps.Auth.ListUsers(r.Context()) + if err != nil { + u.errorPage(w, r, 500, "Could not load users.") + return + } + u.render(w, r, "users", "Users", map[string]any{"Users": users}) +} + +func (u *UI) usersCreate(w http.ResponseWriter, r *http.Request) { + r.ParseForm() //nolint:errcheck + user, err := u.deps.Auth.CreateUser(r.Context(), r.PostFormValue("username"), r.PostFormValue("password"), auth.Role(r.PostFormValue("role"))) + if err != nil { + msg := err.Error() + if errors.Is(err, storage.ErrConflict) { + msg = "That username already exists." + } + u.flash(w, r, "error", msg, "/admin/users") + return + } + u.deps.Audit.Log(r.Context(), "user.create", "user", user.ID.String(), map[string]any{"username": user.Username, "role": user.Role}) + u.flash(w, r, "ok", "User created.", "/admin/users") +} + +func (u *UI) usersUpdate(w http.ResponseWriter, r *http.Request) { + p, _ := auth.PrincipalFrom(r.Context()) + id, err := ids.Parse(r.PathValue("id")) + if err != nil { + u.flash(w, r, "error", "Invalid user.", "/admin/users") + return + } + if id == p.User.ID { + u.flash(w, r, "error", "You cannot change your own account here.", "/admin/users") + return + } + r.ParseForm() //nolint:errcheck + switch r.PostFormValue("action") { + case "role": + role := auth.Role(r.PostFormValue("role")) + if err := u.deps.Auth.SetUserRole(r.Context(), id, role); err != nil { + u.flash(w, r, "error", err.Error(), "/admin/users") + return + } + u.deps.Audit.Log(r.Context(), "user.role", "user", id.String(), map[string]any{"role": role}) + case "disable", "enable": + disabled := r.PostFormValue("action") == "disable" + if err := u.deps.Auth.SetUserDisabled(r.Context(), id, disabled); err != nil { + u.flash(w, r, "error", err.Error(), "/admin/users") + return + } + u.deps.Audit.Log(r.Context(), "user.disabled", "user", id.String(), map[string]any{"disabled": disabled}) + default: + u.flash(w, r, "error", "Unknown action.", "/admin/users") + return + } + u.flash(w, r, "ok", "User updated.", "/admin/users") +} + +// safeNext only allows same-site relative paths for post-login redirects. +func safeNext(s string) string { + if s == "" || !strings.HasPrefix(s, "/") || strings.HasPrefix(s, "//") { + return "" + } + if _, err := url.Parse(s); err != nil { + return "" + } + return s +} diff --git a/internal/ui/ui.go b/internal/ui/ui.go new file mode 100644 index 0000000..74860c2 --- /dev/null +++ b/internal/ui/ui.go @@ -0,0 +1,239 @@ +// Package ui serves the server-rendered administration interface. Every page +// is a Go handler plus an html/template; live updates reuse the same templates +// through SSE fragments (ADR-0007). +package ui + +import ( + "bytes" + "encoding/base64" + "fmt" + "html/template" + "io/fs" + "net/http" + "net/url" + "os" + "strings" + "time" + + "github.com/andydixon/noema/internal/app" + "github.com/andydixon/noema/internal/auth" + "github.com/andydixon/noema/internal/version" + "github.com/andydixon/noema/web" +) + +// UI holds handlers and the parsed template set. +type UI struct { + deps app.Deps + pages map[string]*template.Template + static fs.FS + statusF func(r *http.Request) any // system status provider injected by api +} + +// View is the data passed to every template. +type View struct { + Title string + Path string + Principal *auth.Principal + CSRF string + Flash *Flash + Version string + Data any +} + +// Flash is a one-shot message shown on the next page load. +type Flash struct{ Kind, Message string } + +const flashCookie = "noema_flash" + +// New parses templates. In development, when ./web exists on disk, templates +// and static files are read from disk so edits show without a rebuild. +func New(deps app.Deps, statusF func(r *http.Request) any) (*UI, error) { + tmplFS, staticFS := web.Templates(), web.Static() + if !deps.Config.Production() { + if st, err := os.Stat("web/templates"); err == nil && st.IsDir() { + tmplFS, staticFS = os.DirFS("web/templates"), os.DirFS("web/static") + deps.Log.Info("ui: serving templates and static files from ./web (development)") + } + } + u := &UI{deps: deps, static: staticFS, statusF: statusF} + if err := u.parse(tmplFS); err != nil { + return nil, err + } + return u, nil +} + +func (u *UI) parse(fsys fs.FS) error { + funcs := template.FuncMap{ + "hasPrefix": strings.HasPrefix, + "roles": auth.AllRoles, + "date": func(t time.Time) string { return t.Local().Format("2006-01-02 15:04") }, + "ago": ago, + "duration": func(s int64) string { return (time.Duration(s) * time.Second).Truncate(time.Second).String() }, + "bytes": humanBytes, + "pct": func(f float64) string { return fmt.Sprintf("%.0f%%", f*100) }, + "conf": func(f float64) string { return fmt.Sprintf("%.2f", f) }, + } + base, err := template.New("base").Funcs(funcs).ParseFS(fsys, "layout.html") + if err != nil { + return fmt.Errorf("ui: parse layout: %w", err) + } + pages, err := fs.Glob(fsys, "pages/*.html") + if err != nil { + return err + } + u.pages = map[string]*template.Template{} + for _, p := range pages { + t, err := base.Clone() + if err != nil { + return err + } + if _, err := t.ParseFS(fsys, p); err != nil { + return fmt.Errorf("ui: parse %s: %w", p, err) + } + u.pages[strings.TrimPrefix(strings.TrimSuffix(p, ".html"), "pages/")] = t + } + return nil +} + +// render executes a page template. Rendering into a buffer first means a +// template error yields a proper 500 instead of a half-written page. +func (u *UI) render(w http.ResponseWriter, r *http.Request, page, title string, data any) { + t, ok := u.pages[page] + if !ok { + http.Error(w, "template not found: "+page, http.StatusInternalServerError) + return + } + v := View{Title: title, Path: r.URL.Path, Version: version.Version, Data: data} + if p, ok := auth.PrincipalFrom(r.Context()); ok { + v.Principal = &p + if tok, err := u.deps.Auth.CSRFToken(r.Context(), p.SessionID); err == nil { + v.CSRF = tok + } + } + if c, err := r.Cookie(flashCookie); err == nil && c.Value != "" { + if raw, err := base64.RawURLEncoding.DecodeString(c.Value); err == nil { + if kind, msg, ok := strings.Cut(string(raw), "|"); ok { + v.Flash = &Flash{Kind: kind, Message: msg} + } + } + http.SetCookie(w, &http.Cookie{Name: flashCookie, Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode}) + } + var buf bytes.Buffer + if err := t.ExecuteTemplate(&buf, "layout", v); err != nil { + u.deps.Log.Error("template execution failed", "page", page, "err", err) + http.Error(w, "template error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + buf.WriteTo(w) //nolint:errcheck +} + +// flash stores a one-shot message and redirects. +func (u *UI) flash(w http.ResponseWriter, r *http.Request, kind, msg, to string) { + val := base64.RawURLEncoding.EncodeToString([]byte(kind + "|" + msg)) + http.SetCookie(w, &http.Cookie{Name: flashCookie, Value: val, Path: "/", MaxAge: 60, HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: u.deps.Config.Production()}) + http.Redirect(w, r, to, http.StatusSeeOther) +} + +func (u *UI) errorPage(w http.ResponseWriter, r *http.Request, status int, msg string) { + w.WriteHeader(status) + u.render(w, r, "error", http.StatusText(status), map[string]any{"Status": status, "Message": msg}) +} + +// uiAuthFail redirects anonymous users to login and shows 403 to others. +func (u *UI) uiAuthFail(w http.ResponseWriter, r *http.Request, authenticated bool) { + if authenticated { + u.errorPage(w, r, http.StatusForbidden, "Your role does not permit this page.") + return + } + next := "" + if r.Method == http.MethodGet && r.URL.Path != "/" { + next = "?next=" + url.QueryEscape(r.URL.RequestURI()) + } + http.Redirect(w, r, "/login"+next, http.StatusSeeOther) +} + +func (u *UI) require(min auth.Role, h http.HandlerFunc) http.Handler { + return auth.RequireRole(min, u.uiAuthFail)(h) +} + +// Register mounts all UI routes. +func (u *UI) Register(m *http.ServeMux) { + m.Handle("GET /static/", http.StripPrefix("/static/", cacheStatic(http.FileServerFS(u.static)))) + m.HandleFunc("GET /login", u.loginForm) + m.HandleFunc("POST /login", u.loginSubmit) + m.HandleFunc("POST /logout", u.logout) + m.HandleFunc("GET /setup", u.setupForm) + m.HandleFunc("POST /setup", u.setupSubmit) + m.HandleFunc("GET /about", func(w http.ResponseWriter, r *http.Request) { u.render(w, r, "about", "Interpretation boundary", nil) }) + + m.Handle("GET /{$}", u.require(auth.RoleObserver, u.dashboard)) + m.Handle("GET /system", u.require(auth.RoleObserver, u.system)) + m.Handle("GET /audit", u.require(auth.RoleOperator, u.audit)) + m.Handle("GET /account", u.require(auth.RoleObserver, u.account)) + m.Handle("POST /account/password", u.require(auth.RoleObserver, u.accountPassword)) + m.Handle("POST /account/sessions/{id}/revoke", u.require(auth.RoleObserver, u.accountSessionRevoke)) + m.Handle("POST /account/tokens", u.require(auth.RoleObserver, u.accountTokenCreate)) + m.Handle("POST /account/tokens/{id}/revoke", u.require(auth.RoleObserver, u.accountTokenRevoke)) + m.Handle("GET /admin/users", u.require(auth.RoleAdministrator, u.users)) + m.Handle("POST /admin/users", u.require(auth.RoleAdministrator, u.usersCreate)) + m.Handle("POST /admin/users/{id}", u.require(auth.RoleAdministrator, u.usersUpdate)) + + for _, p := range []struct{ path, title, note string }{ + {"/minds", "Minds", "Mind management arrives in phase 2."}, + {"/approvals", "Approvals", "The approval centre arrives with the capability system."}, + {"/llm", "LLM", "Provider configuration arrives in phase 7."}, + {"/prompts", "Prompts", "The prompt manager arrives in phase 7."}, + {"/integrations", "Integrations", "Integrations arrive in phase 13."}, + {"/policies", "Policies", "The policy matrix arrives in phase 12."}, + } { + m.Handle("GET "+p.path, u.require(auth.RoleObserver, func(w http.ResponseWriter, r *http.Request) { u.render(w, r, "placeholder", p.title, p.note) })) + } +} + +func cacheStatic(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "public, max-age=300") + h.ServeHTTP(w, r) + }) +} + +func ago(t any) string { + var tm time.Time + switch v := t.(type) { + case time.Time: + tm = v + case *time.Time: + if v == nil { + return "never" + } + tm = *v + default: + return "" + } + d := time.Since(tm) + switch { + case d < time.Minute: + return "just now" + case d < time.Hour: + return fmt.Sprintf("%dm ago", int(d.Minutes())) + case d < 48*time.Hour: + return fmt.Sprintf("%dh ago", int(d.Hours())) + default: + return fmt.Sprintf("%dd ago", int(d.Hours()/24)) + } +} + +func humanBytes(n uint64) string { + const unit = 1024 + if n < unit { + return fmt.Sprintf("%d B", n) + } + div, exp := uint64(unit), 0 + for m := n / unit; m >= unit; m /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp]) +} diff --git a/web/embed.go b/web/embed.go new file mode 100644 index 0000000..e065c8b --- /dev/null +++ b/web/embed.go @@ -0,0 +1,17 @@ +// Package web embeds the templates and static assets of the administration +// interface (ADR-0007). Handlers live in internal/ui. +package web + +import ( + "embed" + "io/fs" +) + +//go:embed templates static +var files embed.FS + +// Templates returns the template tree. +func Templates() fs.FS { f, _ := fs.Sub(files, "templates"); return f } + +// Static returns the static asset tree. +func Static() fs.FS { f, _ := fs.Sub(files, "static"); return f } diff --git a/web/static/app.css b/web/static/app.css new file mode 100644 index 0000000..c57fad1 --- /dev/null +++ b/web/static/app.css @@ -0,0 +1,113 @@ +/* Noema administration interface. Design tokens first, then layout, then components. + Light theme on :root, dark via data-theme or system preference. */ +:root { + --font: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif; + --mono: ui-monospace, "JetBrains Mono", "SF Mono", Menlo, Consolas, monospace; + --fs: 15px; + --bg: #f4f6f9; --surface: #ffffff; --surface-2: #eef1f5; --text: #1a1f26; --muted: #5d6673; --border: #dfe4ea; + --accent: #2f6fed; --accent-ink: #ffffff; --accent-soft: #e6eefc; + --ok: #178a5b; --ok-soft: #e1f5ec; --warn: #b7791f; --warn-soft: #fbf1dc; --danger: #c93b3b; --danger-soft: #fbe5e5; --info: #2b6cb0; + --shadow: 0 1px 2px rgba(16,24,40,.06), 0 1px 3px rgba(16,24,40,.08); + --radius: 10px; --sidenav: 232px; + color-scheme: light; +} +@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { --bg:#0f1318; --surface:#161b22; --surface-2:#1d242d; --text:#e6eaf0; --muted:#8d97a5; --border:#28303b; --accent:#6c9cff; --accent-ink:#0b1220; --accent-soft:#1a2740; --ok:#3ecf8e; --ok-soft:#14301f; --warn:#e0b04c; --warn-soft:#34290f; --danger:#ff6b6b; --danger-soft:#3a1717; --info:#7ab8ff; --shadow:0 1px 2px rgba(0,0,0,.4); color-scheme: dark; } } +:root[data-theme="dark"] { --bg:#0f1318; --surface:#161b22; --surface-2:#1d242d; --text:#e6eaf0; --muted:#8d97a5; --border:#28303b; --accent:#6c9cff; --accent-ink:#0b1220; --accent-soft:#1a2740; --ok:#3ecf8e; --ok-soft:#14301f; --warn:#e0b04c; --warn-soft:#34290f; --danger:#ff6b6b; --danger-soft:#3a1717; --info:#7ab8ff; --shadow:0 1px 2px rgba(0,0,0,.4); color-scheme: dark; } + +*, *::before, *::after { box-sizing: border-box; } +html { font-size: var(--fs); } +body { margin: 0; font-family: var(--font); color: var(--text); background: var(--bg); line-height: 1.5; -webkit-font-smoothing: antialiased; } +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } +code, pre, .mono { font-family: var(--mono); font-size: .92em; } +h1 { font-size: 1.6rem; margin: 0 0 .25rem; letter-spacing: -.01em; } +h2 { font-size: 1.05rem; margin: 0 0 .75rem; } +h3 { font-size: .95rem; margin: 0 0 .5rem; } +p { margin: 0 0 .75rem; } +.muted { color: var(--muted); } +.small { font-size: .85rem; } +.nowrap { white-space: nowrap; } +.truncate { max-width: 22ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ok { color: var(--ok); } .warn { color: var(--warn); } .danger { color: var(--danger); } +.skip { position: absolute; left: -999px; } .skip:focus { left: 8px; top: 8px; background: var(--surface); padding: .5rem; z-index: 99; } +:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } + +/* Shell */ +.topbar { position: sticky; top: 0; z-index: 20; display: flex; align-items: center; gap: .75rem; height: 52px; padding: 0 1rem; background: var(--surface); border-bottom: 1px solid var(--border); } +.topbar-spacer { flex: 1; } +.brand { display: inline-flex; align-items: center; gap: .5rem; font-weight: 650; color: var(--text); letter-spacing: -.01em; } +.brand.big { font-size: 1.4rem; margin-bottom: .5rem; } +.brand-mark { width: 18px; height: 18px; border-radius: 50%; background: radial-gradient(circle at center, var(--surface) 0 3px, var(--accent) 4px 6px, transparent 7px), var(--accent); box-shadow: inset 0 0 0 3px var(--accent); } +.shell { display: grid; grid-template-columns: var(--sidenav) 1fr; min-height: calc(100vh - 52px - 40px); } +.sidenav { border-right: 1px solid var(--border); padding: 1rem .75rem; background: var(--surface); position: sticky; top: 52px; height: calc(100vh - 52px); overflow-y: auto; } +.navgroup { margin-bottom: 1.1rem; } +.navhead { font-size: .72rem; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); padding: 0 .6rem .35rem; } +.sidenav a { display: block; padding: .42rem .6rem; border-radius: 7px; color: var(--text); font-size: .93rem; } +.sidenav a:hover { background: var(--surface-2); text-decoration: none; } +.sidenav a[aria-current="page"] { background: var(--accent-soft); color: var(--accent); font-weight: 600; } +.content { padding: 1.5rem 2rem 3rem; max-width: 1400px; width: 100%; } +.footer { text-align: center; padding: .75rem; border-top: 1px solid var(--border); } +.nav-toggle { display: none; } +@media (max-width: 900px) { + .shell { grid-template-columns: 1fr; } + .sidenav { display: none; position: static; height: auto; border-right: 0; border-bottom: 1px solid var(--border); } + .sidenav.open { display: block; } + .nav-toggle { display: inline-flex; } + .content { padding: 1rem; } +} +.bare-main { min-height: calc(100vh - 40px); display: grid; place-items: center; padding: 1rem; } + +/* Components */ +.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.1rem 1.25rem; box-shadow: var(--shadow); } +.card.highlight { border-color: var(--accent); } +.auth-card { width: min(420px, 100%); } +.grid { display: grid; gap: 1rem; margin-bottom: 1rem; } +.cols-2 { grid-template-columns: repeat(2, minmax(0,1fr)); } +.cols-3 { grid-template-columns: repeat(3, minmax(0,1fr)); } +.cols-4 { grid-template-columns: repeat(4, minmax(0,1fr)); } +.cols-3-1 { grid-template-columns: 3fr 1fr; } +@media (max-width: 1100px) { .cols-3, .cols-4 { grid-template-columns: repeat(2, minmax(0,1fr)); } .cols-3-1 { grid-template-columns: 1fr; } } +@media (max-width: 700px) { .cols-2, .cols-3, .cols-4 { grid-template-columns: 1fr; } } +.pagehead { margin-bottom: 1.25rem; } +.stat-label { font-size: .78rem; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); } +.stat-value { font-size: 1.9rem; font-weight: 650; letter-spacing: -.02em; line-height: 1.2; margin: .15rem 0; } +.stat-value.small { font-size: 1.25rem; } +.badge { display: inline-block; font-size: .74rem; font-weight: 600; padding: .1rem .5rem; border-radius: 999px; background: var(--surface-2); color: var(--muted); vertical-align: middle; } +.badge.ok { background: var(--ok-soft); color: var(--ok); } .badge.warn { background: var(--warn-soft); color: var(--warn); } .badge.danger { background: var(--danger-soft); color: var(--danger); } .badge.accent { background: var(--accent-soft); color: var(--accent); } +.flash { padding: .6rem .9rem; border-radius: 8px; margin-bottom: 1rem; border: 1px solid var(--border); background: var(--surface); } +.flash-ok { border-color: var(--ok); background: var(--ok-soft); } .flash-error { border-color: var(--danger); background: var(--danger-soft); } .flash-warn { border-color: var(--warn); background: var(--warn-soft); } +.error { color: var(--danger); } +.prose { max-width: 70ch; } +.steps li { margin-bottom: .4rem; } + +/* Forms */ +.stack { display: flex; flex-direction: column; gap: .8rem; } +.row { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; margin-top: .5rem; } +.inline { display: inline; } +label { display: flex; flex-direction: column; gap: .3rem; font-size: .9rem; font-weight: 500; } +input, select, textarea { font: inherit; color: var(--text); background: var(--surface); border: 1px solid var(--border); border-radius: 7px; padding: .5rem .65rem; } +input:focus, select:focus, textarea:focus { border-color: var(--accent); outline: 2px solid var(--accent-soft); } +textarea { min-height: 6rem; resize: vertical; } +.btn { font: inherit; font-size: .9rem; font-weight: 550; padding: .45rem .9rem; border-radius: 7px; border: 1px solid var(--border); background: var(--surface); color: var(--text); cursor: pointer; display: inline-flex; align-items: center; gap: .35rem; } +.btn:hover { background: var(--surface-2); text-decoration: none; } +.btn.small { padding: .25rem .6rem; font-size: .8rem; } +.btn-primary { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); } +.btn-primary:hover { filter: brightness(1.08); background: var(--accent); } +.btn-danger { color: var(--danger); border-color: var(--danger); } +.btn-ghost { border-color: transparent; background: transparent; } +.iconbtn { font: inherit; width: 34px; height: 34px; border-radius: 7px; border: 1px solid var(--border); background: var(--surface); color: var(--text); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; } + +/* Tables and definition lists */ +.table { width: 100%; border-collapse: collapse; font-size: .92rem; } +.table th { text-align: left; font-weight: 600; font-size: .78rem; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); padding: .45rem .5rem; border-bottom: 1px solid var(--border); } +.table td { padding: .55rem .5rem; border-bottom: 1px solid var(--border); vertical-align: middle; } +.table tr:last-child td { border-bottom: 0; } +.table.compact td, .table.compact th { padding: .35rem .45rem; } +.kv { display: grid; grid-template-columns: max-content 1fr; gap: .35rem 1rem; margin: 0; } +.kv dt { color: var(--muted); font-size: .85rem; } .kv dd { margin: 0; } +pre.token { background: var(--surface-2); padding: .75rem; border-radius: 8px; overflow-x: auto; user-select: all; } + +/* Confidence bar: inline meter used across belief/memory views */ +.conf { display: inline-flex; align-items: center; gap: .4rem; font-variant-numeric: tabular-nums; } +.conf-bar { width: 64px; height: 6px; border-radius: 3px; background: var(--surface-2); overflow: hidden; } +.conf-bar i { display: block; height: 100%; background: var(--accent); } diff --git a/web/static/app.js b/web/static/app.js new file mode 100644 index 0000000..710a860 --- /dev/null +++ b/web/static/app.js @@ -0,0 +1,83 @@ +/* Noema UI enhancements. No frameworks, no inline scripts (CSP). */ +(function () { + "use strict"; + const root = document.documentElement; + const csrf = () => (document.querySelector('meta[name="csrf-token"]') || {}).content || ""; + + // Theme: auto → light → dark, remembered per browser. + try { + const saved = localStorage.getItem("noema.theme"); + if (saved) root.setAttribute("data-theme", saved); + } catch (e) { /* storage unavailable */ } + function cycleTheme() { + const order = ["auto", "light", "dark"]; + const cur = root.getAttribute("data-theme") || "auto"; + const next = order[(order.indexOf(cur) + 1) % order.length]; + root.setAttribute("data-theme", next); + try { localStorage.setItem("noema.theme", next); } catch (e) { /* ignore */ } + } + + // fetch wrapper that carries the CSRF token and same-origin credentials. + async function api(method, url, body) { + const res = await fetch(url, { + method, credentials: "same-origin", + headers: Object.assign({ "X-CSRF-Token": csrf() }, body ? { "Content-Type": "application/json" } : {}), + body: body ? JSON.stringify(body) : undefined, + }); + if (res.status === 204) return null; + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error((data.error && data.error.message) || res.statusText); + return data; + } + + // Server-Sent Events helper: handlers keyed by event name; html payloads are + // inserted into [data-stream-target] containers by the caller. + function stream(url, handlers, opts) { + opts = opts || {}; + let es, retry = 1000; + function connect() { + es = new EventSource(url, { withCredentials: true }); + es.onopen = () => { retry = 1000; document.body.classList.remove("stream-offline"); }; + es.onerror = () => { es.close(); document.body.classList.add("stream-offline"); setTimeout(connect, retry); retry = Math.min(retry * 2, 30000); }; + Object.keys(handlers).forEach((name) => es.addEventListener(name, (ev) => { + let data = ev.data; + try { data = JSON.parse(ev.data); } catch (e) { /* plain text */ } + handlers[name](data, ev); + })); + } + connect(); + return { close: () => es && es.close() }; + } + + document.addEventListener("DOMContentLoaded", () => { + const tt = document.querySelector("[data-theme-toggle]"); + if (tt) tt.addEventListener("click", cycleTheme); + const nt = document.querySelector("[data-nav-toggle]"); + const nav = document.querySelector("[data-nav]"); + if (nt && nav) nt.addEventListener("click", () => { const open = nav.classList.toggle("open"); nt.setAttribute("aria-expanded", String(open)); }); + + // Confirmation on destructive buttons. + document.addEventListener("click", (ev) => { + const el = ev.target.closest("[data-confirm]"); + if (el && !window.confirm(el.getAttribute("data-confirm"))) { ev.preventDefault(); ev.stopImmediatePropagation(); } + }, true); + + // Auto-dismiss flashes. + document.querySelectorAll(".flash").forEach((f) => setTimeout(() => f.remove(), 8000)); + + // Keyboard: "t" toggles theme, "g d" dashboard, "g m" minds, "g s" system. + let pending = ""; + document.addEventListener("keydown", (ev) => { + if (ev.target.matches("input, textarea, select") || ev.metaKey || ev.ctrlKey || ev.altKey) return; + if (ev.key === "t" && !pending) { cycleTheme(); return; } + if (ev.key === "g") { pending = "g"; setTimeout(() => (pending = ""), 800); return; } + if (pending === "g") { + const map = { d: "/", m: "/minds", s: "/system", a: "/audit", l: "/llm", p: "/approvals" }; + if (map[ev.key]) location.href = map[ev.key]; + pending = ""; + } + }); + }); + + window.noema = { api, stream, csrf }; +})(); diff --git a/web/static/favicon.svg b/web/static/favicon.svg new file mode 100644 index 0000000..6e80e6c --- /dev/null +++ b/web/static/favicon.svg @@ -0,0 +1 @@ + diff --git a/web/templates/layout.html b/web/templates/layout.html new file mode 100644 index 0000000..d5d8c89 --- /dev/null +++ b/web/templates/layout.html @@ -0,0 +1,60 @@ +{{define "layout"}} + + + + + +{{if .Title}}{{.Title}} · {{end}}Noema + + + + + +{{if .Principal}} + +
+ + Noema +
+ {{.Principal.User.Username}} · {{.Principal.User.Role}} + +
+
+
+ +
+ {{with .Flash}}
{{.Message}}
{{end}} + {{template "content" .}} +
+
+{{else}} +
+ {{with .Flash}}
{{.Message}}
{{end}} + {{template "content" .}} +
+{{end}} + + +{{end}} + +{{define "nav"}} + + + +{{end}} diff --git a/web/templates/pages/about.html b/web/templates/pages/about.html new file mode 100644 index 0000000..7e36572 --- /dev/null +++ b/web/templates/pages/about.html @@ -0,0 +1,9 @@ +{{define "content"}} +

Interpretation boundary

+
+

Noema models cognitive processes. It does not prove, and its authors do not claim, that it is conscious, sentient, self-aware or a person.

+

Components are named after psychological concepts (attention, working memory, emotion-like state, curiosity, drives, self-model, reflection, dreaming) because those names describe the functional role each plays in the architecture. They are ordinary data structures and deterministic rules, sometimes assisted by language models.

+

When a mind reports that it is "curious" or "concerned", it is reporting the value of a stored scalar that the architecture updates by rule. When it says "I believe", it refers to a belief record with evidence links. Such reports are useful because they are grounded in inspectable state, not because they indicate experience.

+

Treat resemblance to emotion, preference or identity as a property of the model being run, to be studied. Rely on the evidence and provenance views rather than on fluent self-description.

+
+{{end}} diff --git a/web/templates/pages/account.html b/web/templates/pages/account.html new file mode 100644 index 0000000..f6072ce --- /dev/null +++ b/web/templates/pages/account.html @@ -0,0 +1,42 @@ +{{define "content"}} +

Account

Signed in as {{.Principal.User.Username}} ({{.Principal.User.Role}}).

+{{with .Data.NewToken}}

New API token

Copy it now. It will not be shown again.

{{.}}

Use it as Authorization: Bearer … or NOEMA_API_TOKEN for noemactl.

{{end}} +
+
+

Sessions

+ + + {{range .Data.Sessions}} + + + + + {{end}} +
WhereClientLast seen
{{.IP}}{{if eq .ID $.Principal.SessionID}} current{{end}}{{.UserAgent}}{{ago .LastSeenAt}}{{if ne .ID $.Principal.SessionID}}
{{end}}
+
+
+

Change password

+
+ + + + + +

Other sessions are signed out when the password changes.

+
+
+
+

API tokens

+ + + {{range .Data.Tokens}} + {{else}}{{end}} +
NameCreatedLast used
{{.Name}}{{date .CreatedAt}}{{if .LastUsedAt}}{{ago .LastUsedAt}}{{else}}never{{end}}
No tokens.
+
+ + + +
+
+
+{{end}} diff --git a/web/templates/pages/audit.html b/web/templates/pages/audit.html new file mode 100644 index 0000000..52cfa21 --- /dev/null +++ b/web/templates/pages/audit.html @@ -0,0 +1,15 @@ +{{define "content"}} +

Audit

Every administrative change, append-only.

+
+ + + {{range .Data.Entries}} + + + + + {{else}}{{end}} +
#WhenActorActionTargetDetailsIP
{{.Seq}}{{.At}}{{if .ActorName}}{{.ActorName}}{{else}}{{.ActorKind}}{{end}}{{.Action}}{{.TargetKind}} {{.TargetID}}{{printf "%s" .Details}}{{.IP}}
No entries.
+ {{with .Data.Next}}

Older

{{end}} +
+{{end}} diff --git a/web/templates/pages/dashboard.html b/web/templates/pages/dashboard.html new file mode 100644 index 0000000..568350d --- /dev/null +++ b/web/templates/pages/dashboard.html @@ -0,0 +1,27 @@ +{{define "content"}} +

Dashboard

System overview. Minds, cognition and approvals appear here as they come online.

+
+
Running minds
{{.Data.Minds}}
No minds yet. Create one under Minds.
+
System health
{{if .Data.Status.Database.OK}}Healthy{{else}}Degraded{{end}}
Database {{if .Data.Status.Database.OK}}connected{{else}}unavailable{{end}} · up {{duration .Data.Status.UptimeSeconds}}
+
Outstanding approvals
0
Capability layer arrives in a later phase.
+
+
+
+

Recent administrative activity

+ {{if .Data.Audit}} + + + {{range .Data.Audit}}{{end}} +
WhenActorActionTarget
{{.At}}{{.ActorName}}{{if not .ActorName}}{{.ActorKind}}{{end}}{{.Action}}{{.TargetKind}} {{.TargetID}}
+ {{else}}

Nothing yet.

{{end}} +
+
+

Getting started

+
    +
  1. Add an LLM provider under LLM.
  2. +
  3. Create a mind under Minds and shape its personality.
  4. +
  5. Open its conversation page and watch the thought stream.
  6. +
+
+
+{{end}} diff --git a/web/templates/pages/error.html b/web/templates/pages/error.html new file mode 100644 index 0000000..7db5f88 --- /dev/null +++ b/web/templates/pages/error.html @@ -0,0 +1,7 @@ +{{define "content"}} +
+

{{.Data.Status}}

+

{{.Data.Message}}

+

Back to dashboard

+
+{{end}} diff --git a/web/templates/pages/login.html b/web/templates/pages/login.html new file mode 100644 index 0000000..169166c --- /dev/null +++ b/web/templates/pages/login.html @@ -0,0 +1,14 @@ +{{define "content"}} +
+
Noema
+

Sign in to the cognitive runtime.

+
+ + + + + {{with .Data.Error}}{{end}} + +
+
+{{end}} diff --git a/web/templates/pages/placeholder.html b/web/templates/pages/placeholder.html new file mode 100644 index 0000000..991df38 --- /dev/null +++ b/web/templates/pages/placeholder.html @@ -0,0 +1,3 @@ +{{define "content"}} +

{{.Title}}

{{.Data}}

+{{end}} diff --git a/web/templates/pages/setup.html b/web/templates/pages/setup.html new file mode 100644 index 0000000..7559a62 --- /dev/null +++ b/web/templates/pages/setup.html @@ -0,0 +1,15 @@ +{{define "content"}} +
+
Noema
+

First-run setup

+

No accounts exist yet. Create the first administrator. This page disappears once an account exists.

+
+ + + + + {{with .Data.Error}}{{end}} + +
+
+{{end}} diff --git a/web/templates/pages/system.html b/web/templates/pages/system.html new file mode 100644 index 0000000..232b747 --- /dev/null +++ b/web/templates/pages/system.html @@ -0,0 +1,28 @@ +{{define "content"}} +

System

Runtime, database and migration state.

+{{$s := .Data}} +
+
Version
{{$s.Version}}
{{$s.GoVersion}}
+
Uptime
{{duration $s.UptimeSeconds}}
+
Goroutines
{{$s.Goroutines}}
{{$s.NumCPU}} CPUs
+
Memory
{{bytes $s.HeapBytes}}
heap · {{bytes $s.SysBytes}} from OS
+
+
+
+

Database

+
+
Status
{{if $s.Database.OK}}connected{{else}}error {{$s.Database.Error}}{{end}}
+
Server
{{$s.Database.Version}}
+
pgvector
{{if $s.Database.PGVector}}available{{else}}not installed semantic retrieval falls back to text and graph{{end}}
+
Pool
{{$s.Database.PoolTotal}} open · {{$s.Database.PoolIdle}} idle · {{$s.Database.PoolMax}} max
+
+
+
+

Migrations

+ + + {{range $s.Migrations}}{{end}} +
#NameStateApplied
{{printf "%04d" .Version}}{{.Name}}{{if .Drift}}drift{{else if .Applied}}applied{{else}}pending{{end}}{{.AppliedAt}}
+
+
+{{end}} diff --git a/web/templates/pages/users.html b/web/templates/pages/users.html new file mode 100644 index 0000000..3512ad5 --- /dev/null +++ b/web/templates/pages/users.html @@ -0,0 +1,36 @@ +{{define "content"}} +

Users

Operator accounts. Roles: observer (read), operator (act), administrator (configure).

+
+
+ + + {{range .Data.Users}} + + + + + + + {{end}} +
UsernameRoleStateMFACreated
{{.Username}}{{if eq .ID $.Principal.User.ID}} (you){{end}} +
+ + {{if ne .ID $.Principal.User.ID}}{{end}} +
+
{{if .Disabled}}disabled{{else}}active{{end}}{{if .LockedUntil}} locked{{end}}{{if .TOTPEnabled}}TOTP{{else}}off{{end}}{{date .CreatedAt}}{{if ne .ID $.Principal.User.ID}}
+ {{if .Disabled}}{{else}}{{end}}
{{end}}
+
+
+

Create user

+
+ + + + + +
+
+
+{{end}}