Files
noema/internal/api/respond.go
T
Andy Dixon a002a2c100 feat: add authentication, audit log, HTTP API and base web UI
Phase 1 foundation. Local auth per ADR-0006: Argon2id passwords, opaque
hashed session tokens with idle/absolute expiry, CSRF token plus Origin
check on cookie sessions, per-IP token bucket and per-account exponential
lockout, three roles enforced by middleware, personal API tokens for
noemactl. Audit log writer used by every admin mutation.

API: request ids, panic recovery, strict CSP and security headers, body
limits, single JSON error envelope, health/ready/status, users, sessions,
tokens, audit, token-protected /metrics.

UI: embedded html/template pages (login, first-run setup, dashboard,
system, users, account, audit, interpretation boundary), light/dark
theme, dependency-free app.js with CSRF-aware fetch and SSE helper.

Binaries: noemad with graceful shutdown, migrations at start, bootstrap
admin from environment, supervised background tasks; noemactl with
status, migrate, user create and meaningful exit codes.
2026-06-15 20:22:19 +01:00

76 lines
2.2 KiB
Go

// 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"`
}