Code sync, security fixes

This commit is contained in:
Andy Dixon
2026-08-11 10:40:10 +01:00
parent 4e00deedf8
commit 53dbc28383
141 changed files with 36194 additions and 0 deletions
+116
View File
@@ -0,0 +1,116 @@
package api
import (
"regexp"
"strings"
"testing"
)
// Route registration is explicit so that the authentication treatment of every
// endpoint is readable in one place. These tests read that place and assert it,
// so a route added without auth — or a page accidentally made public — fails
// here rather than in production.
// webRoutePattern captures a browser route and the options it was registered with.
var webRoutePattern = regexp.MustCompile(
`mux\.Handle\("(GET|POST|PATCH|DELETE|PUT) ([^"]+)",\s*webChain\("[^"]*",\s*wb\.\w+,\s*([^)]+)\)\)`)
// publicRoutes are the only browser routes an unauthenticated visitor may reach.
//
// Adding to this list is a deliberate act: every entry is a page that must read
// nothing from the database.
var publicRoutes = map[string]bool{
// Go's exact-match pattern for the root. Dual-purpose: the dashboard when
// signed in, the landing page when not.
"/{$}": true,
"/login": true,
"/auth/oauth/start": true,
"/auth/oauth/callback": true,
"/how-it-works": true,
"/api": true,
}
func TestOnlyIntendedBrowserRoutesArePublic(t *testing.T) {
src := readFile(t, routerPath)
matches := webRoutePattern.FindAllStringSubmatch(src, -1)
if len(matches) < 20 {
t.Fatalf("only %d browser routes matched; the pattern has probably drifted from the router",
len(matches))
}
for _, m := range matches {
method, path, opts := m[1], m[2], strings.TrimSpace(m[3])
authenticated := strings.Contains(opts, "read") ||
strings.Contains(opts, "write") ||
strings.Contains(opts, "authenticated: true")
optional := strings.Contains(opts, "optionalAuth")
public := opts == "pub" || optional
if public && !publicRoutes[path] {
t.Errorf("%s %s is registered as public (%s) but is not in the intended public list; "+
"if that is deliberate, add it there and make sure the page reads no stored data",
method, path, opts)
}
if !public && !authenticated {
t.Errorf("%s %s is registered with %q, which neither authenticates nor is public",
method, path, opts)
}
}
}
// Every route under /api/v1 must name the scope it needs. A route that reached
// the mux without one would authenticate but authorise nothing.
func TestEveryAPIRouteNamesAScope(t *testing.T) {
src := readFile(t, routerPath)
registration := regexp.MustCompile(`mux\.Handle\("(GET|POST|PATCH|DELETE|PUT) (/api/v1[^"]*)",\s*(\w+)\(`)
matches := registration.FindAllStringSubmatch(src, -1)
if len(matches) < 15 {
t.Fatalf("only %d API routes matched; the pattern has probably drifted", len(matches))
}
for _, m := range matches {
method, path, chain := m[1], m[2], m[3]
switch chain {
case "apiChain":
// apiChain takes a scope as its third argument; check this call has one.
call := regexp.MustCompile(
`mux\.Handle\("` + regexp.QuoteMeta(method+" "+path) + `",\s*apiChain\([^)]*domain\.Scope\w+\)\)`)
if !call.MatchString(src) {
t.Errorf("%s %s uses apiChain without a domain.Scope argument", method, path)
}
case "tokenChain":
// Token management is session-only by design, not scoped.
default:
t.Errorf("%s %s is registered with %q, which is neither apiChain nor tokenChain — "+
"its authentication and scope treatment is not readable in one place",
method, path, chain)
}
}
}
// The password endpoint must be conditional on local login being enabled, so a
// disabled break-glass path is absent rather than present and refusing.
func TestPasswordLoginIsRegisteredConditionally(t *testing.T) {
src := readFile(t, routerPath)
idx := strings.Index(src, `mux.Handle("POST /login"`)
if idx < 0 {
t.Fatal("the password login route is not registered at all")
}
// Look back a little way for the guard.
window := src[max(0, idx-500):idx]
if !strings.Contains(window, "LocalLoginEnabled") {
t.Fatal("POST /login is registered unconditionally; it must be guarded by " +
"cfg.Auth.LocalLoginEnabled so a disabled local login has no handler to reach")
}
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
+350
View File
@@ -0,0 +1,350 @@
// Package api implements Relata's versioned REST interface under /api/v1.
//
// Handlers here never expose internal detail to clients: stack traces, driver
// errors and file paths stay in the logs, while the response carries a stable
// machine-readable code and a message safe to show a user.
package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"github.com/andydixon/relata/internal/analysis"
"github.com/andydixon/relata/internal/auth"
"github.com/andydixon/relata/internal/domain"
"github.com/andydixon/relata/internal/ingest"
"github.com/andydixon/relata/internal/observability"
"github.com/andydixon/relata/internal/search"
)
// Stable error codes. Clients may branch on these; the messages may change.
const (
CodeInvalidRequest = "invalid_request"
CodeValidationFailed = "validation_failed"
CodeUnauthorised = "unauthorised"
CodeForbidden = "forbidden"
CodeNotFound = "not_found"
CodeDuplicate = "duplicate_content"
CodeConflict = "conflict"
CodePayloadTooLarge = "payload_too_large"
CodeUnsupportedMedia = "unsupported_media_type"
CodeRateLimited = "rate_limited"
CodeInsufficientScope = "insufficient_scope"
CodeCSRFFailed = "csrf_check_failed"
CodeDependencyDown = "dependency_unavailable"
CodeDependencyTimeout = "dependency_timeout"
CodeClientClosed = "client_closed_request"
CodeModelInvalid = "model_output_invalid"
CodeFeatureDisabled = "feature_disabled"
CodeInternal = "internal_error"
)
// ErrorResponse is the body returned for every failed API request.
type ErrorResponse struct {
Error ErrorBody `json:"error"`
}
// ErrorBody carries the code, message and any field-level detail.
type ErrorBody struct {
Code string `json:"code"`
Message string `json:"message"`
Fields []FieldError `json:"fields,omitempty"`
// RequestID lets a user quote a reference when reporting a problem, and
// lets an operator find the corresponding log line.
RequestID string `json:"request_id,omitempty"`
// Detail carries safe supplementary information, such as the ID of an
// existing duplicate item.
Detail map[string]string `json:"detail,omitempty"`
}
// FieldError describes one invalid input field.
type FieldError struct {
Field string `json:"field"`
Message string `json:"message"`
}
// APIError is an error with an HTTP status and a stable code.
type APIError struct {
Status int
Code string
Message string
Fields []FieldError
Detail map[string]string
// Internal is logged but never sent to the client.
Internal error
}
func (e *APIError) Error() string {
if e.Internal != nil {
return fmt.Sprintf("%s: %v", e.Message, e.Internal)
}
return e.Message
}
func (e *APIError) Unwrap() error { return e.Internal }
// Errorf builds an APIError.
func Errorf(status int, code, format string, args ...any) *APIError {
return &APIError{Status: status, Code: code, Message: fmt.Sprintf(format, args...)}
}
// BadRequest builds a 400.
func BadRequest(msg string, fields ...FieldError) *APIError {
return &APIError{Status: http.StatusBadRequest, Code: CodeValidationFailed, Message: msg, Fields: fields}
}
// WriteError sends an error response, mapping domain errors onto HTTP.
//
// Unmapped errors become a generic 500: the internal detail is logged with the
// request ID but never returned, so a database error cannot leak schema or
// connection information to a client.
func WriteError(w http.ResponseWriter, r *http.Request, err error) {
reqID := observability.RequestID(r.Context())
log := observability.LoggerFrom(r.Context())
var apiErr *APIError
if !errors.As(err, &apiErr) {
apiErr = mapDomainError(err)
}
if apiErr.RequestIDSuppressed() {
reqID = ""
}
if apiErr.Status >= 500 {
log.Error("request failed",
"code", apiErr.Code, "path", r.URL.Path, "method", r.Method,
"error", err.Error())
} else {
log.Info("request rejected",
"code", apiErr.Code, "status", apiErr.Status, "path", r.URL.Path, "method", r.Method,
"reason", apiErr.Message)
}
body := ErrorResponse{Error: ErrorBody{
Code: apiErr.Code,
Message: apiErr.Message,
Fields: apiErr.Fields,
RequestID: reqID,
Detail: apiErr.Detail,
}}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(apiErr.Status)
_ = json.NewEncoder(w).Encode(body)
}
// RequestIDSuppressed reports whether the request ID should be withheld. It
// never is at present, but the hook keeps the decision in one place.
func (e *APIError) RequestIDSuppressed() bool { return false }
// mapDomainError converts a domain or service error into an API error.
func mapDomainError(err error) *APIError {
var ve *ingest.ValidationError
if errors.As(err, &ve) {
return &APIError{
Status: http.StatusBadRequest, Code: CodeValidationFailed,
Message: "The request could not be accepted.",
Fields: []FieldError{{Field: ve.Field, Message: ve.Message}},
}
}
var de *ingest.DuplicateError
if errors.As(err, &de) {
return &APIError{
Status: http.StatusConflict, Code: CodeDuplicate,
Message: "An item with identical content already exists. " +
"Send allow_duplicate=true to store it anyway.",
Detail: map[string]string{
"existing_item_id": de.ExistingID,
"existing_title": de.ExistingTitle,
},
}
}
switch {
case errors.Is(err, domain.ErrNotFound):
return &APIError{Status: http.StatusNotFound, Code: CodeNotFound,
Message: "The requested item does not exist."}
case errors.Is(err, domain.ErrForbidden):
return &APIError{Status: http.StatusForbidden, Code: CodeForbidden,
Message: "You do not have permission to perform this action."}
// A cross-workspace access attempt is reported as "not found" rather than
// "forbidden", so that a probe cannot confirm an ID exists elsewhere.
case errors.Is(err, domain.ErrWorkspaceMiss):
return &APIError{Status: http.StatusNotFound, Code: CodeNotFound,
Message: "The requested item does not exist."}
case errors.Is(err, domain.ErrDuplicate):
return &APIError{Status: http.StatusConflict, Code: CodeDuplicate,
Message: "That content already exists."}
case errors.Is(err, domain.ErrConflict):
return &APIError{Status: http.StatusConflict, Code: CodeConflict,
Message: "The record changed while your request was being processed. Please try again."}
case errors.Is(err, domain.ErrTooLarge):
return &APIError{Status: http.StatusRequestEntityTooLarge, Code: CodePayloadTooLarge,
Message: "The request body is larger than the configured limit."}
case errors.Is(err, domain.ErrUnsupported):
return &APIError{Status: http.StatusUnsupportedMediaType, Code: CodeUnsupportedMedia,
Message: "That content type is not supported."}
case errors.Is(err, domain.ErrInvalidInput):
return &APIError{Status: http.StatusBadRequest, Code: CodeInvalidRequest,
Message: firstLine(err.Error())}
case errors.Is(err, domain.ErrRateLimited), errors.Is(err, auth.ErrTooManyAttempts):
return &APIError{Status: http.StatusTooManyRequests, Code: CodeRateLimited,
Message: "Too many attempts. Please wait and try again."}
case errors.Is(err, auth.ErrNoSession), errors.Is(err, auth.ErrInvalidCredentials):
return &APIError{Status: http.StatusUnauthorized, Code: CodeUnauthorised,
Message: "Authentication is required."}
case errors.Is(err, analysis.ErrInvalidOutput):
return &APIError{Status: http.StatusBadGateway, Code: CodeModelInvalid,
Message: "The language model returned a response Relata could not validate. Nothing was stored."}
case errors.Is(err, analysis.ErrDisabled), errors.Is(err, search.ErrAnswerDisabled):
return &APIError{Status: http.StatusServiceUnavailable, Code: CodeFeatureDisabled,
Message: "This feature requires a language model, which is not configured."}
case errors.Is(err, domain.ErrUnavailable):
return &APIError{Status: http.StatusServiceUnavailable, Code: CodeDependencyDown,
Message: "A required service is unavailable. Please try again shortly."}
// A provider that ran out of time is a dependency problem, not an
// unexplained internal failure. Reporting it as one leaves a user with no
// idea that the fix is a bigger timeout or a smaller model.
case errors.Is(err, context.DeadlineExceeded):
return &APIError{Status: http.StatusGatewayTimeout, Code: CodeDependencyTimeout,
Message: "A language model or storage call took longer than the configured timeout. " +
"Try a smaller request, or raise RELATA_ANALYSIS_TIMEOUT and RELATA_HTTP_WRITE_TIMEOUT."}
case errors.Is(err, context.Canceled):
return &APIError{Status: 499, Code: CodeClientClosed,
Message: "The request was cancelled before it completed."}
}
return &APIError{
Status: http.StatusInternalServerError, Code: CodeInternal,
Message: "An unexpected error occurred. The request reference below can be quoted when reporting it.",
Internal: err,
}
}
// WriteJSON sends a successful JSON response.
//
// The response is encoded into a buffer first, so that a marshalling failure is
// reported as a 500 rather than truncating a body whose 200 status has already
// been written.
func WriteJSON(w http.ResponseWriter, status int, v any) {
h := w.Header()
h.Set("Content-Type", "application/json; charset=utf-8")
h.Set("X-Content-Type-Options", "nosniff")
if v == nil {
w.WriteHeader(status)
return
}
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(v); err != nil {
slog.Default().Error("could not encode response body", "error", err.Error())
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":{"code":"internal_error","message":"The response could not be encoded."}}`))
return
}
w.WriteHeader(status)
_, _ = w.Write(buf.Bytes())
}
// DecodeJSON reads a JSON request body strictly.
//
// Unknown fields are rejected so a client sending a misspelled or unsupported
// field is told, rather than having it silently ignored; trailing content is
// rejected so two concatenated documents cannot be smuggled through; and the
// body is size-limited before parsing.
func DecodeJSON(w http.ResponseWriter, r *http.Request, maxBytes int64, dst any) error {
ct := r.Header.Get("Content-Type")
if ct != "" {
base, _, _ := strings.Cut(ct, ";")
if strings.TrimSpace(base) != "application/json" {
return &APIError{
Status: http.StatusUnsupportedMediaType, Code: CodeUnsupportedMedia,
Message: "The request body must be application/json.",
}
}
}
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(dst); err != nil {
return decodeError(err, maxBytes)
}
if err := dec.Decode(&struct{}{}); err != io.EOF {
return BadRequest("The request body must contain exactly one JSON object.")
}
return nil
}
// decodeError turns a JSON decoding failure into a specific, safe message.
func decodeError(err error, maxBytes int64) error {
var syntaxErr *json.SyntaxError
var typeErr *json.UnmarshalTypeError
var maxErr *http.MaxBytesError
switch {
case errors.As(err, &syntaxErr):
return BadRequest(fmt.Sprintf("The request body is not valid JSON (at position %d).", syntaxErr.Offset))
case errors.Is(err, io.ErrUnexpectedEOF):
return BadRequest("The request body ended unexpectedly and is not valid JSON.")
case errors.As(err, &typeErr):
return BadRequest("A field has the wrong type.", FieldError{
Field: typeErr.Field,
Message: fmt.Sprintf("expected %s", typeErr.Type.String()),
})
case errors.Is(err, io.EOF):
return BadRequest("The request body is empty.")
case errors.As(err, &maxErr):
return &APIError{
Status: http.StatusRequestEntityTooLarge, Code: CodePayloadTooLarge,
Message: fmt.Sprintf("The request body must be no larger than %d bytes.", maxBytes),
}
case strings.HasPrefix(err.Error(), "json: unknown field "):
field := strings.Trim(strings.TrimPrefix(err.Error(), "json: unknown field "), `"`)
return BadRequest("The request contains a field Relata does not accept.", FieldError{
Field: field,
Message: "unknown field",
})
}
return BadRequest("The request body could not be read.")
}
func firstLine(s string) string {
if i := strings.IndexByte(s, '\n'); i >= 0 {
s = s[:i]
}
if len(s) > 300 {
s = s[:300]
}
return s
}
+152
View File
@@ -0,0 +1,152 @@
package api
import (
"context"
"errors"
"log/slog"
"net/http"
"sync"
"time"
"github.com/andydixon/relata/internal/domain"
"github.com/andydixon/relata/internal/observability"
)
// HealthChecker reports whether Relata and its dependencies are working.
type HealthChecker struct {
Mongo func(context.Context) error
Vectors domain.VectorStore
Embedder domain.HealthChecker
Analyser domain.HealthChecker
Version string
Started time.Time
}
// checkResult is one dependency's status.
type checkResult struct {
Status string `json:"status"`
Detail string `json:"detail,omitempty"`
// Required marks dependencies without which Relata cannot serve requests.
Required bool `json:"required"`
}
// Live reports that the process is running.
//
// It deliberately checks nothing else: a liveness probe that fails when a
// database is briefly unavailable causes an orchestrator to restart a process
// that was working perfectly well.
func (h *HealthChecker) Live(w http.ResponseWriter, r *http.Request) {
WriteJSON(w, http.StatusOK, map[string]any{
"status": "ok",
"version": h.Version,
"uptime_sec": int(time.Since(h.Started).Seconds()),
})
}
// Ready reports whether Relata can serve requests.
//
// MongoDB and Qdrant are required: without them nothing can be stored or
// retrieved. The AI providers are reported but not required, because Relata's
// deterministic pipeline, search and browsing all work without a model — saying
// otherwise would take the whole service down when Ollama restarts.
func (h *HealthChecker) Ready(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
checks := map[string]checkResult{}
var mu sync.Mutex
var wg sync.WaitGroup
set := func(name string, res checkResult) {
mu.Lock()
checks[name] = res
mu.Unlock()
}
// unavailable records a failed dependency. The driver's own error text is
// logged but never returned: readiness probes are unauthenticated, and
// MongoDB's topology errors carry the host, port and sometimes the
// credentials from the connection string. The caller gets a stable
// description; the operator gets the specifics in the log.
unavailable := func(name, detail string, required bool, err error) {
slog.WarnContext(ctx, "readiness check failed",
"dependency", name, "error", err, "request_id", observability.RequestID(ctx))
set(name, checkResult{Status: "unavailable", Detail: detail, Required: required})
}
if h.Mongo != nil {
wg.Add(1)
go func() {
defer wg.Done()
if err := h.Mongo(ctx); err != nil {
unavailable("mongodb",
"the database did not respond to a health check; see the server log for the cause",
true, err)
return
}
set("mongodb", checkResult{Status: "ok", Required: true})
}()
}
if h.Vectors != nil {
wg.Add(1)
go func() {
defer wg.Done()
if err := h.Vectors.Health(ctx); err != nil {
unavailable("qdrant",
"the vector store did not respond to a health check; see the server log for the cause",
true, err)
return
}
set("qdrant", checkResult{Status: "ok", Required: true})
}()
}
if h.Embedder != nil {
wg.Add(1)
go func() {
defer wg.Done()
st := h.Embedder.Health(ctx)
if !st.Available {
unavailable("embedding_provider",
"the embedding model is not reachable; new items cannot be embedded",
false, errors.New(st.Detail))
return
}
set("embedding_provider", checkResult{Status: "ok"})
}()
}
if h.Analyser != nil {
wg.Add(1)
go func() {
defer wg.Done()
st := h.Analyser.Health(ctx)
if !st.Available {
unavailable("analysis_provider",
"the analysis model is not reachable; extraction falls back to deterministic rules only",
false, errors.New(st.Detail))
return
}
set("analysis_provider", checkResult{Status: "ok"})
}()
}
wg.Wait()
status := http.StatusOK
overall := "ready"
for _, c := range checks {
if c.Required && c.Status != "ok" {
status = http.StatusServiceUnavailable
overall = "not ready"
}
}
WriteJSON(w, status, map[string]any{
"status": overall,
"checks": checks,
"version": h.Version,
"note": "AI providers are reported but not required: ingestion, search and browsing work without them.",
})
}
+44
View File
@@ -0,0 +1,44 @@
package api
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// The readiness probe is unauthenticated. MongoDB's driver errors embed the
// topology — host, port, and, when the connection string carries them,
// credentials — so the raw error must never reach the response body.
func TestReadinessDoesNotEchoDependencyErrorText(t *testing.T) {
secret := "mongodb://relata:sup3rs3cr3t@db.internal:27017/relata?authSource=admin"
h := &HealthChecker{
Mongo: func(context.Context) error {
return fmt.Errorf("server selection error: current topology: { Servers: [{ Addr: %s }] }", secret)
},
Version: "test",
Started: time.Now(),
}
w := httptest.NewRecorder()
h.Ready(w, httptest.NewRequest(http.MethodGet, "/health/ready", nil))
body := w.Body.String()
for _, leak := range []string{"sup3rs3cr3t", "db.internal", "27017", "authSource", "topology"} {
if strings.Contains(body, leak) {
t.Fatalf("readiness response leaks %q from the driver error:\n%s", leak, body)
}
}
// It must still report the failure clearly.
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want %d when a required dependency is down",
w.Code, http.StatusServiceUnavailable)
}
if !strings.Contains(body, "unavailable") {
t.Fatalf("readiness hid the failure entirely:\n%s", body)
}
}
+180
View File
@@ -0,0 +1,180 @@
package api
import (
"os"
"regexp"
"sort"
"strings"
"testing"
)
// The OpenAPI specification is written by hand, so it can drift from the code.
// This test compares it against the routes the router actually registers, which
// is the thing the specification claims to describe.
//
// The route list below is checked against router.go itself, so a route added
// there without a corresponding entry here also fails.
// specPath is the specification under test.
const specPath = "../../docs/openapi.yaml"
// routerPath is the file whose route registrations are the source of truth.
const routerPath = "router.go"
// apiRoutePattern matches the API routes registered in the router.
var apiRoutePattern = regexp.MustCompile(`mux\.Handle\("(GET|POST|PATCH|DELETE|PUT) (/api/v1[^"]*|/health/[^"]*)"`)
// specPathPattern matches top-level path keys in the specification.
var specPathPattern = regexp.MustCompile(`(?m)^ (/(?:api/v1|health)[^:]*):`)
// specMethodPattern matches an HTTP method under a path.
var specMethodPattern = regexp.MustCompile(`(?m)^ (get|post|patch|delete|put):`)
func readFile(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
return string(b)
}
// routerRoutes returns "METHOD /path" for every API route the router registers.
func routerRoutes(t *testing.T) map[string]bool {
t.Helper()
out := map[string]bool{}
for _, m := range apiRoutePattern.FindAllStringSubmatch(readFile(t, routerPath), -1) {
out[m[1]+" "+m[2]] = true
}
if len(out) == 0 {
t.Fatal("no routes were found in router.go; the matcher needs updating")
}
return out
}
// specRoutes returns "METHOD /path" for every operation in the specification.
func specRoutes(t *testing.T) map[string]bool {
t.Helper()
spec := readFile(t, specPath)
// Split the document at each path key, so methods are attributed correctly.
locs := specPathPattern.FindAllStringSubmatchIndex(spec, -1)
out := map[string]bool{}
for i, loc := range locs {
path := spec[loc[2]:loc[3]]
end := len(spec)
if i+1 < len(locs) {
end = locs[i+1][0]
}
for _, m := range specMethodPattern.FindAllStringSubmatch(spec[loc[1]:end], -1) {
out[strings.ToUpper(m[1])+" "+path] = true
}
}
if len(out) == 0 {
t.Fatal("no operations were found in the specification; the matcher needs updating")
}
return out
}
func TestOpenAPISpecMatchesRegisteredRoutes(t *testing.T) {
router := routerRoutes(t)
spec := specRoutes(t)
var missing, extra []string
for route := range router {
if !spec[route] {
missing = append(missing, route)
}
}
for route := range spec {
if !router[route] {
extra = append(extra, route)
}
}
sort.Strings(missing)
sort.Strings(extra)
if len(missing) > 0 {
t.Errorf("these routes exist but are not documented in %s:\n %s",
specPath, strings.Join(missing, "\n "))
}
if len(extra) > 0 {
t.Errorf("these routes are documented in %s but are not registered:\n %s",
specPath, strings.Join(extra, "\n "))
}
}
// The specification enumerates several closed sets that also exist as Go
// constants. A value added in one place and not the other would make the
// specification wrong in a way clients would act on.
func TestOpenAPIEnumsMatchTheDomain(t *testing.T) {
spec := readFile(t, specPath)
cases := []struct {
name string
values []string
}{
{"relationship types", []string{
"similar_to", "mentions", "involves", "supports", "contradicts", "updates",
"supersedes", "provides_context_for", "possibly_explains", "occurred_before",
"occurred_after", "derived_from", "same_entity_as",
}},
{"provenance", []string{
"explicit", "deterministic", "model_inferred", "user_confirmed", "user_rejected",
}},
{"entity types", []string{
"person", "organisation", "location", "product", "event", "date", "subject", "concept",
}},
{"source types", []string{
"note", "document", "json", "email", "transcript", "markdown",
}},
{"job types", []string{
"parse_item", "chunk_item", "generate_embeddings", "extract_entities",
"extract_claims", "discover_relationships", "reprocess_item",
"delete_item_data", "reembed_workspace",
}},
{"error codes", []string{
CodeInvalidRequest, CodeValidationFailed, CodeUnauthorised, CodeForbidden,
CodeNotFound, CodeDuplicate, CodeConflict, CodePayloadTooLarge,
CodeUnsupportedMedia, CodeRateLimited, CodeCSRFFailed, CodeDependencyDown,
CodeModelInvalid, CodeFeatureDisabled, CodeInternal,
}},
{"signal types", []string{
"semantic_similarity", "shared_entity", "alias_entity", "shared_concept",
"temporal_proximity", "shared_metadata", "claim_support", "claim_contradiction",
"model_classification", "user_confirmation", "user_rejection", "content_hash_match",
}},
}
for _, c := range cases {
for _, v := range c.values {
if !strings.Contains(spec, v) {
t.Errorf("%s: %q is not documented in the specification", c.name, v)
}
}
}
}
// The specification must not describe scores as probabilities, since the whole
// design rests on their not being probabilities.
func TestOpenAPIDescribesScoresHonestly(t *testing.T) {
spec := readFile(t, specPath)
for _, required := range []string{
"NOT a calibrated probability",
"Not a probability",
"not a fact",
} {
if !strings.Contains(spec, required) {
t.Errorf("the specification does not state %q", required)
}
}
// A claim that would be untrue.
for _, forbidden := range []string{"probability that", "confidence interval", "calibrated probability that"} {
if strings.Contains(strings.ToLower(spec), strings.ToLower(forbidden)) {
t.Errorf("the specification contains the misleading phrase %q", forbidden)
}
}
}
+367
View File
@@ -0,0 +1,367 @@
package api
import (
"net/http"
"strings"
"github.com/andydixon/relata/internal/auth"
"github.com/andydixon/relata/internal/config"
"github.com/andydixon/relata/internal/domain"
"github.com/andydixon/relata/internal/observability"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
// WebRoutes is implemented by the HTML handler set, so the router can mount the
// browser interface without importing the web package (which imports this one).
type WebRoutes interface {
LoginPage(http.ResponseWriter, *http.Request)
Login(http.ResponseWriter, *http.Request)
Logout(http.ResponseWriter, *http.Request)
OAuthStart(http.ResponseWriter, *http.Request)
OAuthCallback(http.ResponseWriter, *http.Request)
LandingPage(http.ResponseWriter, *http.Request)
HowItWorksPage(http.ResponseWriter, *http.Request)
PublicAPIPage(http.ResponseWriter, *http.Request)
TokensPage(http.ResponseWriter, *http.Request)
CreateToken(http.ResponseWriter, *http.Request)
RevokeToken(http.ResponseWriter, *http.Request)
Dashboard(http.ResponseWriter, *http.Request)
ItemsPage(http.ResponseWriter, *http.Request)
ItemDetail(http.ResponseWriter, *http.Request)
Neighbourhood(http.ResponseWriter, *http.Request)
DownloadOriginal(http.ResponseWriter, *http.Request)
AddTextPage(http.ResponseWriter, *http.Request)
AddText(http.ResponseWriter, *http.Request)
UploadPage(http.ResponseWriter, *http.Request)
Upload(http.ResponseWriter, *http.Request)
DeleteItem(http.ResponseWriter, *http.Request)
ReprocessItem(http.ResponseWriter, *http.Request)
SearchPage(http.ResponseWriter, *http.Request)
ReviewPage(http.ResponseWriter, *http.Request)
ConfirmRelationship(http.ResponseWriter, *http.Request)
RejectRelationship(http.ResponseWriter, *http.Request)
ChangeRelationshipType(http.ResponseWriter, *http.Request)
CreateRelationship(http.ResponseWriter, *http.Request)
EntitiesPage(http.ResponseWriter, *http.Request)
EntityDetail(http.ResponseWriter, *http.Request)
ConfirmAlias(http.ResponseWriter, *http.Request)
RejectAlias(http.ResponseWriter, *http.Request)
JobsPage(http.ResponseWriter, *http.Request)
JobsTable(http.ResponseWriter, *http.Request)
RetryJob(http.ResponseWriter, *http.Request)
SettingsPage(http.ResponseWriter, *http.Request)
Reembed(http.ResponseWriter, *http.Request)
}
// RouterConfig holds what the router needs to wire the application together.
type RouterConfig struct {
Cfg *config.Config
Auth *auth.Service
Tokens *auth.TokenService
API *Handlers
Web WebRoutes
Health *HealthChecker
Metrics *observability.Metrics
Static http.Handler
Logger interface{ Handler() any }
}
// NewRouter builds the complete HTTP handler.
//
// Route registration is explicit rather than pattern-driven, so that the exact
// authentication, CSRF and body-size treatment of every endpoint is readable in
// one place.
func NewRouter(rc RouterConfig) http.Handler {
mux := http.NewServeMux()
logging := Logging{Metrics: rc.Metrics}
authn := Authenticator{Auth: rc.Auth, LoginPath: "/login"}
apiAuthn := Authenticator{Auth: rc.Auth, Tokens: rc.Tokens, APIMode: true}
csrf := CSRF{
TrustedOrigins: trustedOrigins(rc.Cfg),
HeaderName: "X-CSRF-Token",
FieldName: "csrf_token",
}
// --- Health and metrics -------------------------------------------------
//
// Health endpoints are unauthenticated so an orchestrator can reach them,
// but they expose only status, never workspace content.
mux.Handle("GET /health/live", wrap(rc, "/health/live", http.HandlerFunc(rc.Health.Live)))
mux.Handle("GET /health/ready", wrap(rc, "/health/ready", http.HandlerFunc(rc.Health.Ready)))
if rc.Cfg.HTTP.MetricsEnabled && rc.Metrics != nil {
// Metrics describe the service, not stored knowledge. They are bound to
// the same loopback listener as everything else.
mux.Handle("GET /metrics", promhttp.HandlerFor(rc.Metrics.Registry, promhttp.HandlerOpts{
ErrorHandling: promhttp.ContinueOnError,
}))
}
// --- Static assets ------------------------------------------------------
mux.Handle("GET /static/", staticHandler(rc.Static))
// --- REST API -----------------------------------------------------------
//
// API routes authenticate from the session cookie and enforce CSRF on
// state-changing methods, exactly as the browser interface does.
// Every API route names the scope it needs. CSRF is applied unconditionally
// because the middleware skips safe methods itself and exempts bearer
// requests — so no route can be registered having forgotten it.
//
// RequireScope subsumes the old RequireWrite: the scope check is the
// intersection of the caller's role and, for a token, its granted scopes.
apiChain := func(route string, h http.HandlerFunc, scope domain.TokenScope) http.Handler {
return Chain(h,
RequestID(),
Recover(),
logging.Middleware(route),
SecurityHeaders(false),
apiAuthn.RequireAuth(),
csrf.Middleware(),
RequireScope(scope),
)
}
// Token management is deliberately not scoped: it requires an interactive
// session, so a leaked token cannot mint its own replacements.
tokenChain := func(route string, h http.HandlerFunc) http.Handler {
return Chain(h,
RequestID(),
Recover(),
logging.Middleware(route),
SecurityHeaders(false),
apiAuthn.RequireAuth(),
csrf.Middleware(),
RequireSession(),
)
}
a := rc.API
mux.Handle("POST /api/v1/items", apiChain("/api/v1/items", a.CreateItem, domain.ScopeIngest))
mux.Handle("POST /api/v1/items/upload", apiChain("/api/v1/items/upload", a.UploadItem, domain.ScopeIngest))
mux.Handle("GET /api/v1/items", apiChain("/api/v1/items", a.ListItems, domain.ScopeQuery))
mux.Handle("GET /api/v1/items/{id}", apiChain("/api/v1/items/{id}", a.GetItem, domain.ScopeQuery))
mux.Handle("DELETE /api/v1/items/{id}", apiChain("/api/v1/items/{id}", a.DeleteItem, domain.ScopeIngest))
mux.Handle("POST /api/v1/items/{id}/reprocess", apiChain("/api/v1/items/{id}/reprocess", a.ReprocessItem, domain.ScopeIngest))
mux.Handle("GET /api/v1/items/{id}/relationships", apiChain("/api/v1/items/{id}/relationships", a.GetItemRelationships, domain.ScopeQuery))
mux.Handle("GET /api/v1/search", apiChain("/api/v1/search", a.Search, domain.ScopeQuery))
// Answering reads; it creates nothing. It needs the query scope, not ingest.
mux.Handle("POST /api/v1/search/answer", apiChain("/api/v1/search/answer", a.Answer, domain.ScopeQuery))
mux.Handle("GET /api/v1/relationships", apiChain("/api/v1/relationships", a.ListRelationships, domain.ScopeQuery))
mux.Handle("POST /api/v1/relationships", apiChain("/api/v1/relationships", a.CreateRelationship, domain.ScopeReview))
mux.Handle("PATCH /api/v1/relationships/{id}", apiChain("/api/v1/relationships/{id}", a.PatchRelationship, domain.ScopeReview))
mux.Handle("POST /api/v1/relationships/{id}/confirm", apiChain("/api/v1/relationships/{id}/confirm", a.ConfirmRelationship, domain.ScopeReview))
mux.Handle("POST /api/v1/relationships/{id}/reject", apiChain("/api/v1/relationships/{id}/reject", a.RejectRelationship, domain.ScopeReview))
mux.Handle("GET /api/v1/entities", apiChain("/api/v1/entities", a.ListEntities, domain.ScopeQuery))
mux.Handle("GET /api/v1/entities/{id}", apiChain("/api/v1/entities/{id}", a.GetEntity, domain.ScopeQuery))
mux.Handle("POST /api/v1/aliases/{id}/confirm", apiChain("/api/v1/aliases/{id}/confirm", a.ConfirmAlias, domain.ScopeReview))
mux.Handle("POST /api/v1/aliases/{id}/reject", apiChain("/api/v1/aliases/{id}/reject", a.RejectAlias, domain.ScopeReview))
mux.Handle("GET /api/v1/jobs", apiChain("/api/v1/jobs", a.ListJobs, domain.ScopeQuery))
mux.Handle("POST /api/v1/jobs/{id}/retry", apiChain("/api/v1/jobs/{id}/retry", a.RetryJob, domain.ScopeIngest))
mux.Handle("GET /api/v1/tokens", tokenChain("/api/v1/tokens", a.ListTokens))
mux.Handle("POST /api/v1/tokens", tokenChain("/api/v1/tokens", a.CreateToken))
mux.Handle("DELETE /api/v1/tokens/{id}", tokenChain("/api/v1/tokens/{id}", a.RevokeToken))
// --- Browser interface --------------------------------------------------
webChain := func(route string, h http.HandlerFunc, opts webOpts) http.Handler {
mw := []Middleware{
RequestID(),
Recover(),
logging.Middleware(route),
SecurityHeaders(false),
}
if opts.authenticated {
mw = append(mw, authn.RequireAuth())
} else if opts.optionalAuth {
mw = append(mw, authn.OptionalAuth())
}
if opts.csrf {
mw = append(mw, csrf.Middleware())
}
if opts.write {
mw = append(mw, RequireWrite())
}
return Chain(h, mw...)
}
pub := webOpts{}
read := webOpts{authenticated: true}
write := webOpts{authenticated: true, csrf: true, write: true}
wb := rc.Web
mux.Handle("GET /login", webChain("/login", wb.LoginPage, pub))
// The password endpoint is registered only when local login is enabled, so
// a disabled break-glass path is genuinely absent rather than present and
// refusing. There is no handler to reach and nothing to probe.
if rc.Cfg.Auth.LocalLoginEnabled {
// It has its own CSRF handling: no session exists yet, so the
// session-token check cannot apply.
mux.Handle("POST /login", webChain("/login", wb.Login, pub))
}
mux.Handle("POST /logout", webChain("/logout", wb.Logout, webOpts{authenticated: true, csrf: true}))
// Federated sign-in. Both are public: the first has no session yet, and the
// second is where the provider sends the browser back to.
mux.Handle("GET /auth/oauth/start", webChain("/auth/oauth/start", wb.OAuthStart, pub))
mux.Handle("GET "+config.CallbackPath, webChain(config.CallbackPath, wb.OAuthCallback, pub))
// The public site. These pages read nothing from the database.
mux.Handle("GET /how-it-works", webChain("/how-it-works", wb.HowItWorksPage, pub))
mux.Handle("GET /api", webChain("/api", wb.PublicAPIPage, pub))
// The only dual-purpose route: the dashboard when signed in, the landing
// page when not. OptionalAuth rather than RequireAuth, so an anonymous
// visitor is served rather than redirected.
mux.Handle("GET /{$}", webChain("/", wb.LandingPage, webOpts{optionalAuth: true}))
mux.Handle("GET /items", webChain("/items", wb.ItemsPage, read))
mux.Handle("GET /items/{id}", webChain("/items/{id}", wb.ItemDetail, read))
mux.Handle("GET /items/{id}/neighbourhood", webChain("/items/{id}/neighbourhood", wb.Neighbourhood, read))
mux.Handle("GET /items/{id}/download", webChain("/items/{id}/download", wb.DownloadOriginal, read))
mux.Handle("POST /items/{id}/delete", webChain("/items/{id}/delete", wb.DeleteItem, write))
mux.Handle("POST /items/{id}/reprocess", webChain("/items/{id}/reprocess", wb.ReprocessItem, write))
mux.Handle("GET /add", webChain("/add", wb.AddTextPage, read))
mux.Handle("POST /add", webChain("/add", wb.AddText, write))
mux.Handle("GET /upload", webChain("/upload", wb.UploadPage, read))
mux.Handle("POST /upload", webChain("/upload", wb.Upload, write))
mux.Handle("GET /search", webChain("/search", wb.SearchPage, read))
mux.Handle("GET /review", webChain("/review", wb.ReviewPage, read))
mux.Handle("POST /relationships/create", webChain("/relationships/create", wb.CreateRelationship, write))
mux.Handle("POST /relationships/{id}/confirm", webChain("/relationships/{id}/confirm", wb.ConfirmRelationship, write))
mux.Handle("POST /relationships/{id}/reject", webChain("/relationships/{id}/reject", wb.RejectRelationship, write))
mux.Handle("POST /relationships/{id}/type", webChain("/relationships/{id}/type", wb.ChangeRelationshipType, write))
mux.Handle("GET /entities", webChain("/entities", wb.EntitiesPage, read))
mux.Handle("GET /entities/{id}", webChain("/entities/{id}", wb.EntityDetail, read))
mux.Handle("POST /aliases/{id}/confirm", webChain("/aliases/{id}/confirm", wb.ConfirmAlias, write))
mux.Handle("POST /aliases/{id}/reject", webChain("/aliases/{id}/reject", wb.RejectAlias, write))
mux.Handle("GET /jobs", webChain("/jobs", wb.JobsPage, read))
mux.Handle("GET /jobs/table", webChain("/jobs/table", wb.JobsTable, read))
mux.Handle("POST /jobs/{id}/retry", webChain("/jobs/{id}/retry", wb.RetryJob, write))
mux.Handle("GET /settings", webChain("/settings", wb.SettingsPage, read))
mux.Handle("POST /settings/reembed", webChain("/settings/reembed", wb.Reembed, write))
mux.Handle("GET /settings/tokens", webChain("/settings/tokens", wb.TokensPage, read))
mux.Handle("POST /settings/tokens", webChain("/settings/tokens", wb.CreateToken, write))
mux.Handle("POST /settings/tokens/{id}/revoke", webChain("/settings/tokens/{id}/revoke", wb.RevokeToken, write))
// Anything unmatched. The handler distinguishes API paths from browser
// paths so a mistyped API URL gets JSON, not HTML.
mux.Handle("/", Chain(http.HandlerFunc(notFound),
RequestID(), Recover(), logging.Middleware("not_found"), SecurityHeaders(false)))
// The outermost body limit applies to every route. Upload routes raise it
// themselves, since a document is legitimately larger than a JSON request.
return MaxBody(maxBodyFor(rc.Cfg))(mux)
}
type webOpts struct {
authenticated bool
// optionalAuth resolves a session when one is present but serves anonymous
// callers rather than redirecting them.
optionalAuth bool
csrf bool
write bool
}
// maxBodyFor returns the global request-body ceiling.
//
// It is the larger of the JSON limit and the upload limit plus multipart
// overhead, because one middleware guards both kinds of route.
func maxBodyFor(cfg *config.Config) int64 {
limit := cfg.HTTP.MaxRequestBytes
if upload := cfg.Uploads.MaxUploadBytes + (2 << 20); upload > limit {
limit = upload
}
return limit
}
// wrap applies the standard middleware to an unauthenticated endpoint.
func wrap(rc RouterConfig, route string, h http.Handler) http.Handler {
logging := Logging{Metrics: rc.Metrics}
return Chain(h, RequestID(), Recover(), logging.Middleware(route), SecurityHeaders(false))
}
// staticHandler serves embedded assets with long-lived caching.
//
// Static files carry no user content, so they are the one place where caching
// is safe; the no-store default set by SecurityHeaders is overridden here.
func staticHandler(fs http.Handler) http.Handler {
return http.StripPrefix("/static/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Reject any path attempting to escape the asset root. http.FS already
// refuses these, but failing early keeps the intent explicit.
if strings.Contains(r.URL.Path, "..") {
http.NotFound(w, r)
return
}
w.Header().Set("Cache-Control", "public, max-age=3600")
w.Header().Set("X-Content-Type-Options", "nosniff")
fs.ServeHTTP(w, r)
}))
}
// notFound answers unmatched routes in the right format.
func notFound(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/") {
WriteError(w, r, &APIError{
Status: http.StatusNotFound, Code: CodeNotFound,
Message: "No such endpoint. The API is versioned under /api/v1.",
})
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`<!DOCTYPE html><html lang="en-GB"><head><meta charset="utf-8">` +
`<title>Not found — Relata</title><link rel="stylesheet" href="/static/app.css"></head>` +
`<body><main class="page"><div class="error-page"><h1>Page not found</h1>` +
`<p>There is nothing at this address.</p>` +
`<p><a class="btn" href="/">Back to the dashboard</a></p></div></main></body></html>`))
}
// trustedOrigins derives the origins permitted to submit state-changing
// requests, from the configured base URL and listen address.
func trustedOrigins(cfg *config.Config) []string {
origins := []string{}
if base := strings.TrimRight(cfg.HTTP.BaseURL, "/"); base != "" {
origins = append(origins, base)
}
// The listen address is also accepted, so that reaching Relata directly at
// http://127.0.0.1:8080 works when BaseURL names something else.
addr := cfg.HTTP.Addr
if strings.HasPrefix(addr, ":") {
addr = "127.0.0.1" + addr
}
for _, scheme := range []string{"http://", "https://"} {
o := scheme + addr
if !containsStr(origins, o) {
origins = append(origins, o)
}
}
// localhost and 127.0.0.1 are the same host to a person but different
// origins to a browser.
if strings.HasPrefix(addr, "127.0.0.1:") {
port := strings.TrimPrefix(addr, "127.0.0.1")
origins = append(origins, "http://localhost"+port, "https://localhost"+port)
}
return origins
}
func containsStr(list []string, v string) bool {
for _, e := range list {
if e == v {
return true
}
}
return false
}
+170
View File
@@ -0,0 +1,170 @@
package api
import (
"net/http"
"time"
"github.com/andydixon/relata/internal/auth"
"github.com/andydixon/relata/internal/domain"
)
// tokenView is a token as returned to a caller.
//
// There is no field for the secret because there is no secret to return: only
// its hash was stored. The prefix is enough to tell one token from another.
type tokenView struct {
ID string `json:"id"`
Name string `json:"name"`
Prefix string `json:"prefix"`
Scopes []domain.TokenScope `json:"scopes"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
Status string `json:"status"`
}
func viewToken(t *domain.APIToken, now time.Time) tokenView {
v := tokenView{
ID: t.ID, Name: t.Name, Prefix: t.Prefix, Scopes: t.Scopes,
CreatedAt: t.CreatedAt, ExpiresAt: t.ExpiresAt,
LastUsedAt: t.LastUsedAt, RevokedAt: t.RevokedAt,
Status: "active",
}
switch {
case t.RevokedAt != nil:
v.Status = "revoked"
case !t.ExpiresAt.IsZero() && !now.Before(t.ExpiresAt):
v.Status = "expired"
}
if v.Scopes == nil {
v.Scopes = []domain.TokenScope{}
}
return v
}
// ListTokens returns the caller's own API tokens.
func (h *Handlers) ListTokens(w http.ResponseWriter, r *http.Request) {
p, err := principal(r)
if err != nil {
WriteError(w, r, err)
return
}
toks, err := h.d.Tokens.List(r.Context(), p)
if err != nil {
WriteError(w, r, err)
return
}
now := time.Now().UTC()
out := make([]tokenView, 0, len(toks))
for _, t := range toks {
out = append(out, viewToken(t, now))
}
WriteJSON(w, http.StatusOK, map[string]any{"tokens": out, "total": len(out)})
}
// createTokenRequest is the JSON body for issuing a token.
type createTokenRequest struct {
Name string `json:"name"`
Scopes []string `json:"scopes"`
// TTLHours is optional; the configured default applies when it is absent.
TTLHours int `json:"ttl_hours"`
}
// createTokenResponse is the only place a token secret ever appears.
type createTokenResponse struct {
Token tokenView `json:"token"`
// Secret is shown once. It cannot be retrieved again.
Secret string `json:"secret"`
Warning string `json:"warning"`
}
// CreateToken issues an API token for the caller.
func (h *Handlers) CreateToken(w http.ResponseWriter, r *http.Request) {
p, err := principal(r)
if err != nil {
WriteError(w, r, err)
return
}
var req createTokenRequest
if err := DecodeJSON(w, r, 8<<10, &req); err != nil {
WriteError(w, r, err)
return
}
scopes, err := parseScopes(req.Scopes)
if err != nil {
WriteError(w, r, err)
return
}
ttl := time.Duration(req.TTLHours) * time.Hour
issued, err := h.d.Tokens.Create(r.Context(), p, auth.CreateRequest{
Name: req.Name, Scopes: scopes, TTL: ttl,
})
if err != nil {
WriteError(w, r, err)
return
}
h.d.Audit.Record(r.Context(), p, domain.AuditTokenCreated, "api_token", issued.Token.ID, "success",
map[string]string{"name": issued.Token.Name, "prefix": issued.Token.Prefix})
WriteJSON(w, http.StatusCreated, createTokenResponse{
Token: viewToken(issued.Token, time.Now().UTC()),
Secret: issued.Secret,
Warning: "This is the only time the token is shown. Store it now; " +
"Relata keeps only a hash and cannot show it again.",
})
}
// RevokeToken makes one of the caller's tokens unusable.
func (h *Handlers) RevokeToken(w http.ResponseWriter, r *http.Request) {
p, err := principal(r)
if err != nil {
WriteError(w, r, err)
return
}
id, err := pathID(r, "id")
if err != nil {
WriteError(w, r, err)
return
}
if err := h.d.Tokens.Revoke(r.Context(), p, id); err != nil {
WriteError(w, r, err)
return
}
h.d.Audit.Record(r.Context(), p, domain.AuditTokenRevoked, "api_token", id, "success", nil)
WriteJSON(w, http.StatusOK, map[string]any{"revoked": true, "id": id})
}
// parseScopes validates requested scopes against the closed set.
//
// An unrecognised scope is refused rather than ignored: silently dropping one
// would issue a token weaker than the caller believes they hold.
func parseScopes(raw []string) ([]domain.TokenScope, error) {
if len(raw) == 0 {
return nil, &APIError{
Status: http.StatusBadRequest, Code: CodeInvalidRequest,
Message: "Choose at least one scope for the token.",
Detail: map[string]string{"available": "ingest, query, review"},
}
}
out := make([]domain.TokenScope, 0, len(raw))
for _, s := range raw {
sc := domain.TokenScope(s)
if !sc.Valid() {
return nil, &APIError{
Status: http.StatusBadRequest, Code: CodeInvalidRequest,
Message: "That is not a recognised scope.",
Detail: map[string]string{"scope": s, "available": "ingest, query, review"},
}
}
out = append(out, sc)
}
return out, nil
}