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.
This commit is contained in:
Andy Dixon
2026-06-15 20:22:19 +01:00
parent 6374e174c6
commit a002a2c100
40 changed files with 3081 additions and 20 deletions
+60
View File
@@ -0,0 +1,60 @@
{{define "layout"}}<!doctype html>
<html lang="en" data-theme="auto">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{.CSRF}}">
<title>{{if .Title}}{{.Title}} · {{end}}Noema</title>
<link rel="stylesheet" href="/static/app.css">
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
<script src="/static/app.js" defer></script>
</head>
<body class="{{if .Principal}}app{{else}}bare{{end}}">
{{if .Principal}}
<a class="skip" href="#main">Skip to content</a>
<header class="topbar">
<button class="iconbtn nav-toggle" aria-label="Toggle navigation" aria-expanded="false" data-nav-toggle>☰</button>
<a class="brand" href="/"><span class="brand-mark" aria-hidden="true"></span>Noema</a>
<div class="topbar-spacer"></div>
<span class="muted small">{{.Principal.User.Username}} · {{.Principal.User.Role}}</span>
<button class="iconbtn" data-theme-toggle aria-label="Toggle colour theme" title="Toggle theme (t)">◐</button>
<form method="post" action="/logout" class="inline"><input type="hidden" name="_csrf" value="{{.CSRF}}"><button class="btn btn-ghost small" type="submit">Sign out</button></form>
</header>
<div class="shell">
<nav class="sidenav" aria-label="Primary" data-nav>
{{template "nav" .}}
</nav>
<main id="main" class="content" tabindex="-1">
{{with .Flash}}<div class="flash flash-{{.Kind}}" role="status">{{.Message}}</div>{{end}}
{{template "content" .}}
</main>
</div>
{{else}}
<main id="main" class="bare-main">
{{with .Flash}}<div class="flash flash-{{.Kind}}" role="status">{{.Message}}</div>{{end}}
{{template "content" .}}
</main>
{{end}}
<footer class="footer muted small">Noema {{.Version}} · models cognitive processes; it does not claim consciousness. <a href="/about">Interpretation boundary</a></footer>
</body>
</html>{{end}}
{{define "nav"}}
<div class="navgroup"><div class="navhead">Observe</div>
<a href="/" {{if eq .Path "/"}}aria-current="page"{{end}}>Dashboard</a>
<a href="/minds" {{if hasPrefix .Path "/minds"}}aria-current="page"{{end}}>Minds</a>
<a href="/approvals" {{if hasPrefix .Path "/approvals"}}aria-current="page"{{end}}>Approvals</a>
</div>
<div class="navgroup"><div class="navhead">Configure</div>
<a href="/llm" {{if hasPrefix .Path "/llm"}}aria-current="page"{{end}}>LLM</a>
<a href="/prompts" {{if hasPrefix .Path "/prompts"}}aria-current="page"{{end}}>Prompts</a>
<a href="/integrations" {{if hasPrefix .Path "/integrations"}}aria-current="page"{{end}}>Integrations</a>
<a href="/policies" {{if hasPrefix .Path "/policies"}}aria-current="page"{{end}}>Policies</a>
</div>
<div class="navgroup"><div class="navhead">System</div>
<a href="/system" {{if eq .Path "/system"}}aria-current="page"{{end}}>System</a>
<a href="/audit" {{if eq .Path "/audit"}}aria-current="page"{{end}}>Audit</a>
{{if .Principal.User.Role.AtLeast "administrator"}}<a href="/admin/users" {{if hasPrefix .Path "/admin/users"}}aria-current="page"{{end}}>Users</a>{{end}}
<a href="/account" {{if eq .Path "/account"}}aria-current="page"{{end}}>Account</a>
</div>
{{end}}
+9
View File
@@ -0,0 +1,9 @@
{{define "content"}}
<h1>Interpretation boundary</h1>
<div class="prose">
<p>Noema models cognitive processes. It does not prove, and its authors do not claim, that it is conscious, sentient, self-aware or a person.</p>
<p>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.</p>
<p>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.</p>
<p>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.</p>
</div>
{{end}}
+42
View File
@@ -0,0 +1,42 @@
{{define "content"}}
<div class="pagehead"><h1>Account</h1><p class="muted">Signed in as <strong>{{.Principal.User.Username}}</strong> ({{.Principal.User.Role}}).</p></div>
{{with .Data.NewToken}}<div class="card highlight"><h2>New API token</h2><p>Copy it now. It will not be shown again.</p><pre class="token"><code>{{.}}</code></pre><p class="muted small">Use it as <code>Authorization: Bearer …</code> or <code>NOEMA_API_TOKEN</code> for noemactl.</p></div>{{end}}
<section class="grid cols-2">
<div class="card">
<h2>Sessions</h2>
<table class="table compact">
<thead><tr><th>Where</th><th>Client</th><th>Last seen</th><th></th></tr></thead>
<tbody>{{range .Data.Sessions}}<tr>
<td>{{.IP}}{{if eq .ID $.Principal.SessionID}} <span class="badge">current</span>{{end}}</td>
<td class="small muted truncate" title="{{.UserAgent}}">{{.UserAgent}}</td>
<td class="small muted nowrap">{{ago .LastSeenAt}}</td>
<td>{{if ne .ID $.Principal.SessionID}}<form method="post" action="/account/sessions/{{.ID}}/revoke" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="btn small">Revoke</button></form>{{end}}</td>
</tr>{{end}}</tbody>
</table>
</div>
<div class="card">
<h2>Change password</h2>
<form method="post" action="/account/password" class="stack">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<label>Current password <input type="password" name="current" required autocomplete="current-password"></label>
<label>New password <input type="password" name="password" required minlength="12" autocomplete="new-password"></label>
<label>Confirm <input type="password" name="confirm" required minlength="12" autocomplete="new-password"></label>
<button class="btn btn-primary" type="submit">Change password</button>
<p class="muted small">Other sessions are signed out when the password changes.</p>
</form>
</div>
<div class="card">
<h2>API tokens</h2>
<table class="table compact">
<thead><tr><th>Name</th><th>Created</th><th>Last used</th><th></th></tr></thead>
<tbody>{{range .Data.Tokens}}<tr><td>{{.Name}}</td><td class="small muted">{{date .CreatedAt}}</td><td class="small muted">{{if .LastUsedAt}}{{ago .LastUsedAt}}{{else}}never{{end}}</td>
<td><form method="post" action="/account/tokens/{{.ID}}/revoke" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="btn small">Revoke</button></form></td></tr>{{else}}<tr><td colspan="4" class="muted">No tokens.</td></tr>{{end}}</tbody>
</table>
<form method="post" action="/account/tokens" class="row">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<input name="name" placeholder="Token name (e.g. laptop cli)" required maxlength="64">
<button class="btn" type="submit">Create token</button>
</form>
</div>
</section>
{{end}}
+15
View File
@@ -0,0 +1,15 @@
{{define "content"}}
<div class="pagehead"><h1>Audit</h1><p class="muted">Every administrative change, append-only.</p></div>
<div class="card">
<table class="table compact">
<thead><tr><th>#</th><th>When</th><th>Actor</th><th>Action</th><th>Target</th><th>Details</th><th>IP</th></tr></thead>
<tbody>{{range .Data.Entries}}<tr>
<td class="muted">{{.Seq}}</td><td class="nowrap muted small">{{.At}}</td>
<td>{{if .ActorName}}{{.ActorName}}{{else}}<span class="muted">{{.ActorKind}}</span>{{end}}</td>
<td><code>{{.Action}}</code></td><td class="small">{{.TargetKind}} <span class="muted">{{.TargetID}}</span></td>
<td><code class="small">{{printf "%s" .Details}}</code></td><td class="muted small">{{.IP}}</td>
</tr>{{else}}<tr><td colspan="7" class="muted">No entries.</td></tr>{{end}}</tbody>
</table>
{{with .Data.Next}}<p><a class="btn" href="/audit?before={{.}}">Older</a></p>{{end}}
</div>
{{end}}
+27
View File
@@ -0,0 +1,27 @@
{{define "content"}}
<div class="pagehead"><h1>Dashboard</h1><p class="muted">System overview. Minds, cognition and approvals appear here as they come online.</p></div>
<section class="grid cols-3">
<div class="card stat"><div class="stat-label">Running minds</div><div class="stat-value">{{.Data.Minds}}</div><div class="muted small">No minds yet. Create one under Minds.</div></div>
<div class="card stat"><div class="stat-label">System health</div><div class="stat-value">{{if .Data.Status.Database.OK}}<span class="ok">Healthy</span>{{else}}<span class="danger">Degraded</span>{{end}}</div><div class="muted small">Database {{if .Data.Status.Database.OK}}connected{{else}}unavailable{{end}} · up {{duration .Data.Status.UptimeSeconds}}</div></div>
<div class="card stat"><div class="stat-label">Outstanding approvals</div><div class="stat-value">0</div><div class="muted small">Capability layer arrives in a later phase.</div></div>
</section>
<section class="grid cols-2">
<div class="card">
<h2>Recent administrative activity</h2>
{{if .Data.Audit}}
<table class="table compact">
<thead><tr><th>When</th><th>Actor</th><th>Action</th><th>Target</th></tr></thead>
<tbody>{{range .Data.Audit}}<tr><td class="nowrap muted">{{.At}}</td><td>{{.ActorName}}{{if not .ActorName}}<span class="muted">{{.ActorKind}}</span>{{end}}</td><td><code>{{.Action}}</code></td><td class="muted small">{{.TargetKind}} {{.TargetID}}</td></tr>{{end}}</tbody>
</table>
{{else}}<p class="muted">Nothing yet.</p>{{end}}
</div>
<div class="card">
<h2>Getting started</h2>
<ol class="steps">
<li>Add an LLM provider under <a href="/llm">LLM</a>.</li>
<li>Create a mind under <a href="/minds">Minds</a> and shape its personality.</li>
<li>Open its conversation page and watch the thought stream.</li>
</ol>
</div>
</section>
{{end}}
+7
View File
@@ -0,0 +1,7 @@
{{define "content"}}
<div class="card auth-card">
<h1>{{.Data.Status}}</h1>
<p>{{.Data.Message}}</p>
<p><a href="/">Back to dashboard</a></p>
</div>
{{end}}
+14
View File
@@ -0,0 +1,14 @@
{{define "content"}}
<div class="card auth-card">
<div class="brand big"><span class="brand-mark" aria-hidden="true"></span>Noema</div>
<p class="muted">Sign in to the cognitive runtime.</p>
<form method="post" action="/login" class="stack">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<input type="hidden" name="next" value="{{.Data.Next}}">
<label>Username <input name="username" autocomplete="username" required autofocus></label>
<label>Password <input type="password" name="password" autocomplete="current-password" required></label>
{{with .Data.Error}}<p class="error" role="alert">{{.}}</p>{{end}}
<button class="btn btn-primary" type="submit">Sign in</button>
</form>
</div>
{{end}}
+3
View File
@@ -0,0 +1,3 @@
{{define "content"}}
<div class="pagehead"><h1>{{.Title}}</h1><p class="muted">{{.Data}}</p></div>
{{end}}
+15
View File
@@ -0,0 +1,15 @@
{{define "content"}}
<div class="card auth-card">
<div class="brand big"><span class="brand-mark" aria-hidden="true"></span>Noema</div>
<h1>First-run setup</h1>
<p class="muted">No accounts exist yet. Create the first administrator. This page disappears once an account exists.</p>
<form method="post" action="/setup" class="stack">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<label>Username <input name="username" autocomplete="username" required minlength="2" maxlength="64" autofocus></label>
<label>Password <input type="password" name="password" autocomplete="new-password" required minlength="12"></label>
<label>Confirm password <input type="password" name="confirm" autocomplete="new-password" required minlength="12"></label>
{{with .Data.Error}}<p class="error" role="alert">{{.}}</p>{{end}}
<button class="btn btn-primary" type="submit">Create administrator</button>
</form>
</div>
{{end}}
+28
View File
@@ -0,0 +1,28 @@
{{define "content"}}
<div class="pagehead"><h1>System</h1><p class="muted">Runtime, database and migration state.</p></div>
{{$s := .Data}}
<section class="grid cols-4">
<div class="card stat"><div class="stat-label">Version</div><div class="stat-value small">{{$s.Version}}</div><div class="muted small">{{$s.GoVersion}}</div></div>
<div class="card stat"><div class="stat-label">Uptime</div><div class="stat-value small">{{duration $s.UptimeSeconds}}</div></div>
<div class="card stat"><div class="stat-label">Goroutines</div><div class="stat-value small">{{$s.Goroutines}}</div><div class="muted small">{{$s.NumCPU}} CPUs</div></div>
<div class="card stat"><div class="stat-label">Memory</div><div class="stat-value small">{{bytes $s.HeapBytes}}</div><div class="muted small">heap · {{bytes $s.SysBytes}} from OS</div></div>
</section>
<section class="grid cols-2">
<div class="card">
<h2>Database</h2>
<dl class="kv">
<dt>Status</dt><dd>{{if $s.Database.OK}}<span class="badge ok">connected</span>{{else}}<span class="badge danger">error</span> {{$s.Database.Error}}{{end}}</dd>
<dt>Server</dt><dd class="small">{{$s.Database.Version}}</dd>
<dt>pgvector</dt><dd>{{if $s.Database.PGVector}}<span class="badge ok">available</span>{{else}}<span class="badge warn">not installed</span> <span class="muted small">semantic retrieval falls back to text and graph</span>{{end}}</dd>
<dt>Pool</dt><dd>{{$s.Database.PoolTotal}} open · {{$s.Database.PoolIdle}} idle · {{$s.Database.PoolMax}} max</dd>
</dl>
</div>
<div class="card">
<h2>Migrations</h2>
<table class="table compact">
<thead><tr><th>#</th><th>Name</th><th>State</th><th>Applied</th></tr></thead>
<tbody>{{range $s.Migrations}}<tr><td>{{printf "%04d" .Version}}</td><td><code>{{.Name}}</code></td><td>{{if .Drift}}<span class="badge danger">drift</span>{{else if .Applied}}<span class="badge ok">applied</span>{{else}}<span class="badge warn">pending</span>{{end}}</td><td class="muted small">{{.AppliedAt}}</td></tr>{{end}}</tbody>
</table>
</div>
</section>
{{end}}
+36
View File
@@ -0,0 +1,36 @@
{{define "content"}}
<div class="pagehead"><h1>Users</h1><p class="muted">Operator accounts. Roles: observer (read), operator (act), administrator (configure).</p></div>
<section class="grid cols-3-1">
<div class="card">
<table class="table">
<thead><tr><th>Username</th><th>Role</th><th>State</th><th>MFA</th><th>Created</th><th></th></tr></thead>
<tbody>{{range .Data.Users}}<tr>
<td><strong>{{.Username}}</strong>{{if eq .ID $.Principal.User.ID}} <span class="muted small">(you)</span>{{end}}</td>
<td>
<form method="post" action="/admin/users/{{.ID}}" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}">
<select name="role" aria-label="Role for {{.Username}}" {{if eq .ID $.Principal.User.ID}}disabled{{end}}>
{{range $r := roles}}<option value="{{$r}}" {{if eq $r .Role}}selected{{end}}>{{$r}}</option>{{end}}
</select>
{{if ne .ID $.Principal.User.ID}}<button class="btn small" type="submit" name="action" value="role">Set</button>{{end}}
</form>
</td>
<td>{{if .Disabled}}<span class="badge danger">disabled</span>{{else}}<span class="badge ok">active</span>{{end}}{{if .LockedUntil}} <span class="badge warn" title="Locked after failed logins">locked</span>{{end}}</td>
<td>{{if .TOTPEnabled}}<span class="badge ok">TOTP</span>{{else}}<span class="muted">off</span>{{end}}</td>
<td class="muted small nowrap">{{date .CreatedAt}}</td>
<td>{{if ne .ID $.Principal.User.ID}}<form method="post" action="/admin/users/{{.ID}}" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}">
{{if .Disabled}}<button class="btn small" name="action" value="enable">Enable</button>{{else}}<button class="btn small btn-danger" name="action" value="disable" data-confirm="Disable {{.Username}} and revoke their sessions?">Disable</button>{{end}}</form>{{end}}</td>
</tr>{{end}}</tbody>
</table>
</div>
<div class="card">
<h2>Create user</h2>
<form method="post" action="/admin/users" class="stack">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<label>Username <input name="username" required minlength="2" maxlength="64" autocomplete="off"></label>
<label>Password <input type="password" name="password" required minlength="12" autocomplete="new-password"></label>
<label>Role <select name="role">{{range roles}}<option value="{{.}}" {{if eq . "observer"}}selected{{end}}>{{.}}</option>{{end}}</select></label>
<button class="btn btn-primary" type="submit">Create</button>
</form>
</div>
</section>
{{end}}