openvibers/openvibe.ai Commits

Operator console (roadmap WS-O task 4): /console on ai.openvibe.network, server-rendered with no JavaScript, for OpenVibe.Network staff. Providers (secret reference names and whether they resolve, never a value; base URLs without query or user info), models, routes with their fallbacks in order, templates and workflows with every version, its status and the version in use; runs with status tabs and filters (workflow, requester, error code, UTC time range), failures by error code, and a run page (versions, provider/model, fallback, attempts, tokens, cost, error, grounding, citations, the hash-only request log, and the stored input/output bounded: strings cut at 240 characters, lists at 20 items, credential-named keys blanked); quotas with their current windows, usage by day/requester/workflow/model; cache entries and hits; the audit log by kind, action, actor and target. Sign-in is Network SSO (authorization code + PKCE S256 as OAuth client `ai`, refresh token revoked at once) with Billing's session scheme (__Host- cookie, only its hash stored in console_sessions, 60 min, CSRF token plus same-origin check on every POST, sanitizeNext keeps next on /console and sends control characters and backslashes home). The token's staff claims map to AI's capability ids and are re-mapped on every request: staff.site.view (admin, owner) -> ai.usage.read, staff.ai.manage (admin, owner) -> ai.workflow.manage (version status, cancel a run), staff.secrets.manage (owner) -> ai.provider.manage (provider enable/disable, circuit reset, quotas, cache purge); a global_mod or user gets 403 and no session. Writes reuse the admin API's functions (provider status, circuit reset, cache purge and the status summary move to server/ops.js, shared by both), so each writes the same audit_log row with the person as actor; sign-ins, sign-outs and refusals are audited as console.*. Strict CSP (one hashed stylesheet), noindex, no-store, GET /robots.txt disallows /console, /auth/ and /api/; the nginx reference vhost proxies /console, /auth/ and /robots.txt with the client address from $remote_addr. 503 in production without AI_CONSOLE_SESSION_SECRET or OV_OAUTH_CLIENT_SECRET. Checked in headless Chrome at 390/768/1280 px (no horizontal overflow, wide tables scroll in their container) and with axe (WCAG 2.1 AA, light and dark): no violations. test/console.test.js

OP OpenVibers committed 26 Sept 2026 · 86e1246a75 · 15 files, +2022 −35


Changes

ddiff --git a/.env.example b/.env.example
iindex 779d4d6..42dfa00 100644
---- a/.env.example
++++ b/.env.example
@@@ -25,6 +25,20 @@ OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
#AI_NS_REQUIRED=true
#OV_NETWORK_PUBLIC_KEY=
++# ── Operator console (/console; README "Operator console") ──
++# Network SSO as OAuth client `ai` (the client AI's service tokens use). The Network must list
++# ${BASE_URL}/auth/callback (https://ai.openvibe.network/auth/callback) as a redirect URI of client `ai`.
++# OV_OAUTH_CLIENT_SECRET is also what the events relay and the user modules authenticate with.
++#OV_OAUTH_CLIENT_ID=ai
++#OV_OAUTH_CLIENT_SECRET=
++# Signs the sign-in flow cookie and keys the client-address hashes: at least 32 random characters
++# (openssl rand -hex 32). Required in production: without it or the client secret the console answers 503.
++#AI_CONSOLE_SESSION_SECRET=
++# Absolute console session lifetime in minutes (5-720)
++#AI_CONSOLE_SESSION_TTL_MIN=60
++# Audience a Network user token must carry
++#AI_SSO_AUDIENCE=openvibe.network
++
# ── The shared provider — the same names as OpenVibe.Live's admin AI settings ──
# (ai_enabled, ai_provider, ai_base_url, ai_api_key, ai_model, ai_model_<role>, ai_pricing_json,
# ai_input/output_cost_per_mtok, ai_max_cost_usd_per_day). scripts/import-from-live.js prints the
ddiff --git a/README.md b/README.md
iindex b9fd9f7..8f179e4 100644
---- a/README.md
++++ b/README.md
@@@ -62,6 +62,8 @@ idempotent replay):
| `GET /api/v1/status\|usage\|quotas\|requests\|audit\|cache\|providers\|models` | `ai.usage.read` |
| `GET /api/health`, `GET /api/ready`, `GET /release.json` | public |
| `GET /metrics` | direct loopback callers only (a request carrying X-Forwarded-For gets 404) |
++| `GET /console…`, `/auth/login\|callback`, `POST /auth/logout` | Network staff, signed in (see [Operator console](#operator-console)) |
++| `GET /robots.txt` | public: disallows `/console`, `/auth/`, `/api/` |
`/api/ready` (openvibe-shared/ready) is 503 when the database, the active workflows or the Network
key fail; provider configuration (an active provider without its credentials, or with an open
@@@ -86,6 +88,47 @@ Live's `ai.run.create` with no namespaces), else `<id>.*`; app and module tokens
nothing. `AI_NS_FALLBACK=none` removes the fallback; `AI_NS_REQUIRED=false` is a rollback lever to
the old rule (no `ns` = every namespace).
++## Operator console
++
++`/console` on ai.openvibe.network (roadmap WS-O task 4) is a server-rendered staff console: no
++JavaScript, one inline stylesheet allowed by its CSP hash, `noindex` (meta and `X-Robots-Tag`),
++`no-store`, disallowed in `robots.txt`, in no sitemap. It does not load the OpenVibe Frame (the Frame
++needs scripts and inline styles), like Billing's staff console. Code: `server/console/`.
++
++| Page | What it shows |
++|---|---|
++| `/console` | providers needing attention, runs and failures of the last 24 h by error code, today's spend, the queue, the busiest quota windows |
++| `/console/providers` | every provider (kind, status, **secret reference name and whether it resolves, never a value**, circuit, base URL without query/user info, default model, priority/timeout) and the models |
++| `/console/routes`, `/templates`, `/workflows` (`/:key[?version=]`) | every key's newest version and the version in use; per key every version with its status and content (primary and fallbacks in order, prompts, steps, schemas) |
++| `/console/runs` | runs newest first, filtered by status (tabs), workflow, requester (`live` = `service:live`), error code and a UTC time range; failed runs grouped by error code |
++| `/console/runs/:id` | versions used, provider/model, fallback, attempts, tokens, cost, error code and detail, requester/attribution/target, grounding, citations, the request log (hashes only), and the stored input/output **bounded**: strings cut at 240 characters, lists at 20 items, 6 KB in all, credential-named keys blanked; raw debug fields never |
++| `/console/quotas`, `/console/usage` | active quotas with their current windows and counters, every quota; usage by day, requester, workflow and provider/model over a day range (`quotas.usage`) |
++| `/console/cache` | live entries and hits by workflow |
++| `/console/audit` | the `audit_log`, filtered by kind (configuration changes by default, runs, console, everything), action, actor, target |
++
++**Who may use what.** Sign-in is OpenVibe.Network SSO (authorization code + PKCE S256 as OAuth client
++`ai`; the refresh token is revoked at once). The staff claims of the Network token (contracts staff
++map, `policy.staff-role-map@1`) are mapped to AI's own capability ids, and each page and action needs
++the same one the admin API does:
++
++| Network staff capability | Held by | Console capability | Allows |
++|---|---|---|---|
++| `staff.site.view` | admin, owner | `ai.usage.read` | open the console, read every page |
++| `staff.ai.manage` | admin, owner | `ai.workflow.manage` | set a route/template/workflow version's status; cancel a queued or running run |
++| `staff.secrets.manage` | owner | `ai.provider.manage` | enable/disable a provider, reset its circuit, add/change a quota, purge the cache (one workflow, or all with a confirmation) |
++
++A `global_mod` (only `staff.console.access`), a user or a token without a `usr_` subject is refused
++(403, no session, audited as `console.sign_in.refused`). The session (`__Host-ovai_staff`, host-only,
++HttpOnly, Secure, SameSite=Strict, `AI_CONSOLE_SESSION_TTL_MIN` = 60 min absolute; only its hash is
++stored in `console_sessions`) keeps the sign-in token's staff claims and the mapping is applied again
++on every request. Every POST needs the session's CSRF token and a same-origin request. Writes go
++through the same functions as the admin API (`server/ops.js`, `registry.setStatus`, `quotas.upsert`,
++`runs.cancel`), so each writes the same `audit_log` row with the person's `usr_…` subject as actor
++(API changes show `svc:…`); sign-ins, sign-outs and refused requests are audited as `console.*`. New
++versions of routes, templates and workflows, models and provider records are still created through
++the admin API only. There is no drill mode in AI; the console starts nothing (no timer, no outbound
++call) until someone signs in.
++
## The eleven record groups
| Group | Table(s) |
@@@ -154,6 +197,7 @@ stub joins every route as a last resort outside production only (`AI_STUB_FALLBA
| Media fetched only from allow-listed https OpenVibe hosts; DNS answers and every redirect hop re-checked (a public hop never reaches internal Media); size caps | `test/ssrf.test.js` |
| The shared compiled-schema cache (caller-supplied schemas) is an LRU bounded by count and bytes, in Ajv too | `test/schemas.test.js` |
| Token and capability denial, namespaces, no secret values in any response, runs private to the requester | `test/auth.test.js` |
++| Operator console: SSO + PKCE, staff-only (anonymous → sign-in, non-staff 403), the staff → AI capability map, CSRF on every write, no script/secret on any page, noindex/robots, failed-run filters, audit rows from API and console changes | `test/console.test.js` |
| Ported adapters against fake OpenAI/Anthropic servers, stub determinism, whisper filter, templates | `test/providers.test.js` |
| Import from a Live snapshot: dry run, holds, idempotent re-run | `test/import.test.js` |
| Capability proposals are valid contracts documents matching what is enforced | `test/proposals.test.js` |
@@@ -174,14 +218,21 @@ and rollback are in `docs/migration.md`.
Deployed: `/opt/openvibe.ai`, env `/etc/openvibe/ai.env` (0600), unit `deploy/systemd/openvibe-ai.service`
(`StateDirectory=openvibe-ai`, database `/var/lib/openvibe-ai/ai.db`), principal `ai`. The nginx vhost
--`deploy/nginx/ai.openvibe.network.conf` (only health/ready public; the API is host-local) is not
--installed: `ai.openvibe.network` still serves the Sites placeholder.
++`deploy/nginx/ai.openvibe.network.conf` (health/ready, `/`, `/robots.txt`, the operator console
++`/console` and its sign-in `/auth/` public; the API is host-local) is not installed:
++`ai.openvibe.network` still serves the Sites placeholder. The console also needs
++`AI_CONSOLE_SESSION_SECRET` (32+ random characters) and `OV_OAUTH_CLIENT_SECRET` in the env file
++(without them it answers 503 in production), and the Network must list
++`https://ai.openvibe.network/auth/callback` among client `ai`'s redirect URIs.
## Not done yet
- `ai.run.*` events to OpenVibe.Events (and their schemas in Contracts); per-actor (BYO) provider
secrets; moving Live's local transcription (whisper) and the passthrough prompts into AI templates;
-- removing Network's fallback to Live's `/internal/ai/site-copy`; a server-rendered status page.
++ removing Network's fallback to Live's `/internal/ai/site-copy`; a public server-rendered status page.
++- The operator console is built but not reachable yet: it needs the vhost below installed (which
++ replaces the Sites placeholder), `https://ai.openvibe.network/auth/callback` listed as a redirect URI
++ of Network OAuth client `ai`, and `AI_CONSOLE_SESSION_SECRET` in `/etc/openvibe/ai.env`.
- Import holds from the 2026-09-23 run: a streamer's own provider key stays in Live, and 3,021 Live
translations cannot become cache entries (they have no source text) and stay in Live.
- No fallback is declared on any production route, so an outage of the one real provider fails every
ddiff --git a/deploy/nginx/ai.openvibe.network.conf b/deploy/nginx/ai.openvibe.network.conf
iindex 3aff6ed..370e2b9 100644
---- a/deploy/nginx/ai.openvibe.network.conf
++++ b/deploy/nginx/ai.openvibe.network.conf
@@@ -11,6 +11,9 @@
## /api/v1/* service-to-service API (Network service tokens, audience openvibe.ai). Services on
## this host call 127.0.0.1:4700 directly, so the public vhost refuses it; open the
## location below only when a service on another host needs it (it still needs a token).
++## /console the operator console (server-rendered, OpenVibe.Network staff only) and /auth/* its
++## Network sign-in (redirect URI https://ai.openvibe.network/auth/callback, OAuth client
++## `ai`); both noindex. /robots.txt keeps crawlers out of them.
## ═══════════════════════════════════════════════════════════════
server {
@@@ -62,6 +65,25 @@ server {
# proxy_read_timeout 90s; # ?wait= holds a request up to 60 s
}
++ # ── Operator console and its Network sign-in (staff only; the app refuses everyone else) ──
++ location /console {
++ proxy_pass http://127.0.0.1:4700;
++ proxy_set_header Host $host;
++ proxy_set_header X-Real-IP $remote_addr;
++ proxy_set_header X-Forwarded-For $remote_addr;
++ proxy_set_header CF-Connecting-IP $remote_addr;
++ proxy_set_header X-Forwarded-Proto $scheme;
++ }
++ location /auth/ {
++ proxy_pass http://127.0.0.1:4700;
++ proxy_set_header Host $host;
++ proxy_set_header X-Real-IP $remote_addr;
++ proxy_set_header X-Forwarded-For $remote_addr;
++ proxy_set_header CF-Connecting-IP $remote_addr;
++ proxy_set_header X-Forwarded-Proto $scheme;
++ }
++ location = /robots.txt { proxy_pass http://127.0.0.1:4700; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }
++
location = / {
proxy_pass http://127.0.0.1:4700;
proxy_set_header Host $host;
ddiff --git a/server/api/admin.js b/server/api/admin.js
iindex d555ad8..123fbf6 100644
---- a/server/api/admin.js
++++ b/server/api/admin.js
@@@ -16,13 +16,17 @@
* GET /api/v1/quotas POST /api/v1/quotas (ai.provider.manage)
* GET /api/v1/usage, /api/v1/requests, /api/v1/audit, /api/v1/cache (ai.usage.read)
* DELETE /api/v1/cache[?workflow=] purge (ai.provider.manage)
++ *
++ * Provider status, circuit reset, cache purge and the status summary are server/ops.js, shared with
++ * the operator console (server/console) so both make the same change and write the same audit row.
*/
const express = require('express');
const { CAPS } = require('../auth');
const { AiError } = require('../util');
const { sendError } = require('./runs');
++const { createOps } = require('../ops');
--function adminRouter({ db, registry, pool, quotas, cache, runs, auth, log = console }) {
++function adminRouter({ db, registry, pool, quotas, cache, runs, auth, ops = createOps({ db, registry, pool, quotas, cache, runs }), log = console }) {
const r = express.Router();
const provRead = auth.requireCap(CAPS.providerManage, CAPS.usageRead);
const provWrite = auth.requireCap(CAPS.providerManage);
@@@ -31,20 +35,10 @@ function adminRouter({ db, registry, pool, quotas, cache, runs, auth, log = cons
const usageRead = auth.requireCap(CAPS.usageRead);
const wrap = (fn) => (req, res) => { try { const out = fn(req, res); if (out !== undefined) res.json(out); } catch (err) { sendError(res, err, req.ov, log); } };
const actor = (req) => ({ actor: req.principal.sub, trace: req.ov.traceId });
-- const view = (p) => registry.publicProvider(p, pool.health(p.key));
++ const view = ops.providerView;
const body = (req) => (req.body && typeof req.body === 'object' ? req.body : {});
-- r.get('/api/v1/status', usageRead, wrap(() => {
-- const counts = Object.fromEntries(db.prepare('SELECT status, COUNT(*) n FROM runs GROUP BY status').all().map(x => [x.status, x.n]));
-- const today = new Date().toISOString().slice(0, 10);
-- const spend = db.prepare('SELECT COALESCE(SUM(cost_usd),0) cost, COALESCE(SUM(requests),0) requests FROM usage_daily WHERE day = ?').get(today);
-- return {
-- providers: registry.listProviders().map(p => ({ key: p.key, kind: p.kind, status: p.status, credentials: view(p).credentials, health: pool.health(p.key).state })),
-- runs: counts, today: { day: today, requests: spend.requests, cost_usd: spend.cost },
-- workflows: registry.listWorkflows().length, templates: registry.listTemplates().length, routes: registry.listRoutes().length,
-- inflight: runs.inflight.size, quotas: quotas.counters(),
-- };
-- }));
++ r.get('/api/v1/status', usageRead, wrap(() => ops.status()));
// Providers
r.get('/api/v1/providers', provRead, wrap(() => ({ providers: registry.listProviders().map(view) })));
@@@ -66,16 +60,9 @@ function adminRouter({ db, registry, pool, quotas, cache, runs, auth, log = cons
return { provider: view(registry.upsertProvider({ ...b, key: req.params.key }, { ...actor(req), origin: 'admin' })) };
}));
for (const [action, status] of [['disable', 'disabled'], ['enable', 'active']]) {
-- r.post(`/api/v1/providers/:key/${action}`, provWrite, wrap((req) => {
-- if (!registry.getProvider(req.params.key)) throw new AiError(404, 'ai.not_found', 'no such provider');
-- return { provider: view(registry.upsertProvider({ key: req.params.key, status }, { ...actor(req), origin: 'admin' })) };
-- }));
++ r.post(`/api/v1/providers/:key/${action}`, provWrite, wrap((req) => ({ provider: view(ops.setProviderStatus(req.params.key, status, actor(req))) })));
}
-- r.post('/api/v1/providers/:key/reset', provWrite, wrap((req) => {
-- pool.resetHealth(req.params.key);
-- registry.audit(req.principal.sub, 'provider.circuit_reset', 'provider', req.params.key, { trace: req.ov.traceId });
-- return { health: pool.health(req.params.key) };
-- }));
++ r.post('/api/v1/providers/:key/reset', provWrite, wrap((req) => ({ health: ops.resetCircuit(req.params.key, actor(req)) })));
// Models
r.get('/api/v1/models', provRead, wrap((req) => ({ models: registry.listModels({ provider: req.query.provider }) })));
@@@ -133,11 +120,7 @@ function adminRouter({ db, registry, pool, quotas, cache, runs, auth, log = cons
}));
r.get('/api/v1/audit', usageRead, wrap((req) => ({ audit: registry.listAudit({ action: req.query.action, targetType: req.query.target_type, targetId: req.query.target_id, limit: Number(req.query.limit) || 100, before: req.query.before }) })));
r.get('/api/v1/cache', usageRead, wrap(() => ({ cache: cache.stats() })));
-- r.delete('/api/v1/cache', provWrite, wrap((req) => {
-- const n = cache.purge({ workflow: req.query.workflow });
-- registry.audit(req.principal.sub, 'cache.purge', 'cache', req.query.workflow || '*', { trace: req.ov.traceId, metadata: { removed: n } });
-- return { removed: n };
-- }));
++ r.delete('/api/v1/cache', provWrite, wrap((req) => ({ removed: ops.purgeCache(req.query.workflow, actor(req)) })));
return r;
}
ddiff --git a/server/app.js b/server/app.js
iindex 7c68b61..e15f4b1 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -8,9 +8,11 @@ const { createRelease } = require('openvibe-shared/release');
const { createAiReadiness, registerAiGauges } = require('./observability');
const { runsRouter } = require('./api/runs');
const { adminRouter } = require('./api/admin');
++const { createOps } = require('./ops');
++const { consoleRouter } = require('./console');
const pkg = require('../package.json');
--function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, log = console }) {
++function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console }) {
const app = express();
app.disable('x-powered-by');
app.set('trust proxy', 'loopback');
@@@ -41,8 +43,17 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
// GET /release.json (ADR-016) and POST /release-metrics (open tabs' update reports into /metrics).
release.mount(app, { registry: metrics.registry });
++ // Operator actions shared by the admin API and the operator console (one implementation, one audit row).
++ const ops = createOps({ db, registry, pool, quotas, cache, runs });
app.use(runsRouter({ runs, registry, auth, config, log }));
-- app.use(adminRouter({ db, registry, pool, quotas, cache, runs, auth, log }));
++ app.use(adminRouter({ db, registry, pool, quotas, cache, runs, auth, ops, log }));
++ // The operator console (/console, /auth/*): Network staff, server-rendered, no scripts (server/console).
++ app.use(consoleRouter({ config, db, registry, pool, quotas, cache, runs, keys, ops, env, clock, fetchImpl, log }));
++
++ // Crawlers: nothing here is for search engines, the console and sign-in least of all (no sitemap either).
++ app.get('/robots.txt', (_req, res) => {
++ res.type('text/plain').send('User-agent: *\nDisallow: /console\nDisallow: /auth/\nDisallow: /api/\n');
++ });
app.get('/', (_req, res) => {
res.type('text/plain').send([
@@@ -53,6 +64,7 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
'POST /api/v1/{chat,generate,summarize,classify,extract,enrich,embed}',
'GET /api/v1/workflows | templates | routes | providers | models | quotas | usage | audit',
'GET /api/health, /api/ready, /release.json',
++ 'GET /console the operator console (OpenVibe.Network staff sign-in)',
'',
'Callers authenticate with OpenVibe.Network service tokens (audience openvibe.ai).',
'AI output is a draft/evidence package attributed to a workflow, model and run, never to a person.',
ddiff --git a/server/config.js b/server/config.js
iindex d27c91d..6000b36 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -61,12 +61,13 @@ function load(env = process.env) {
if (env.AI_PRICING_JSON) {
try { pricing = JSON.parse(env.AI_PRICING_JSON) || {}; } catch { throw new Error('AI_PRICING_JSON is not valid JSON'); }
}
++ const baseUrl = trimUrl(env.BASE_URL || (isProduction ? 'https://ai.openvibe.network' : `http://localhost:${port}`));
return {
port,
host: env.HOST || '127.0.0.1',
nodeEnv,
isProduction,
-- baseUrl: trimUrl(env.BASE_URL || (isProduction ? 'https://ai.openvibe.network' : `http://localhost:${port}`)),
++ baseUrl,
// Identity: OpenVibe.Network signs the service tokens callers present (audience openvibe.ai).
networkUrl: trimUrl(env.OV_NETWORK_URL || 'https://openvibe.network'),
@@@ -80,6 +81,22 @@ function load(env = process.env) {
dbPath: env.AI_DB_PATH || './data/ai.db',
++ // ── Operator console (server/console): OpenVibe.Network SSO (authorization code + PKCE S256
++ // as OAuth client `ai`), for Network staff only (README "Operator console"). Both secrets are
++ // references, resolved when used; the console answers 503 in production without them.
++ console: {
++ clientId: String(env.OV_OAUTH_CLIENT_ID || 'ai').trim(),
++ clientSecretRef: 'env:OV_OAUTH_CLIENT_SECRET',
++ // Signs the sign-in flow cookie and keys the client-address hashes: 32+ random characters.
++ sessionSecretRef: 'env:AI_CONSOLE_SESSION_SECRET',
++ sessionTtlMin: Math.max(5, Math.min(12 * 60, int(env.AI_CONSOLE_SESSION_TTL_MIN, 60))),
++ redirectUri: `${baseUrl}/auth/callback`,
++ // Secure (and __Host-) cookies whenever the console is served over https (always in production).
++ cookieSecure: baseUrl.startsWith('https://'),
++ // Audience a Network user token must carry (the Network's own is always present).
++ ssoAudience: String(env.AI_SSO_AUDIENCE || 'openvibe.network').trim(),
++ },
++
// ── The shared provider (Live's "shared key") ──
// AI_ENABLED is Live's ai_enabled master switch. Unset means "on when a key or a
// self-hosted base URL is configured".
ddiff --git a/server/console/index.js b/server/console/index.js
nnew file mode 100644
iindex 0000000..f01ad28
---- /dev/null
++++ b/server/console/index.js
@@@ -0,0 +1,391 @@
++'use strict';
++/**
++ * The AI operator console (roadmap WS-O task 4): server-rendered, no JavaScript, on
++ * ai.openvibe.network under /console, for OpenVibe.Network staff.
++ *
++ * GET /auth/login, /auth/callback Network SSO (authorization code + PKCE S256, OAuth client `ai`)
++ * POST /auth/logout
++ * GET /console overview: providers, runs and failures (24 h), spend, queue, quotas
++ * GET /console/providers providers (secret reference names + presence only) and models
++ * POST /console/providers/:key/status enable/disable (ai.provider.manage; ops.setProviderStatus)
++ * POST /console/providers/:key/reset close the circuit (ai.provider.manage; ops.resetCircuit)
++ * GET /console/{routes,templates,workflows}[/:key[?version=]] versions, status, content
++ * POST /console/{routes,templates,workflows}/:key/versions/:v/status (ai.workflow.manage; registry.setStatus)
++ * GET /console/runs[?status&workflow&requester&code&from&to&before], /console/runs/:id
++ * POST /console/runs/:id/cancel (ai.workflow.manage; runs.cancel)
++ * GET /console/quotas; POST /console/quotas (writes: ai.provider.manage; quotas.upsert)
++ * GET /console/usage[?from&to&requester] (quotas.usage)
++ * GET /console/cache; POST /console/cache/purge (writes: ai.provider.manage; ops.purgeCache)
++ * GET /console/audit[?kind&action&actor&target_type&target_id&before]
++ *
++ * Who: a Network person (usr_ subject) whose sign-in token holds staff capabilities of the contracts
++ * staff map (policy.staff-role-map@1, ADR-022). A console session holds the AI capability ids of
++ * STAFF_TO_AI below, and each page and action names the one it needs, exactly as the admin API does:
++ *
++ * staff.site.view (admin, owner) -> ai.usage.read open the console, read every page
++ * staff.ai.manage (admin, owner) -> ai.workflow.manage version status changes, cancel runs
++ * staff.secrets.manage (owner) -> ai.provider.manage providers, circuits, quotas, cache purge
++ *
++ * A global_mod (staff.console.access only) and everyone else is refused (403) and gets no session.
++ * The staff claims are read from the sign-in token and kept on the session, which is short
++ * (AI_CONSOLE_SESSION_TTL_MIN, 60 min); the mapping is applied again on every request.
++ *
++ * Writes go through the functions the admin API uses (server/ops.js, registry, quotas, runs), so each
++ * writes the same audit_log row, with the person's subject as the actor. Every POST needs the
++ * session's CSRF token and a same-origin request; refusals and sign-ins are audited as console.*.
++ * Nothing starts when this module loads or when the router is built: no timer, no outbound call.
++ */
++const crypto = require('crypto');
++const express = require('express');
++const { AiError, resolveSecret } = require('../util');
++const { CAPS } = require('../auth');
++const { staff: staffMap } = require('openvibe-contracts');
++const { createSessions, sameString, random } = require('./session');
++const sso = require('./sso');
++const q = require('./queries');
++const { CSP, esc, pages } = require('./views');
++
++/** Network staff capability -> AI capability held by a console session (documented in README "Operator console"). */
++const STAFF_TO_AI = Object.freeze([
++ ['staff.site.view', CAPS.usageRead],
++ ['staff.ai.manage', CAPS.workflowManage],
++ ['staff.secrets.manage', CAPS.providerManage],
++]);
++
++/** The AI capabilities these staff claims map to. */
++function consoleCapabilities(staffClaims) {
++ const out = [];
++ for (const [s, ai] of STAFF_TO_AI) {
++ let ok = false;
++ try { ok = staffMap.can(staffClaims || {}, s); } catch { ok = false; }
++ if (ok) out.push(ai);
++ }
++ return out;
++}
++
++/** Where to go after sign-in: a console page only; control characters and backslashes go home. */
++function sanitizeNext(v) {
++ // Browsers drop tab and newline characters from a URL and read a backslash as "/": "/<TAB>/evil.com"
++ // would leave the site. A next with any control character or backslash goes home.
++ if (typeof v === 'string' && /[\u0000-\u001f\u007f\\]/.test(v)) return '/console';
++ const s = String(v || '');
++ if (s.length > 300 || !/^\/console(?:$|[/?#])/.test(s)) return '/console';
++ return s;
++}
++
++const NOTICES = {
++ provider_status: 'Provider status changed.',
++ circuit_reset: 'Circuit reset: the provider will be tried again.',
++ version_status: 'Version status changed.',
++ quota_saved: 'Quota saved.',
++ cache_purged: 'Cache purged.',
++ run_cancelled: 'Run cancelled.',
++};
++const DEF_KINDS = {
++ routes: { one: 'route', list: 'listRoutes', get: 'getRoute' },
++ templates: { one: 'template', list: 'listTemplates', get: 'getTemplate' },
++ workflows: { one: 'workflow', list: 'listWorkflows', get: 'getWorkflow' },
++};
++const DAY_MS = 86400000;
++
++function consoleRouter({ config, db, registry, pool, quotas, cache, runs, keys, ops, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console }) {
++ const cc = config.console;
++ const r = express.Router();
++ const scoped = ['/console', '/auth'];
++ const baseOrigin = (() => { try { return new URL(config.baseUrl).origin; } catch { return null; } })();
++
++ let secret = resolveSecret(cc.sessionSecretRef, env);
++ let unavailable = null;
++ if (!secret || secret.length < 32) {
++ if (config.isProduction) unavailable = 'AI_CONSOLE_SESSION_SECRET (at least 32 characters) is not set';
++ else {
++ secret = crypto.randomBytes(32).toString('hex');
++ if (config.nodeEnv !== 'test') log.warn('[console] AI_CONSOLE_SESSION_SECRET unset: using an ephemeral secret (development only)');
++ }
++ }
++ const clientSecret = () => resolveSecret(cc.clientSecretRef, env);
++ if (!clientSecret()) unavailable = unavailable || 'OV_OAUTH_CLIENT_SECRET is not set';
++ if (unavailable && config.nodeEnv !== 'test') log.warn(`[console] operator console disabled: ${unavailable}`);
++ const sessions = createSessions({ db, config, clock, secret: secret || 'unavailable' });
++
++ /** Every secret value this process can resolve, so that no page can ever carry one (belt and braces). */
++ function secretValues() {
++ const vals = new Set([clientSecret(), secret]);
++ for (const p of registry.listProviders()) vals.add(resolveSecret(p.secret_ref, env));
++ return [...vals].filter((v) => v && v.length >= 8);
++ }
++ function scrub(text) {
++ let out = text;
++ for (const v of secretValues()) for (const form of new Set([v, esc(v)])) out = out.split(form).join('[redacted]');
++ return out;
++ }
++ const send = (res, status, text) => res.status(status).type('html').send(scrub(text));
++ const who = (req) => ({ actor: req.staff.subject, trace: req.ov ? req.ov.traceId : null });
++ const common = (req) => ({ staff: req.staff, csrf: req.staff ? req.staff.csrf : '' });
++ const audit = (actor, action, targetType, targetId, metadata, req) => registry.audit(actor || 'anonymous', action, targetType, targetId, { trace: req && req.ov ? req.ov.traceId : null, metadata });
++ const notice = (req) => {
++ const base = NOTICES[req.query.done];
++ if (!base) return null;
++ return req.query.done === 'cache_purged' ? `${base} ${Number(req.query.removed) || 0} entr${Number(req.query.removed) === 1 ? 'y' : 'ies'} removed.` : base;
++ };
++
++ // ── Every console and sign-in response ───────────────────
++ r.use(scoped, (req, res, next) => {
++ res.setHeader('Content-Security-Policy', CSP);
++ res.setHeader('X-Frame-Options', 'DENY');
++ res.setHeader('Referrer-Policy', 'no-referrer');
++ res.setHeader('X-Robots-Tag', 'noindex, nofollow');
++ res.setHeader('Cache-Control', 'no-store');
++ if (unavailable) return send(res, 503, pages.message({ title: 'Console unavailable', text: 'The operator console is not configured on this host. The operator must set its environment (see .env.example, "Operator console").' }));
++ return next();
++ });
++ r.use(scoped, express.urlencoded({ extended: false, limit: '16kb', parameterLimit: 50 }));
++
++ // ── Session -> req.staff ─────────────────────────────────
++ r.use(scoped, (req, res, next) => {
++ req.staff = null;
++ const row = sessions.read(req);
++ if (!row) return next();
++ const caps = consoleCapabilities(row.staff);
++ if (!caps.includes(CAPS.usageRead)) {
++ sessions.revoke(row);
++ sessions.clear(res);
++ audit(row.subject, 'console.request.refused', 'console', 'session', { reason: 'the session no longer maps to ai.usage.read' }, req);
++ return send(res, 403, pages.message({ title: 'Not authorized', text: 'Your account is not AI operator staff any more. The session was ended.' }));
++ }
++ req.staff = {
++ subject: row.subject, username: row.username, role: staffMap.effectiveRole(row.staff), caps, csrf: row.csrf, session: row,
++ principal: { sub: row.subject, subject: { type: 'user', id: row.subject }, cap: caps, claims: { sub: row.subject, cap: caps } },
++ };
++ return next();
++ });
++
++ /** Staff only, holding `cap` through STAFF_TO_AI. POSTs also need the CSRF token. */
++ function needs(cap) {
++ return (req, res, next) => {
++ if (!req.staff) {
++ if (req.method === 'GET') return send(res, 401, pages.signIn({ next: sanitizeNext(req.originalUrl) }));
++ return send(res, 401, pages.signIn({ message: 'Your session has ended. Sign in again, then repeat the action.' }));
++ }
++ if (!req.staff.caps.includes(cap)) {
++ if (req.method === 'POST') audit(req.staff.subject, 'console.request.refused', 'console', req.path, { reason: `needs ${cap}` }, req);
++ return send(res, 403, pages.message({ title: 'Not authorized', text: `This needs ${cap}.`, ...common(req) }));
++ }
++ if (req.method === 'POST' && !csrfOk(req)) {
++ audit(req.staff.subject, 'console.request.refused', 'console', req.path, { reason: 'missing or invalid CSRF token, or a cross-site request' }, req);
++ return send(res, 403, pages.message({ title: 'Request refused', text: 'The form was stale or came from another site. Reload the page and try again.', ...common(req) }));
++ }
++ return next();
++ };
++ }
++ function csrfOk(req) {
++ const site = req.headers['sec-fetch-site'];
++ if (site && site !== 'same-origin' && site !== 'none') return false;
++ const origin = req.headers.origin;
++ if (origin && origin !== baseOrigin) return false;
++ return sameString((req.body || {})._csrf, req.staff.csrf);
++ }
++ const wrap = (fn) => (req, res, next) => Promise.resolve().then(() => fn(req, res, next)).catch((e) => {
++ if (e instanceof AiError) return send(res, e.status >= 400 && e.status < 600 ? e.status : 400, pages.message({ title: 'Refused', text: `${e.detail || e.message} (${e.code})`, back: req.get('referer') && req.get('referer').startsWith(`${baseOrigin}/console`) ? req.get('referer') : '/console', ...common(req) }));
++ log.error(`[console] ${req.method} ${req.path}: ${e && e.stack ? e.stack : e}`);
++ if (!res.headersSent) send(res, 500, pages.message({ title: 'Error', text: 'Something went wrong. Nothing was changed unless the page says so; check the audit log.', ...common(req) }));
++ return null;
++ });
++ const read = needs(CAPS.usageRead);
++
++ // ── Sign-in ──────────────────────────────────────────────
++ r.get('/auth/login', (req, res) => {
++ const state = random(32);
++ const { verifier, challenge } = sso.pkcePair();
++ sessions.setFlow(res, { s: state, v: verifier, n: sanitizeNext(req.query.next) });
++ res.redirect(302, sso.authorizeUrl(config, { state, challenge }));
++ });
++
++ r.get('/auth/callback', wrap(async (req, res) => {
++ const flow = sessions.takeFlow(req, res);
++ if (req.query.error) return send(res, 400, pages.signIn({ message: `Sign-in was not completed (${String(req.query.error).slice(0, 60)}).` }));
++ if (!flow || !req.query.code || !sameString(req.query.state, flow.s)) {
++ return send(res, 400, pages.signIn({ message: 'The sign-in could not be verified (expired or mismatched state). Please try again.' }));
++ }
++ let person;
++ try {
++ const publicKey = keys.get() || await keys.fetchOnce();
++ if (!publicKey) throw new Error('the Network key is not loaded');
++ person = await sso.exchange({ config, clientSecret: clientSecret(), code: req.query.code, verifier: flow.v, publicKey, now: clock.now(), fetchImpl });
++ } catch (e) {
++ log.warn(`[console] sign-in failed: ${e.message}`);
++ return send(res, e.status && e.status < 500 ? 400 : 502, pages.signIn({ message: 'OpenVibe.Network did not confirm the sign-in. Please try again.' }));
++ }
++ const ipHash = sessions.ipHash(req.ip);
++ if (person.error) {
++ audit('anonymous', 'console.sign_in.refused', 'console', 'sign_in', { reason: person.error, ip_hash: ipHash }, req);
++ return send(res, 403, pages.message({ title: 'Not authorized', text: 'This console is only for OpenVibe.Network staff. Your sign-in was recorded.' }));
++ }
++ const caps = consoleCapabilities(person.staff);
++ const role = staffMap.effectiveRole(person.staff);
++ if (!caps.includes(CAPS.usageRead)) {
++ audit(person.subject, 'console.sign_in.refused', 'console', 'sign_in', { reason: `role ${role} does not hold staff.site.view`, role, ip_hash: ipHash }, req);
++ return send(res, 403, pages.message({ title: 'Not authorized', text: 'This console is only for OpenVibe.Network staff (admins and the owner). Your sign-in was recorded.' }));
++ }
++ db.transaction(() => {
++ sessions.create(res, { subject: person.subject, username: person.username, staff: person.staff, ip: req.ip });
++ audit(person.subject, 'console.sign_in', 'console', 'sign_in', { role, capabilities: caps, ip_hash: ipHash }, req);
++ })();
++ return res.redirect(303, flow.n || '/console');
++ }));
++
++ r.post('/auth/logout', read, (req, res) => {
++ db.transaction(() => {
++ sessions.revoke(req.staff.session);
++ audit(req.staff.subject, 'console.sign_out', 'console', 'sign_in', {}, req);
++ })();
++ sessions.clear(res);
++ res.redirect(303, '/console');
++ });
++
++ // ── Overview ─────────────────────────────────────────────
++ r.get('/console', read, wrap((req, res) => {
++ const since = new Date(clock.now() - DAY_MS).toISOString();
++ const st = ops.status();
++ const problems = [];
++ for (const p of registry.listProviders().filter((x) => x.status === 'active' && x.kind !== 'stub')) {
++ const v = ops.providerView(p);
++ if (v.credentials === 'missing') problems.push({ key: p.key, kind: p.kind, problem: `credentials missing (${p.secret_ref || 'no reference'})` });
++ if (v.health && v.health.state !== 'closed') problems.push({ key: p.key, kind: p.kind, problem: `circuit ${v.health.state}` });
++ }
++ const stats = cache.stats();
++ const frac = (c) => {
++ const x = c.quota;
++ return Math.max(x.max_requests ? c.used.requests / x.max_requests : 0, x.max_tokens ? c.used.tokens / x.max_tokens : 0, x.max_cost_usd ? c.used.cost_usd / x.max_cost_usd : 0);
++ };
++ send(res, 200, pages.overview({
++ ...common(req), st, counts24: q.statusCounts(db, since), failures24: q.failures(db, since), queue: runs.stats(), problems,
++ cacheTotals: { entries: stats.reduce((a, s) => a + s.entries, 0), hits: stats.reduce((a, s) => a + (s.hits || 0), 0) },
++ stubFallback: config.stubFallback, topQuotas: [...st.quotas].sort((a, b) => frac(b) - frac(a)).slice(0, 10),
++ }));
++ }));
++
++ // ── Providers ────────────────────────────────────────────
++ r.get('/console/providers', read, wrap((req, res) => send(res, 200, pages.providers({
++ ...common(req), rows: registry.listProviders().map(ops.providerView), models: registry.listModels(), stubFallback: config.stubFallback,
++ canManage: req.staff.caps.includes(CAPS.providerManage), notice: notice(req),
++ }))));
++ r.post('/console/providers/:key/status', needs(CAPS.providerManage), wrap((req, res) => {
++ ops.setProviderStatus(req.params.key, String(req.body.status || ''), who(req));
++ res.redirect(303, '/console/providers?done=provider_status');
++ }));
++ r.post('/console/providers/:key/reset', needs(CAPS.providerManage), wrap((req, res) => {
++ if (!registry.getProvider(req.params.key)) throw new AiError(404, 'ai.not_found', 'no such provider');
++ ops.resetCircuit(req.params.key, who(req));
++ res.redirect(303, '/console/providers?done=circuit_reset');
++ }));
++
++ // ── Routes, templates, workflows ─────────────────────────
++ /** key -> the version runs use: the newest route version if active; the newest active template/workflow version. */
++ function inUseMap(kind, history) {
++ const m = new Map();
++ const byKey = new Map();
++ for (const x of history) { if (!byKey.has(x.key)) byKey.set(x.key, []); byKey.get(x.key).push(x); }
++ for (const [key, list] of byKey) {
++ const sorted = [...list].sort((a, b) => b.version - a.version);
++ const v = kind === 'routes' ? (sorted[0].status === 'active' ? sorted[0].version : null) : ((sorted.find((x) => x.status === 'active') || {}).version || null);
++ m.set(key, v);
++ }
++ return m;
++ }
++ for (const [kind, k] of Object.entries(DEF_KINDS)) {
++ r.get(`/console/${kind}`, read, wrap((req, res) => send(res, 200, pages.definitions({
++ ...common(req), kind, rows: registry[k.list](), inUse: inUseMap(kind, registry[k.list]({ history: true })),
++ }))));
++ r.get(`/console/${kind}/:key`, read, wrap((req, res) => {
++ const versions = registry[k.list]({ history: true }).filter((x) => x.key === req.params.key).sort((a, b) => b.version - a.version);
++ if (!versions.length) return send(res, 404, pages.message({ title: 'Not found', text: `No ${k.one} ${req.params.key}.`, back: `/console/${kind}`, ...common(req) }));
++ const want = Number(req.query.version);
++ const selected = (Number.isInteger(want) && versions.find((x) => x.version === want)) || versions[0];
++ return send(res, 200, pages.definition({
++ ...common(req), kind, key: req.params.key, versions, selected: registry[k.get](req.params.key, selected.version),
++ inUse: inUseMap(kind, versions).get(req.params.key), canManage: req.staff.caps.includes(CAPS.workflowManage), notice: notice(req),
++ }));
++ }));
++ r.post(`/console/${kind}/:key/versions/:version/status`, needs(CAPS.workflowManage), wrap((req, res) => {
++ const version = Number(req.params.version);
++ if (!Number.isInteger(version) || version < 1) throw new AiError(404, 'ai.not_found', `no ${k.one} ${req.params.key} v${req.params.version}`);
++ registry.setStatus(k.one, req.params.key, version, String(req.body.status || ''), who(req));
++ res.redirect(303, `/console/${kind}/${encodeURIComponent(req.params.key)}?version=${version}&done=version_status`);
++ }));
++ }
++
++ // ── Runs ─────────────────────────────────────────────────
++ r.get('/console/runs', read, wrap((req, res) => {
++ const { filters, form, bad } = q.parseRunFilters(req.query);
++ const list = q.listRuns(db, filters);
++ send(res, bad.length ? 400 : 200, pages.runs({
++ ...common(req), form, filters, bad, rows: list.rows, next: list.next, facets: q.runFacets(db),
++ failures24: filters.status === 'failed' ? q.failures(db, new Date(clock.now() - DAY_MS).toISOString()) : [],
++ }));
++ }));
++ r.get('/console/runs/:id', read, wrap((req, res) => {
++ const run = runs.get(req.params.id);
++ if (!run) return send(res, 404, pages.message({ title: 'Not found', text: `No run ${req.params.id}.`, back: '/console/runs', ...common(req) }));
++ return send(res, 200, pages.run({
++ ...common(req), r: run, citations: runs.citations(run.id), requests: runs.requestsFor(run.id), input: q.preview(run.input), output: q.preview(run.output),
++ canCancel: req.staff.caps.includes(CAPS.workflowManage), notice: notice(req),
++ }));
++ }));
++ r.post('/console/runs/:id/cancel', needs(CAPS.workflowManage), wrap((req, res) => {
++ runs.cancel(req.params.id, req.staff.principal, { trace: req.ov ? req.ov.traceId : null, principalHas: (cap) => req.staff.caps.includes(cap) });
++ res.redirect(303, `/console/runs/${encodeURIComponent(req.params.id)}?done=run_cancelled`);
++ }));
++
++ // ── Quotas and usage ─────────────────────────────────────
++ const quotaPage = (req, res, status, extra = {}) => send(res, status, pages.quotas({
++ ...common(req), counters: quotas.counters(), all: quotas.list(), canManage: req.staff.caps.includes(CAPS.providerManage), notice: notice(req), ...extra,
++ }));
++ r.get('/console/quotas', read, wrap((req, res) => quotaPage(req, res, 200)));
++ r.post('/console/quotas', needs(CAPS.providerManage), wrap((req, res) => {
++ const b = req.body || {};
++ const form = {};
++ for (const f of ['scope_type', 'scope_id', 'window', 'max_requests', 'max_tokens', 'max_cost_usd', 'workflow_prefix', 'status']) form[f] = String(b[f] == null ? '' : b[f]).trim().slice(0, 200);
++ try {
++ quotas.upsert({ ...form, scope_id: form.scope_id || (form.scope_type === 'global' ? '*' : ''), workflow_prefix: form.workflow_prefix || null, status: form.status || 'active' }, { ...who(req), origin: 'admin' });
++ } catch (e) {
++ if (!(e instanceof AiError)) throw e;
++ return quotaPage(req, res, e.status, { error: `${e.detail || e.message} (${e.code})`, form, notice: null });
++ }
++ return res.redirect(303, '/console/quotas?done=quota_saved');
++ }));
++ r.get('/console/usage', read, wrap((req, res) => {
++ const { filters, form, bad } = q.parseUsageFilters(req.query, clock.now());
++ send(res, bad.length ? 400 : 200, pages.usage({ ...common(req), form, bad, report: q.usageReport(quotas, filters) }));
++ }));
++
++ // ── Cache ────────────────────────────────────────────────
++ const cachePage = (req, res, status, extra = {}) => send(res, status, pages.cache({
++ ...common(req), stats: cache.stats(), canManage: req.staff.caps.includes(CAPS.providerManage), notice: notice(req), ...extra,
++ }));
++ r.get('/console/cache', read, wrap((req, res) => cachePage(req, res, 200)));
++ r.post('/console/cache/purge', needs(CAPS.providerManage), wrap((req, res) => {
++ const workflow = String((req.body || {}).workflow || '').trim();
++ if (workflow && !/^[a-z][a-z0-9_-]*(\.[a-z0-9_-]+)*$/.test(workflow)) return cachePage(req, res, 422, { error: 'that is not a workflow key', notice: null });
++ if (!workflow && (req.body || {}).confirm_all !== 'yes') return cachePage(req, res, 422, { error: 'give a workflow key, or tick the box to purge every entry', notice: null });
++ const removed = ops.purgeCache(workflow || null, who(req));
++ return res.redirect(303, `/console/cache?done=cache_purged&removed=${removed}`);
++ }));
++
++ // ── Audit ────────────────────────────────────────────────
++ r.get('/console/audit', read, wrap((req, res) => {
++ const { filters, form, bad } = q.parseAuditFilters(req.query);
++ const page = q.listAudit(db, filters);
++ send(res, bad.length ? 400 : 200, pages.audit({ ...common(req), form, bad, rows: page.rows, next: page.next, names: q.usernames(db), kinds: q.AUDIT_KINDS }));
++ }));
++
++ // Anything else under /console or /auth: the sign-in page, or (signed in) the console's own not-found page.
++ r.all(['/console/*', '/auth/*'], (req, res) => {
++ if (!req.staff) return send(res, 401, pages.signIn({ next: req.method === 'GET' ? sanitizeNext(req.originalUrl) : '/console' }));
++ return send(res, 404, pages.message({ title: 'Not found', text: 'There is no such console page.', ...common(req) }));
++ });
++
++ return r;
++}
++
++module.exports = { consoleRouter, consoleCapabilities, sanitizeNext, STAFF_TO_AI };
ddiff --git a/server/console/queries.js b/server/console/queries.js
nnew file mode 100644
iindex 0000000..6ded09f
---- /dev/null
++++ b/server/console/queries.js
@@@ -0,0 +1,219 @@
++'use strict';
++/**
++ * Read models of the operator console. Reads only: every change goes through the functions the
++ * admin API uses (server/ops.js, registry, quotas, runs). Filters are validated here; a malformed one
++ * is dropped and reported, never passed on, and every value is a bound parameter.
++ *
++ * Run previews follow the run storage rules (server/runs.js retainedInput): inline images are
++ * already hashes and passthrough prompts are not kept at all. On top of that the console shows at
++ * most PREVIEW_STRING characters of any string, PREVIEW_ITEMS items of any list and PREVIEW_BYTES in
++ * all, and blanks keys that name credentials. Raw debug prompt/response fields are never selected.
++ */
++
++const RUN_STATUSES = ['queued', 'running', 'succeeded', 'failed', 'cancelled', 'cached'];
++const KEY_RE = /^[a-z][a-z0-9_-]*(\.[a-z0-9_-]+)*$/;
++const REQUESTER_RE = /^(service|app|mod|user):[A-Za-z0-9_.:-]{1,120}$/;
++const SERVICE_RE = /^[a-z][a-z0-9-]{1,39}$/;
++const CODE_RE = /^[a-z][a-z0-9_.-]{1,80}$/;
++const RUN_ID_RE = /^run_[0-9A-HJKMNP-TV-Z]{26}$/;
++const DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
++const MINUTE_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d{1,3})?)?Z?$/;
++const PAGE = 50;
++
++const PREVIEW_STRING = 240;
++const PREVIEW_ITEMS = 20;
++const PREVIEW_DEPTH = 6;
++const PREVIEW_BYTES = 6000;
++// Keys that name a credential (api_key, apiKey, client_secret, accessToken, password, …), compared letters-only.
++const SECRETISH = ['apikey', 'secret', 'secrets', 'password', 'passwd', 'token', 'authorization', 'cookie', 'credential', 'credentials', 'privatekey'];
++const secretish = (k) => { const x = String(k).toLowerCase().replace(/[^a-z]/g, ''); return SECRETISH.some((s) => x.endsWith(s)); };
++
++const str = (v) => (typeof v === 'string' ? v.trim() : '');
++
++/** 'YYYY-MM-DD' or a datetime-local value (read as UTC) -> ISO; `to` of a bare day is the next midnight (exclusive). */
++function parseTime(v, edge) {
++ if (DAY_RE.test(v)) {
++ const ms = Date.parse(`${v}T00:00:00Z`);
++ if (!Number.isFinite(ms)) return null;
++ return new Date(edge === 'to' ? ms + 86400000 : ms).toISOString();
++ }
++ if (MINUTE_RE.test(v)) {
++ const ms = Date.parse(v.endsWith('Z') ? v : `${v}Z`);
++ return Number.isFinite(ms) ? new Date(ms).toISOString() : null;
++ }
++ return null;
++}
++
++/** 'live' -> 'service:live'; 'service:live', 'app:…' kept; anything else null. */
++function requesterOf(v) {
++ if (!v) return null;
++ if (SERVICE_RE.test(v)) return `service:${v}`;
++ return REQUESTER_RE.test(v) ? v : null;
++}
++
++/** Run list filters from the query string: { filters, form, bad }. `form` echoes what was typed. */
++function parseRunFilters(q = {}) {
++ const filters = {};
++ const bad = [];
++ const form = {};
++ for (const k of ['status', 'workflow', 'requester', 'code', 'from', 'to', 'before']) form[k] = str(q[k]).slice(0, 200);
++ if (form.status) { if (RUN_STATUSES.includes(form.status)) filters.status = form.status; else bad.push('status'); }
++ if (form.workflow) { if (form.workflow.length <= 120 && KEY_RE.test(form.workflow)) filters.workflow = form.workflow; else bad.push('workflow'); }
++ if (form.requester) { const r = requesterOf(form.requester); if (r) filters.requester = r; else bad.push('requester'); }
++ if (form.code) { if (CODE_RE.test(form.code)) filters.code = form.code; else bad.push('error code'); }
++ if (form.from) { const t = parseTime(form.from, 'from'); if (t) filters.fromIso = t; else bad.push('from'); }
++ if (form.to) { const t = parseTime(form.to, 'to'); if (t) filters.toIso = t; else bad.push('to'); }
++ if (form.before) { if (RUN_ID_RE.test(form.before)) filters.before = form.before; else bad.push('before'); }
++ return { filters, form, bad };
++}
++
++function listRuns(db, f, limit = PAGE) {
++ const where = [];
++ const args = [];
++ if (f.status) { where.push('status = ?'); args.push(f.status); }
++ if (f.workflow) { where.push('workflow_key = ?'); args.push(f.workflow); }
++ if (f.requester) {
++ const i = f.requester.indexOf(':');
++ where.push('requester_type = ? AND requester_id = ?');
++ args.push(f.requester.slice(0, i), f.requester.slice(i + 1));
++ }
++ if (f.code) { where.push('error_code = ?'); args.push(f.code); }
++ if (f.fromIso) { where.push('created_at >= ?'); args.push(f.fromIso); }
++ if (f.toIso) { where.push('created_at < ?'); args.push(f.toIso); }
++ if (f.before) { where.push('id < ?'); args.push(f.before); }
++ const rows = db.prepare(`SELECT id, status, workflow_key, workflow_version, requester_type, requester_id, provider_key, model_key, fallback_used, attempts,
++ tokens_in, tokens_out, cost_usd, error_code, synthetic, cached_from, retry_of, created_at, finished_at
++ FROM runs ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY id DESC LIMIT ?`).all(...args, limit + 1);
++ return { rows: rows.slice(0, limit), next: rows.length > limit ? rows[limit - 1].id : null };
++}
++
++/** Failed runs since `sinceIso`, grouped by error code. */
++function failures(db, sinceIso) {
++ return db.prepare(`SELECT error_code, COUNT(*) AS n, COUNT(DISTINCT workflow_key) AS workflows, MAX(created_at) AS last_at
++ FROM runs WHERE status = 'failed' AND created_at >= ? GROUP BY error_code ORDER BY n DESC LIMIT 20`).all(sinceIso);
++}
++
++function statusCounts(db, sinceIso) {
++ return Object.fromEntries(db.prepare('SELECT status, COUNT(*) AS n FROM runs WHERE created_at >= ? GROUP BY status').all(sinceIso).map(r => [r.status, r.n]));
++}
++
++/** Suggestions for the run filters (<datalist>): requesters and workflow keys that have runs. */
++function runFacets(db) {
++ return {
++ requesters: db.prepare("SELECT DISTINCT requester_type || ':' || requester_id AS r FROM runs ORDER BY r LIMIT 100").all().map(x => x.r),
++ workflows: db.prepare('SELECT DISTINCT workflow_key AS w FROM runs ORDER BY w LIMIT 300').all().map(x => x.w),
++ codes: db.prepare("SELECT DISTINCT error_code AS c FROM runs WHERE error_code IS NOT NULL ORDER BY c LIMIT 100").all().map(x => x.c),
++ };
++}
++
++/** A bounded, credential-blanked copy of a stored input/output for display. */
++function preview(value) {
++ let cut = false;
++ const walk = (v, depth) => {
++ if (typeof v === 'string') {
++ if (v.length <= PREVIEW_STRING) return v;
++ cut = true;
++ return `${v.slice(0, PREVIEW_STRING)}… (${v.length - PREVIEW_STRING} more characters)`;
++ }
++ if (v == null || typeof v !== 'object') return v;
++ if (depth >= PREVIEW_DEPTH) { cut = true; return '…'; }
++ if (Array.isArray(v)) {
++ const out = v.slice(0, PREVIEW_ITEMS).map(x => walk(x, depth + 1));
++ if (v.length > PREVIEW_ITEMS) { cut = true; out.push(`… (${v.length - PREVIEW_ITEMS} more items)`); }
++ return out;
++ }
++ const out = {};
++ for (const [k, x] of Object.entries(v)) {
++ if (secretish(k) && x != null && x !== '') { out[k] = '[not shown]'; cut = true; } else out[k] = walk(x, depth + 1);
++ }
++ return out;
++ };
++ if (value === null || value === undefined) return { text: null, cut: false };
++ let text = JSON.stringify(walk(value, 0), null, 2);
++ if (text.length > PREVIEW_BYTES) { text = `${text.slice(0, PREVIEW_BYTES)}\n… (truncated)`; cut = true; }
++ return { text, cut };
++}
++
++// ── Audit ────────────────────────────────────────────────────
++const AUDIT_KINDS = Object.freeze({
++ changes: { label: 'Configuration changes', where: "target_type IN ('provider', 'model', 'route', 'template', 'workflow', 'quota', 'cache')" },
++ runs: { label: 'Runs', where: "target_type = 'run'" },
++ console: { label: 'Console sign-ins and refusals', where: "action LIKE 'console.%'" },
++ all: { label: 'Everything', where: null },
++});
++
++function parseAuditFilters(q = {}) {
++ const form = {};
++ for (const k of ['kind', 'action', 'actor', 'target_type', 'target_id', 'before']) form[k] = str(q[k]).slice(0, 200);
++ const f = { kind: AUDIT_KINDS[form.kind] ? form.kind : 'changes' };
++ const bad = [];
++ if (form.action) { if (/^[a-z][a-z0-9_.]{1,80}$/.test(form.action)) f.action = form.action; else bad.push('action'); }
++ if (form.actor) { if (/^[A-Za-z0-9_:.-]{1,120}$/.test(form.actor)) f.actor = form.actor; else bad.push('actor'); }
++ if (form.target_type) { if (/^[a-z_]{1,40}$/.test(form.target_type)) f.targetType = form.target_type; else bad.push('target type'); }
++ if (form.target_id) f.targetId = form.target_id;
++ if (form.before) { if (/^\d{1,15}$/.test(form.before)) f.before = Number(form.before); else bad.push('before'); }
++ form.kind = f.kind;
++ return { filters: f, form, bad };
++}
++
++function listAudit(db, f, limit = 100) {
++ const where = [];
++ const args = [];
++ const kind = AUDIT_KINDS[f.kind] || AUDIT_KINDS.changes;
++ if (kind.where) where.push(`(${kind.where})`);
++ if (f.action) { where.push('action = ?'); args.push(f.action); }
++ if (f.actor) { where.push('actor = ?'); args.push(f.actor); }
++ if (f.targetType) { where.push('target_type = ?'); args.push(f.targetType); }
++ if (f.targetId) { where.push('target_id = ?'); args.push(f.targetId); }
++ if (f.before) { where.push('id < ?'); args.push(f.before); }
++ const rows = db.prepare(`SELECT * FROM audit_log ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY id DESC LIMIT ?`).all(...args, limit + 1);
++ const page = rows.slice(0, limit).map(r => ({ ...r, metadata: parseMeta(r.metadata) }));
++ return { rows: page, next: rows.length > limit ? page[page.length - 1].id : null };
++}
++function parseMeta(s) { try { const v = JSON.parse(s || '{}'); return v && typeof v === 'object' ? v : {}; } catch { return {}; } }
++
++/** subject -> username, from console sign-ins (the audit log keeps only the subject). */
++function usernames(db) {
++ const map = new Map();
++ for (const r of db.prepare('SELECT subject, username FROM console_sessions WHERE username IS NOT NULL ORDER BY created_at').all()) map.set(r.subject, r.username);
++ return map;
++}
++
++// ── Usage ────────────────────────────────────────────────────
++function parseUsageFilters(q = {}, nowMs = Date.now()) {
++ const form = { from: str(q.from).slice(0, 10), to: str(q.to).slice(0, 10), requester: str(q.requester).slice(0, 200) };
++ const bad = [];
++ const f = {};
++ const today = new Date(nowMs).toISOString().slice(0, 10);
++ if (!form.from) form.from = new Date(nowMs - 6 * 86400000).toISOString().slice(0, 10);
++ if (!form.to) form.to = today;
++ if (DAY_RE.test(form.from)) f.from = form.from; else bad.push('from');
++ if (DAY_RE.test(form.to)) f.to = form.to; else bad.push('to');
++ if (form.requester) { const r = requesterOf(form.requester); if (r) f.requester = r; else bad.push('requester'); }
++ return { filters: f, form, bad };
++}
++
++/** usage_daily rows (quotas.usage, the API's own query) summed four ways. */
++function usageReport(quotas, f) {
++ const rows = quotas.usage({ from: f.from, to: f.to, requester: f.requester });
++ const blank = () => ({ requests: 0, tokens_in: 0, tokens_out: 0, tokens_cached: 0, cost_usd: 0 });
++ const add = (acc, r) => { acc.requests += r.requests; acc.tokens_in += r.tokens_in; acc.tokens_out += r.tokens_out; acc.tokens_cached += r.tokens_cached; acc.cost_usd += r.cost_usd; return acc; };
++ const group = (keyOf) => {
++ const m = new Map();
++ for (const r of rows) { const k = keyOf(r); m.set(k, add(m.get(k) || blank(), r)); }
++ return [...m.entries()].map(([key, v]) => ({ key, ...v })).sort((a, b) => b.cost_usd - a.cost_usd || b.requests - a.requests);
++ };
++ return {
++ total: rows.reduce(add, blank()),
++ truncated: rows.length >= 1000,
++ byDay: group(r => r.day).sort((a, b) => (a.key < b.key ? 1 : -1)),
++ byRequester: group(r => r.requester),
++ byWorkflow: group(r => r.workflow_key),
++ byModel: group(r => `${r.provider_key}${r.model_key ? ` / ${r.model_key}` : ''}`),
++ };
++}
++
++module.exports = {
++ RUN_STATUSES, AUDIT_KINDS, PAGE, parseRunFilters, listRuns, failures, statusCounts, runFacets, preview,
++ parseAuditFilters, listAudit, usernames, parseUsageFilters, usageReport, parseTime, requesterOf,
++};
ddiff --git a/server/console/session.js b/server/console/session.js
nnew file mode 100644
iindex 0000000..dd51e20
---- /dev/null
++++ b/server/console/session.js
@@@ -0,0 +1,112 @@
++'use strict';
++/**
++ * Operator console sessions, the sign-in flow cookie, CSRF tokens and client-address hashing (the
++ * same scheme as OpenVibe.Billing's staff console).
++ *
++ * session cookie `__Host-ovai_staff` (plain `ovai_staff` when the console is served over http in
++ * development): a random 256-bit id, host-only, Path=/, HttpOnly, Secure,
++ * SameSite=Strict, Max-Age = the session TTL (absolute, AI_CONSOLE_SESSION_TTL_MIN,
++ * 60 min by default). The database keeps only its SHA-256.
++ * flow cookie `ovai_flow`, Path=/auth, HttpOnly, Secure, SameSite=Lax, 10 minutes: the OAuth
++ * state, the PKCE verifier and the page to return to, HMAC-signed with the session
++ * secret. Lax because the callback is a top-level navigation back from
++ * openvibe.network; the state and PKCE checks are what make it safe.
++ * CSRF one random token per session, in every form, compared in constant time on every
++ * POST, plus an Origin / Sec-Fetch-Site check (index.js csrfOk).
++ */
++const crypto = require('crypto');
++const { iso, parseJson } = require('../util');
++
++const FLOW_COOKIE = 'ovai_flow';
++const FLOW_TTL_MS = 10 * 60 * 1000;
++
++const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
++const random = (bytes = 32) => crypto.randomBytes(bytes).toString('base64url');
++
++function sameString(a, b) {
++ const x = Buffer.from(String(a || ''));
++ const y = Buffer.from(String(b || ''));
++ return x.length === y.length && x.length > 0 && crypto.timingSafeEqual(x, y);
++}
++
++function parseCookies(req) {
++ const out = {};
++ for (const part of String(req.headers.cookie || '').split(';')) {
++ const i = part.indexOf('=');
++ if (i < 1) continue;
++ const k = part.slice(0, i).trim();
++ if (k in out) continue;
++ try { out[k] = decodeURIComponent(part.slice(i + 1).trim()); } catch { /* malformed: ignored */ }
++ }
++ return out;
++}
++
++function cookieHeader(name, value, { maxAgeSec, path = '/', sameSite = 'Strict', secure }) {
++ const parts = [`${name}=${value}`, `Path=${path}`, 'HttpOnly', `SameSite=${sameSite}`];
++ if (secure) parts.push('Secure');
++ parts.push(`Max-Age=${Math.max(0, Math.floor(maxAgeSec))}`);
++ return parts.join('; ');
++}
++
++function appendCookie(res, value) {
++ const prev = res.getHeader('Set-Cookie');
++ res.setHeader('Set-Cookie', prev ? [].concat(prev, value) : [value]);
++}
++
++function createSessions({ db, config, clock, secret }) {
++ const cc = config.console;
++ const now = () => clock.now();
++ const sessionCookie = cc.cookieSecure ? '__Host-ovai_staff' : 'ovai_staff';
++ const hmac = (s) => crypto.createHmac('sha256', secret).update(String(s)).digest('base64url');
++
++ /** Keyed hash of the client address: comparable across rows, not reversible by enumerating IPv4. */
++ const ipHash = (ip) => (ip ? crypto.createHmac('sha256', secret).update(`ip:${ip}`).digest('hex').slice(0, 32) : null);
++
++ // ── Sign-in flow cookie ──────────────────────────────────
++ function setFlow(res, flow) {
++ const body = Buffer.from(JSON.stringify({ ...flow, e: now() + FLOW_TTL_MS })).toString('base64url');
++ appendCookie(res, cookieHeader(FLOW_COOKIE, `${body}.${hmac(`flow:${body}`)}`, { maxAgeSec: FLOW_TTL_MS / 1000, path: '/auth', sameSite: 'Lax', secure: cc.cookieSecure }));
++ }
++ function takeFlow(req, res) {
++ appendCookie(res, cookieHeader(FLOW_COOKIE, '', { maxAgeSec: 0, path: '/auth', sameSite: 'Lax', secure: cc.cookieSecure }));
++ const raw = parseCookies(req)[FLOW_COOKIE];
++ if (!raw) return null;
++ const [body, sig] = raw.split('.');
++ if (!body || !sig || !sameString(sig, hmac(`flow:${body}`))) return null;
++ try {
++ const flow = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'));
++ return flow && typeof flow.e === 'number' && flow.e > now() ? flow : null;
++ } catch { return null; }
++ }
++
++ // ── Sessions ─────────────────────────────────────────────
++ function create(res, { subject, username, staff, ip }) {
++ const t = now();
++ db.prepare('DELETE FROM console_sessions WHERE expires_at < ?').run(iso(t - 24 * 3600 * 1000));
++ const id = random(32);
++ const ttlMs = cc.sessionTtlMin * 60 * 1000;
++ db.prepare(`INSERT INTO console_sessions (id_hash, subject, username, staff, csrf, created_at, expires_at, ip_hash)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(sha256(id), subject, username || null, JSON.stringify(staff || {}), random(32), iso(t), iso(t + ttlMs), ipHash(ip));
++ appendCookie(res, cookieHeader(sessionCookie, id, { maxAgeSec: ttlMs / 1000, secure: cc.cookieSecure }));
++ }
++
++ /** The live session of this request, or null (unknown, expired or revoked). */
++ function read(req) {
++ const id = parseCookies(req)[sessionCookie];
++ if (!id || !/^[A-Za-z0-9_-]{43}$/.test(id)) return null;
++ const row = db.prepare('SELECT * FROM console_sessions WHERE id_hash = ?').get(sha256(id));
++ if (!row || row.revoked_at || Date.parse(row.expires_at) <= now()) return null;
++ return { ...row, staff: parseJson(row.staff, {}) };
++ }
++
++ function revoke(row) {
++ if (row) db.prepare('UPDATE console_sessions SET revoked_at = ? WHERE id_hash = ? AND revoked_at IS NULL').run(iso(now()), row.id_hash);
++ }
++ function clear(res) {
++ appendCookie(res, cookieHeader(sessionCookie, '', { maxAgeSec: 0, secure: cc.cookieSecure }));
++ }
++
++ return { setFlow, takeFlow, create, read, revoke, clear, ipHash, sessionCookie };
++}
++
++module.exports = { createSessions, parseCookies, sameString, sha256, random, FLOW_COOKIE };
ddiff --git a/server/console/sso.js b/server/console/sso.js
nnew file mode 100644
iindex 0000000..20a7831
---- /dev/null
++++ b/server/console/sso.js
@@@ -0,0 +1,120 @@
++'use strict';
++/**
++ * OpenVibe.Network single sign-on for the operator console: OAuth 2 authorization code with PKCE
++ * (S256), as OAuth client `ai` (the client AI already uses for its service tokens).
++ *
++ * authorizeUrl() <OV_NETWORK_URL>/oauth/authorize?response_type=code&client_id=ai
++ * &redirect_uri=<BASE_URL>/auth/callback&scope=profile&state=…&code_challenge=…
++ * &code_challenge_method=S256
++ * exchange() POST <OV_NETWORK_INTERNAL_URL>/oauth/token (client secret + code_verifier), server
++ * to server; the access token is then verified offline with the Network's RS256 key
++ * AI already holds (issuer, audience, expiry)
++ *
++ * The Network's user access token carries `role`, `is_owner` and `subject_id` (and, once the Network
++ * issues them, `staff_caps` / `staff_map`), minted at the code exchange, so what the console reads
++ * is current as of sign-in; the session is short. AI keeps none of the Network's tokens: the refresh
++ * token it is handed is revoked straight away (best effort).
++ */
++const crypto = require('crypto');
++const { ids, staff } = require('openvibe-contracts');
++
++const b64urlJson = (s) => JSON.parse(Buffer.from(String(s), 'base64url').toString('utf8'));
++const PRINCIPAL_SUB = /^(svc|app|mod):/;
++
++function pkcePair() {
++ const verifier = crypto.randomBytes(32).toString('base64url'); // 43 chars
++ const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
++ return { verifier, challenge };
++}
++
++function authorizeUrl(config, { state, challenge }) {
++ const q = new URLSearchParams({
++ response_type: 'code',
++ client_id: config.console.clientId,
++ redirect_uri: config.console.redirectUri,
++ scope: 'profile',
++ state,
++ code_challenge: challenge,
++ code_challenge_method: 'S256',
++ });
++ return `${config.networkUrl}/oauth/authorize?${q.toString()}`;
++}
++
++/** Verify a Network user access token (RS256). Returns claims or throws with a short reason. */
++function verifyUserToken(token, { publicKey, issuer, audience, now }) {
++ const parts = typeof token === 'string' ? token.split('.') : [];
++ if (parts.length !== 3) throw new Error('not a JWT');
++ let header, claims;
++ try { header = b64urlJson(parts[0]); claims = b64urlJson(parts[1]); } catch { throw new Error('undecodable token'); }
++ if (header.alg !== 'RS256') throw new Error(`alg ${header.alg} not accepted`);
++ let good = false;
++ try { good = crypto.verify('RSA-SHA256', Buffer.from(`${parts[0]}.${parts[1]}`), publicKey, Buffer.from(parts[2], 'base64url')); } catch { good = false; }
++ if (!good) throw new Error('signature does not verify');
++ const t = Math.floor(now / 1000);
++ if (typeof claims.exp !== 'number' || claims.exp + 30 < t) throw new Error('token expired');
++ if (typeof claims.iat === 'number' && claims.iat - 30 > t) throw new Error('token issued in the future');
++ if (claims.iss !== issuer) throw new Error('wrong issuer');
++ const aud = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
++ if (!aud.includes(audience)) throw new Error(`token is not for ${audience}`);
++ return claims;
++}
++
++/**
++ * The person a verified token names: { subject, username, staff } or { error }. Service, app and
++ * module principals and FedCM assertions are not people; a person needs a usr_ subject. `staff` is
++ * the staff part of the claims (the contracts staff map's claim names), kept on the session so the
++ * console can ask staff.can() on every request.
++ */
++function personFromClaims(claims) {
++ if (!claims || typeof claims !== 'object') return { error: 'not a token' };
++ if (PRINCIPAL_SUB.test(String(claims.sub || '')) || claims.actor_type === 'service' || claims.actor_type === 'app') return { error: 'not a person\'s token' };
++ if (claims.typ === 'fedcm') return { error: 'a FedCM assertion is not a sign-in' };
++ if (!ids.isSubjectId('user', claims.subject_id)) return { error: 'the token carries no network subject' };
++ const c = staff.map.claims;
++ const staffClaims = { [c.role]: typeof claims[c.role] === 'string' ? claims[c.role] : 'user' };
++ if (claims[c.owner] === true) staffClaims[c.owner] = true;
++ if (Array.isArray(claims[c.capabilities])) staffClaims[c.capabilities] = claims[c.capabilities].filter(x => typeof x === 'string').slice(0, 100);
++ if (typeof claims[c.map] === 'string') staffClaims[c.map] = claims[c.map].slice(0, 20);
++ return {
++ subject: claims.subject_id,
++ username: typeof claims.username === 'string' ? claims.username.slice(0, 64) : null,
++ staff: staffClaims,
++ };
++}
++
++async function exchange({ config, clientSecret, code, verifier, publicKey, now, fetchImpl = globalThis.fetch }) {
++ const cc = config.console;
++ const body = new URLSearchParams({
++ grant_type: 'authorization_code',
++ code: String(code),
++ redirect_uri: cc.redirectUri,
++ client_id: cc.clientId,
++ client_secret: clientSecret,
++ code_verifier: verifier,
++ });
++ const res = await fetchImpl(`${config.networkInternalUrl}/oauth/token`, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
++ body,
++ signal: AbortSignal.timeout(10_000),
++ });
++ const data = await res.json().catch(() => null);
++ if (!res.ok || !data || !data.access_token) {
++ const err = new Error((data && (data.error_description || data.error)) || `token endpoint ${res.status}`);
++ err.status = res.status;
++ throw err;
++ }
++ // AI never uses the Network session again: drop the refresh token now.
++ if (data.refresh_token) {
++ Promise.resolve(fetchImpl(`${config.networkInternalUrl}/oauth/revoke`, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json' },
++ body: JSON.stringify({ client_id: cc.clientId, client_secret: clientSecret, token: data.refresh_token }),
++ signal: AbortSignal.timeout(3000),
++ })).catch(() => {});
++ }
++ const claims = verifyUserToken(data.access_token, { publicKey, issuer: config.issuer, audience: cc.ssoAudience, now });
++ return personFromClaims(claims);
++}
++
++module.exports = { pkcePair, authorizeUrl, verifyUserToken, personFromClaims, exchange };
ddiff --git a/server/console/views.js b/server/console/views.js
nnew file mode 100644
iindex 0000000..72bd162
---- /dev/null
++++ b/server/console/views.js
@@@ -0,0 +1,479 @@
++'use strict';
++/**
++ * Server-rendered pages of the AI operator console. No JavaScript and no external resources: one
++ * inline stylesheet allowed by its hash in the Content-Security-Policy. Every interpolated value is
++ * HTML-escaped by the `html` tag unless it is itself `html`.
++ *
++ * Providers are shown as registry.publicProvider shows them: the secret reference NAME (env:NAME)
++ * and whether it resolves (configured | missing | not_required), never a value; base URLs lose any
++ * user info and query string. Run inputs and outputs arrive already bounded by queries.preview.
++ */
++const crypto = require('crypto');
++
++class Raw { constructor(s) { this.s = s; } }
++const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
++function render(v) {
++ if (v instanceof Raw) return v.s;
++ if (Array.isArray(v)) return v.map(render).join('');
++ if (v == null || v === false) return '';
++ return esc(v);
++}
++function html(strings, ...vals) {
++ let out = strings[0];
++ vals.forEach((v, i) => { out += render(v) + strings[i + 1]; });
++ return new Raw(out);
++}
++
++const CSS = `
++:root{color-scheme:light dark;--bg:#f6f7f9;--fg:#15181d;--muted:#555e6c;--card:#fff;--line:#d5dae2;--accent:#1f5fd6;--on-accent:#fff;--ok:#11703b;--bad:#b3261e;--warn:#7a4f00;--warnbg:#fff4d6}
++@media (prefers-color-scheme:dark){:root{--bg:#101318;--fg:#e8ebf0;--muted:#a3adbb;--card:#171b22;--line:#2d3440;--accent:#8ab2ff;--on-accent:#0b1220;--ok:#5fd394;--bad:#ff8a80;--warn:#ffcf66;--warnbg:#3a2e0b}}
++*{box-sizing:border-box}html{-webkit-text-size-adjust:100%}body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;overflow-wrap:anywhere}
++.skip{position:absolute;left:-9999px;top:0;background:var(--card);color:var(--fg);padding:8px 12px;z-index:2}.skip:focus{left:8px;top:8px}
++.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
++header{background:var(--card);border-bottom:1px solid var(--line)}
++.bar{max-width:1240px;margin:0 auto;padding:10px 16px;display:flex;flex-wrap:wrap;gap:8px 18px;align-items:center}
++.brand{font-weight:700;margin-right:8px}nav ul{display:flex;flex-wrap:wrap;gap:4px 14px;margin:0;padding:0;list-style:none}
++nav a{color:var(--muted);text-decoration:none}nav a[aria-current=page],nav a:hover{color:var(--fg);text-decoration:underline}
++.who{margin-left:auto;color:var(--muted);font-size:13px;display:flex;flex-wrap:wrap;gap:6px 10px;align-items:center}
++main{max-width:1240px;margin:0 auto;padding:18px 16px 48px}
++h1{font-size:22px;margin:4px 0 14px}h2{font-size:17px;margin:24px 0 8px}h3{font-size:15px;margin:16px 0 6px}
++a{color:var(--accent)}a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,.tablewrap:focus-visible,summary:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
++.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,220px),1fr));gap:12px}
++.card{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:14px 16px;margin:0 0 12px}
++.grid .card{margin:0}.card .k{color:var(--muted);font-size:13px}.card .v{font-size:20px;font-weight:650}
++.tablewrap{position:relative;overflow-x:auto;background:var(--card);border:1px solid var(--line);border-radius:10px;margin:0 0 14px;max-width:100%}
++table{border-collapse:collapse;width:100%;font-size:14px}caption{text-align:left;font-weight:650;padding:9px 10px 4px;caption-side:top}
++th,td{text-align:left;padding:7px 10px;border-bottom:1px solid var(--line);vertical-align:top}td{overflow-wrap:normal;word-break:normal}
++th{color:var(--muted);font-weight:600;white-space:nowrap}tbody tr:last-child td{border-bottom:0}
++code,.mono,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px}td code,td .mono{overflow-wrap:break-word}
++pre{background:var(--bg);border:1px solid var(--line);border-radius:8px;padding:10px;overflow:auto;max-height:420px;white-space:pre-wrap;overflow-wrap:anywhere;margin:6px 0 12px}
++.ok{color:var(--ok);font-weight:600}.bad{color:var(--bad);font-weight:600}.warn{color:var(--warn);font-weight:600}.muted{color:var(--muted)}.nowrap{white-space:nowrap}
++.banner{padding:10px 14px;border-radius:8px;margin:0 0 14px;border:1px solid var(--line);background:var(--card)}
++.banner.error{background:var(--warnbg);color:var(--warn);border-color:var(--warn)}.banner.done{border-color:var(--ok);color:var(--ok)}
++.tabs{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 12px;padding:0;list-style:none}.tabs a{display:inline-block;padding:4px 10px;border:1px solid var(--line);border-radius:999px;text-decoration:none;color:var(--fg)}.tabs a[aria-current=page]{background:var(--fg);color:var(--bg)}
++form.inline{display:inline-flex;flex-wrap:wrap;gap:6px;align-items:center;margin:0}form.stack{display:grid;gap:10px;max-width:560px}
++.filters{display:flex;flex-wrap:wrap;gap:10px 12px;align-items:flex-end;margin:0 0 14px}.filters label{flex:1 1 150px;min-width:0}.filters .actions{display:flex;gap:10px;align-items:center}
++label{display:grid;gap:4px;font-size:14px}label.check{display:flex;gap:8px;align-items:flex-start}
++input[type=text],input[type=number],input[type=date],input[type=datetime-local],select{font:inherit;padding:6px 8px;border:1px solid var(--line);border-radius:7px;background:var(--bg);color:var(--fg);width:100%;min-width:0}
++form.inline select{width:auto}
++button{font:inherit;padding:6px 13px;border-radius:7px;border:1px solid var(--line);background:var(--card);color:var(--fg);cursor:pointer}
++button.primary{background:var(--accent);border-color:var(--accent);color:var(--on-accent)}button.danger{border-color:var(--bad);color:var(--bad)}
++button.link{border:0;background:none;padding:0;color:var(--accent);text-decoration:underline}
++fieldset{border:1px solid var(--line);border-radius:10px;padding:12px 14px;margin:0;min-width:0}legend{padding:0 6px;font-weight:600}
++dl{display:grid;grid-template-columns:minmax(0,max-content) minmax(0,1fr);gap:6px 16px;margin:0}dt{color:var(--muted)}dd{margin:0;min-width:0}
++.cols{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,340px),1fr));gap:16px;align-items:start}
++details{margin:6px 0 12px}summary{cursor:pointer;color:var(--accent)}
++.pager{display:flex;gap:14px;flex-wrap:wrap;margin:0 0 14px}.m0{margin:0}.mt{margin:10px 0 0}
++@media (max-width:600px){.who{margin-left:0}h1{font-size:20px}}
++`;
++const CSS_HASH = `sha256-${crypto.createHash('sha256').update(CSS).digest('base64')}`;
++const CSP = `default-src 'none'; style-src '${CSS_HASH}'; img-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'`;
++
++// ── Formatting ───────────────────────────────────────────────
++const n = (v) => Number(v || 0).toLocaleString('en-US');
++function usd(v) {
++ const x = Number(v) || 0;
++ if (x === 0) return '$0';
++ return `$${x < 0.01 ? x.toFixed(6) : x.toFixed(x < 100 ? 4 : 2)}`;
++}
++const when = (s) => (s ? html`<time datetime="${s}">${String(s).replace('T', ' ').slice(0, 19)} UTC</time>` : '—');
++function span(fromIso, toIso) {
++ if (!fromIso || !toIso) return '';
++ const ms = Date.parse(toIso) - Date.parse(fromIso);
++ if (!Number.isFinite(ms) || ms < 0) return '';
++ return ms < 1000 ? `${ms} ms` : ms < 120000 ? `${(ms / 1000).toFixed(1)} s` : `${Math.round(ms / 60000)} min`;
++}
++function secs(s) {
++ const v = Math.max(0, Number(s) || 0);
++ return v < 120 ? `${v} s` : v < 7200 ? `${Math.round(v / 60)} min` : `${Math.round(v / 3600)} h`;
++}
++const clip = (s, max = 200) => { const t = String(s == null ? '' : s); return t.length > max ? `${t.slice(0, max)}…` : t; };
++const GOOD = new Set(['succeeded', 'active', 'closed', 'configured', 'ok', 'cached', 'not_required']);
++const BAD = new Set(['failed', 'open', 'disabled', 'missing', 'error', 'timeout', 'archived']);
++const badge = (s) => html`<span class="${GOOD.has(s) ? 'ok' : BAD.has(s) ? 'bad' : 'warn'}">${s || '—'}</span>`;
++/** A base URL without user info, query or fragment (a key must never ride along in a URL shown here). */
++function safeUrl(u) {
++ if (!u) return '—';
++ try { const x = new URL(u); return `${x.protocol}//${x.host}${x.pathname}`; } catch { return '(unparseable URL)'; }
++}
++const qs = (params) => {
++ const q = new URLSearchParams();
++ for (const [k, v] of Object.entries(params)) if (v != null && v !== '') q.set(k, String(v));
++ const s = q.toString();
++ return s ? `?${s}` : '';
++};
++const enc = encodeURIComponent;
++const csrfField = (csrf) => html`<input type="hidden" name="_csrf" value="${csrf}">`;
++
++/** A table with a caption, inside a focusable scroll container (wide tables scroll, the page does not). */
++function table(caption, head, rows, empty = 'None.') {
++ if (!rows.length) return html`<div class="card"><p class="muted m0"><strong>${caption}:</strong> ${empty}</p></div>`;
++ return html`<div class="tablewrap" role="region" tabindex="0" aria-label="${caption}"><table><caption>${caption}</caption>
++<thead><tr>${head.map((h) => html`<th scope="col">${h}</th>`)}</tr></thead><tbody>${rows}</tbody></table></div>`;
++}
++
++const NAV = [
++ ['/console', 'Overview', 'overview'], ['/console/providers', 'Providers', 'providers'], ['/console/routes', 'Routes', 'routes'],
++ ['/console/templates', 'Templates', 'templates'], ['/console/workflows', 'Workflows', 'workflows'], ['/console/runs', 'Runs', 'runs'],
++ ['/console/quotas', 'Quotas', 'quotas'], ['/console/usage', 'Usage', 'usage'], ['/console/cache', 'Cache', 'cache'], ['/console/audit', 'Audit', 'audit'],
++];
++
++function layout({ title, section, staff, csrf, notice, error, body }) {
++ return `<!doctype html>${render(html`<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
++<meta name="robots" content="noindex,nofollow"><meta name="referrer" content="no-referrer"><title>${title} · AI operator console</title><style>${new Raw(CSS)}</style></head>
++<body><a class="skip" href="#main">Skip to content</a><header><div class="bar"><span class="brand">OpenVibe.AI · operator console</span>
++${staff ? html`<nav aria-label="Console"><ul>${NAV.map(([href, label, id]) => html`<li><a href="${href}"${id === section ? html` aria-current="page"` : ''}>${label}</a></li>`)}</ul></nav>
++<div class="who"><span>${staff.username ? `@${staff.username} ` : ''}<code>${staff.subject}</code> · ${staff.role}</span>
++<form class="inline" method="post" action="/auth/logout">${csrfField(csrf)}<button class="link" type="submit">Sign out</button></form></div>` : ''}
++</div></header><main id="main" tabindex="-1">
++${notice ? html`<div class="banner done" role="status">${notice}</div>` : ''}
++${error ? html`<div class="banner error" role="alert"><strong>Refused:</strong> ${error}</div>` : ''}
++${body}
++</main></body></html>`)}`;
++}
++
++// ── Pages ────────────────────────────────────────────────────
++function signIn({ next, message }) {
++ const href = `/auth/login${next && next !== '/console' ? `?next=${enc(next)}` : ''}`;
++ return layout({
++ title: 'Sign in', body: html`<h1>AI operator console</h1>
++<div class="card"><p>${message || 'Sign in with your OpenVibe.Network account. Only network staff (admins and the owner) can use this console.'}</p>
++<p><a href="${href}">Sign in with OpenVibe.Network</a></p></div>`,
++ });
++}
++
++function message({ title, text, staff, csrf, back = '/console' }) {
++ return layout({ title, staff, csrf, body: html`<h1>${title}</h1><div class="card"><p>${text}</p><p><a href="${back}">Back</a></p></div>` });
++}
++
++const CAP_LABELS = {
++ 'ai.usage.read': 'read everything here (ai.usage.read)',
++ 'ai.workflow.manage': 'change route, template and workflow version status; cancel runs (ai.workflow.manage)',
++ 'ai.provider.manage': 'enable/disable providers, reset circuits, edit quotas, purge the cache (ai.provider.manage)',
++};
++
++function overview({ staff, csrf, st, counts24, failures24, queue, cacheTotals, problems, stubFallback, topQuotas }) {
++ const runs24 = Object.values(counts24).reduce((a, b) => a + b, 0);
++ return layout({
++ title: 'Overview', section: 'overview', staff, csrf, body: html`<h1>Overview</h1>
++<div class="card"><p class="m0">This session may: ${staff.caps.map((c, i) => html`${i ? '; ' : ''}${CAP_LABELS[c] || c}`)}.</p></div>
++<div class="grid">
++<div class="card"><div class="k">Providers</div><div class="v">${n(st.providers.filter((p) => p.status === 'active').length)} active</div>
++<div class="muted">${problems.length ? html`<span class="bad">${problems.length} need attention</span>` : 'no credential or circuit problem'} · stub fallback ${stubFallback ? 'on' : 'off'}</div><a href="/console/providers">Providers</a></div>
++<div class="card"><div class="k">Runs, last 24 h</div><div class="v">${n(runs24)}</div>
++<div class="muted">${Object.entries(counts24).map(([s, c], i) => html`${i ? ' · ' : ''}${s} ${n(c)}`)}</div><a href="/console/runs">Runs</a></div>
++<div class="card"><div class="k">Failed, last 24 h</div><div class="v">${counts24.failed ? html`<span class="bad">${n(counts24.failed)}</span>` : '0'}</div>
++<div class="muted">${failures24.length ? failures24.slice(0, 3).map((f, i) => html`${i ? ' · ' : ''}${f.error_code || '(none)'} ${n(f.n)}`) : 'none'}</div><a href="/console/runs?status=failed">Failed runs</a></div>
++<div class="card"><div class="k">Spend today (${st.today.day})</div><div class="v">${usd(st.today.cost_usd)}</div><div class="muted">${n(st.today.requests)} provider requests</div><a href="/console/usage">Usage</a></div>
++<div class="card"><div class="k">Queue</div><div class="v">${n(queue.running)} running</div><div class="muted">${n(queue.queued)} queued · up to ${n(queue.max_concurrent)} at once, ${n(queue.max_queued)} waiting</div></div>
++<div class="card"><div class="k">Definitions</div><div class="v">${n(st.workflows)} workflows</div><div class="muted">${n(st.templates)} templates · ${n(st.routes)} routes</div><a href="/console/workflows">Workflows</a></div>
++<div class="card"><div class="k">Cache</div><div class="v">${n(cacheTotals.entries)} entries</div><div class="muted">${n(cacheTotals.hits)} hits served</div><a href="/console/cache">Cache</a></div>
++</div>
++<h2>Needs attention</h2>
++${table('Provider problems', ['Provider', 'Kind', 'Problem'], problems.map((p) => html`<tr><td><code>${p.key}</code></td><td>${p.kind}</td><td class="bad">${p.problem}</td></tr>`), 'none.')}
++${table('Failed runs in the last 24 hours, by error code', ['Error code', 'Runs', 'Workflows', 'Last'], failures24.map((f) => html`<tr>
++<td><a href="/console/runs${qs({ status: 'failed', code: f.error_code })}"><code>${f.error_code || '(none)'}</code></a></td><td>${n(f.n)}</td><td>${n(f.workflows)}</td><td>${when(f.last_at)}</td></tr>`), 'no failed runs.')}
++${table('Busiest quota windows', ['Scope', 'Window', 'Used', 'Limit', 'Resets in'], topQuotas.map((c) => html`<tr>
++<td>${c.quota.scope_type} <code>${c.scope_id}</code>${c.quota.workflow_prefix ? html` · <code>${c.quota.workflow_prefix}*</code>` : ''}</td><td>${c.quota.window}</td>
++<td>${limitsUsed(c)}</td><td>${limits(c.quota)}</td><td>${secs(c.resets_in_seconds)}</td></tr>`), 'no active quota.')}`,
++ });
++}
++
++function limits(q) {
++ const parts = [];
++ if (q.max_requests != null) parts.push(`${n(q.max_requests)} requests`);
++ if (q.max_tokens != null) parts.push(`${n(q.max_tokens)} tokens`);
++ if (q.max_cost_usd != null) parts.push(usd(q.max_cost_usd));
++ return parts.join(' · ') || 'no limit set';
++}
++function limitsUsed(c) {
++ const q = c.quota;
++ const pct = (used, max) => (max ? ` (${Math.min(999, Math.round((used / max) * 100))}%)` : '');
++ const parts = [];
++ if (q.max_requests != null) parts.push(`${n(c.used.requests)} requests${pct(c.used.requests, q.max_requests)}`);
++ if (q.max_tokens != null) parts.push(`${n(c.used.tokens)} tokens${pct(c.used.tokens, q.max_tokens)}`);
++ if (q.max_cost_usd != null) parts.push(`${usd(c.used.cost_usd)}${pct(c.used.cost_usd, q.max_cost_usd)}`);
++ return parts.join(' · ') || `${n(c.used.requests)} requests`;
++}
++
++function providers({ staff, csrf, rows, models, canManage, stubFallback, notice, error }) {
++ return layout({
++ title: 'Providers', section: 'providers', staff, csrf, notice, error, body: html`<h1>Providers and models</h1>
++<p class="muted">Credentials are secret <em>references</em> (<code>env:NAME</code>) resolved from the service environment; this page shows the name and whether it resolves, never a value. Routes try their primary and then each fallback in order; the table below is in provider priority order. The deterministic stub joins every route as a last resort: <strong>${stubFallback ? 'on' : 'off'}</strong> (<code>AI_STUB_FALLBACK</code>).</p>
++${table('Providers', ['Provider', 'Kind', 'Status', 'Credentials', 'Circuit', 'Base URL', 'Default model', 'Can do', 'Priority / timeout', ...(canManage ? ['Actions'] : [])], rows.map((p) => html`<tr>
++<td><code>${p.key}</code><br><span class="muted">${p.display_name}</span></td><td>${p.kind}</td><td>${badge(p.status)}</td>
++<td>${badge(p.credentials)}${p.secret_ref ? html`<br><code>${p.secret_ref}</code>` : ''}<br><span class="muted">${p.auth_mode}</span></td>
++<td>${badge(p.health.state)}${p.health.consecutive_failures ? html`<br><span class="muted">${n(p.health.consecutive_failures)} failure(s) in a row</span>` : ''}${p.health.last_error ? html`<br><span class="muted">${clip(p.health.last_error, 160)}</span>` : ''}</td>
++<td class="mono">${safeUrl(p.base_url)}</td><td>${p.default_model ? html`<code>${p.default_model}</code>` : '—'}</td><td>${(p.capabilities || []).join(', ') || '—'}</td>
++<td>${n(p.priority)} · ${n(p.timeout_ms)} ms<br><span class="muted">${p.origin}, updated ${when(p.updated_at)}</span></td>
++${canManage ? html`<td><form class="inline" method="post" action="/console/providers/${enc(p.key)}/status">${csrfField(csrf)}<input type="hidden" name="status" value="${p.status === 'active' ? 'disabled' : 'active'}"><button type="submit" class="${p.status === 'active' ? 'danger' : ''}">${p.status === 'active' ? 'Disable' : 'Enable'}<span class="sr-only"> ${p.key}</span></button></form>
++${p.health.state !== 'closed' || p.health.consecutive_failures ? html` <form class="inline" method="post" action="/console/providers/${enc(p.key)}/reset">${csrfField(csrf)}<button type="submit">Reset circuit<span class="sr-only"> of ${p.key}</span></button></form>` : ''}</td>` : ''}
++</tr>`), 'no providers.')}
++${canManage ? '' : html`<p class="muted">Enabling, disabling and circuit resets need <code>ai.provider.manage</code> (the owner's <code>staff.secrets.manage</code>).</p>`}
++${table('Models', ['Provider', 'Model', 'Type', 'Status', 'Context / max output', 'Cost per Mtok (in / out / cached)', 'Supports'], models.map((m) => html`<tr>
++<td><code>${m.provider_key}</code></td><td><code>${m.model_key}</code></td><td>${m.type}</td><td>${badge(m.status)}</td>
++<td>${m.context_window ? n(m.context_window) : '—'} / ${m.max_output ? n(m.max_output) : '—'}</td>
++<td>${m.cost.in_per_mtok != null ? usd(m.cost.in_per_mtok) : '—'} / ${m.cost.out_per_mtok != null ? usd(m.cost.out_per_mtok) : '—'} / ${m.cost.cached_per_mtok != null ? usd(m.cost.cached_per_mtok) : '—'}</td>
++<td>${Object.entries(m.supports).filter(([, v]) => v).map(([k]) => k).join(', ') || '—'}</td></tr>`), 'no models registered (providers use their default model).')}`,
++ });
++}
++
++const KINDS = {
++ routes: { one: 'route', title: 'Routes', statuses: ['active', 'disabled'] },
++ templates: { one: 'template', title: 'Templates', statuses: ['draft', 'active', 'deprecated', 'archived'] },
++ workflows: { one: 'workflow', title: 'Workflows', statuses: ['draft', 'active', 'deprecated', 'archived'] },
++};
++const target = (c) => (c ? html`<code>${c.provider}</code>${c.model ? html` / <code>${c.model}</code>` : ''}` : '—');
++
++function definitions({ staff, csrf, kind, rows, inUse }) {
++ const k = KINDS[kind];
++ const link = (key) => html`<a href="/console/${kind}/${enc(key)}"><code>${key}</code></a>`;
++ const used = (r) => (inUse.get(r.key) ? `v${inUse.get(r.key)}` : html`<span class="bad">none</span>`);
++ let head, body;
++ if (kind === 'routes') {
++ head = ['Route', 'Newest', 'Status', 'Primary', 'Fallbacks, in order', 'Format / timeout', 'Created'];
++ body = rows.map((r) => html`<tr><td>${link(r.key)}</td><td>v${r.version}</td><td>${badge(r.status)}</td>
++<td>${r.alias_of ? html`alias of <a href="/console/routes/${enc(r.alias_of)}"><code>${r.alias_of}</code></a>` : target(r.primary)}</td>
++<td>${r.alias_of ? '—' : r.fallbacks.length ? r.fallbacks.map((f, i) => html`${i ? ' → ' : ''}${target(f)}`) : html`<span class="warn">none</span>`}</td>
++<td>${r.response_format}${r.timeout_ms ? ` · ${n(r.timeout_ms)} ms` : ''}</td><td>${r.created_by || '—'}<br>${when(r.created_at)}</td></tr>`);
++ } else if (kind === 'templates') {
++ head = ['Template', 'Newest', 'Status', 'In use', 'Name', 'Default route', 'Owner / visibility', 'Created'];
++ body = rows.map((t) => html`<tr><td>${link(t.key)}</td><td>v${t.version}</td><td>${badge(t.status)}</td><td>${used(t)}</td><td>${t.name}</td>
++<td>${t.default_route ? html`<code>${t.default_route}</code>` : '—'}</td><td>${t.owner} · ${t.visibility}</td><td>${t.created_by || '—'}<br>${when(t.created_at)}</td></tr>`);
++ } else {
++ head = ['Workflow', 'Newest', 'Status', 'In use', 'Namespace', 'Steps', 'Cache', 'Default route', 'Created'];
++ body = rows.map((w) => html`<tr><td>${link(w.key)}</td><td>v${w.version}</td><td>${badge(w.status)}</td><td>${used(w)}</td><td>${w.namespace}</td>
++<td>${(w.steps || []).map((s) => s.kind + (s.template ? ` (${s.template})` : '')).join(', ')}</td><td>${w.cache_mode}${w.cache_ttl_sec ? ` · ${secs(w.cache_ttl_sec)}` : ''}</td>
++<td>${w.default_route ? html`<code>${w.default_route}</code>` : '—'}</td><td>${w.created_by || '—'}<br>${when(w.created_at)}</td></tr>`);
++ }
++ return layout({
++ title: k.title, section: kind, staff, csrf, body: html`<h1>${k.title}</h1>
++<p class="muted">Every edit is a new version and old versions stay readable; runs record the versions they used. ${kind === 'routes'
++ ? 'A route runs its newest version, which must be active.'
++ : 'Runs use the newest active version ("in use"); a newer draft is not used until it is made active.'} Versions are created through the admin API (<code>POST /api/v1/${kind}/:key/versions</code>).</p>
++${table(`${k.title} (newest version of each)`, head, body, `no ${kind}.`)}`,
++ });
++}
++
++function definition({ staff, csrf, kind, key, versions, selected, inUse, canManage, notice, error }) {
++ const k = KINDS[kind];
++ const v = selected;
++ let content;
++ if (kind === 'routes') {
++ content = html`<dl><dt>Status</dt><dd>${badge(v.status)}</dd>
++${v.alias_of ? html`<dt>Alias of</dt><dd><a href="/console/routes/${enc(v.alias_of)}"><code>${v.alias_of}</code></a></dd>` : html`<dt>Primary</dt><dd>${target(v.primary)}</dd>
++<dt>Fallbacks</dt><dd>${v.fallbacks.length ? html`<ol>${v.fallbacks.map((f) => html`<li>${target(f)}</li>`)}</ol>` : html`<span class="warn">none: an outage of the primary fails every run on this route</span>`}</dd>`}
++<dt>Response format</dt><dd>${v.response_format}</dd><dt>Max output tokens</dt><dd>${v.max_output_tokens != null ? n(v.max_output_tokens) : '—'}</dd>
++<dt>Timeout</dt><dd>${v.timeout_ms ? `${n(v.timeout_ms)} ms` : 'provider default'}</dd><dt>Created</dt><dd>${v.created_by || '—'}, ${when(v.created_at)}</dd></dl>
++<h3>Options</h3><pre>${JSON.stringify(v.options || {}, null, 2)}</pre>`;
++ } else if (kind === 'templates') {
++ content = html`<dl><dt>Status</dt><dd>${badge(v.status)}</dd><dt>Name</dt><dd>${v.name}</dd><dt>Description</dt><dd>${v.description || '—'}</dd>
++<dt>Default route</dt><dd>${v.default_route ? html`<a href="/console/routes/${enc(v.default_route)}"><code>${v.default_route}</code></a>` : '—'}</dd>
++<dt>Owner / visibility</dt><dd>${v.owner} · ${v.visibility}</dd><dt>Created</dt><dd>${v.created_by || '—'}, ${when(v.created_at)}</dd></dl>
++<h3>System prompt</h3><pre>${v.system_prompt || '(empty)'}</pre><h3>User prompt</h3><pre>${v.user_prompt || '(empty)'}</pre>
++<details><summary>Input schema</summary><pre>${JSON.stringify(v.input_schema, null, 2)}</pre></details>
++<details><summary>Output schema</summary><pre>${JSON.stringify(v.output_schema, null, 2)}</pre></details>
++<details><summary>Metadata</summary><pre>${JSON.stringify(v.metadata || {}, null, 2)}</pre></details>`;
++ } else {
++ content = html`<dl><dt>Status</dt><dd>${badge(v.status)}</dd><dt>Name</dt><dd>${v.name}</dd><dt>Description</dt><dd>${v.description || '—'}</dd>
++<dt>Namespace</dt><dd>${v.namespace}</dd><dt>Default route</dt><dd>${v.default_route ? html`<a href="/console/routes/${enc(v.default_route)}"><code>${v.default_route}</code></a>` : '—'}</dd>
++<dt>Cache</dt><dd>${v.cache_mode}${v.cache_ttl_sec ? ` · ${secs(v.cache_ttl_sec)}` : ''}</dd><dt>Created</dt><dd>${v.created_by || '—'}, ${when(v.created_at)}</dd></dl>
++<h3>Steps</h3>${html`<ol>${(v.steps || []).map((s) => html`<li><code>${s.kind}</code>${s.template ? html` · template <a href="/console/templates/${enc(s.template)}"><code>${s.template}</code></a>` : ''}${s.route ? html` · route <code>${s.route}</code>` : ''}${s.operation ? html` · ${s.operation}` : ''}</li>`)}</ol>`}
++<details><summary>Steps as stored</summary><pre>${JSON.stringify(v.steps || [], null, 2)}</pre></details>
++<details><summary>Input schema</summary><pre>${JSON.stringify(v.input_schema, null, 2)}</pre></details>
++<details><summary>Output schema</summary><pre>${JSON.stringify(v.output_schema, null, 2)}</pre></details>
++<details><summary>Metadata</summary><pre>${JSON.stringify(v.metadata || {}, null, 2)}</pre></details>`;
++ }
++ const statusForm = (x) => html`<form class="inline" method="post" action="/console/${kind}/${enc(key)}/versions/${x.version}/status">${csrfField(csrf)}
++<label><span class="sr-only">Status of version ${x.version}</span><select name="status">${k.statuses.map((s) => html`<option value="${s}"${s === x.status ? html` selected` : ''}>${s}</option>`)}</select></label><button type="submit">Set<span class="sr-only"> status of version ${x.version}</span></button></form>`;
++ return layout({
++ title: `${k.one} ${key}`, section: kind, staff, csrf, notice, error, body: html`<h1>${k.title.slice(0, -1)} <code>${key}</code></h1>
++<p>${inUse ? html`Runs use <strong>v${inUse}</strong>.` : html`<span class="bad">No version is in use</span> (none is active).`}
++${kind === 'workflows' ? html` <a href="/console/runs${qs({ workflow: key })}">Runs of this workflow</a> · <a href="/console/runs${qs({ workflow: key, status: 'failed' })}">failed runs</a>` : ''}
++<a href="/console/audit${qs({ kind: 'all', target_type: k.one, target_id: key })}">Changes to it</a></p>
++${table('Versions', ['Version', 'Status', 'Created by', 'Created', ...(canManage ? ['Change status'] : [])], versions.map((x) => html`<tr>
++<td><a href="/console/${kind}/${enc(key)}${qs({ version: x.version })}"${x.version === v.version ? html` aria-current="true"` : ''}>v${x.version}</a>${x.version === inUse ? html` <span class="ok">in use</span>` : ''}</td>
++<td>${badge(x.status)}</td><td>${x.created_by || '—'}</td><td>${when(x.created_at)}</td>${canManage ? html`<td>${statusForm(x)}</td>` : ''}</tr>`))}
++${canManage ? '' : html`<p class="muted">Changing a version's status needs <code>ai.workflow.manage</code> (<code>staff.ai.manage</code>).</p>`}
++<h2>Version ${v.version}</h2>${content}`,
++ });
++}
++
++const RUN_TABS = [['', 'All'], ['failed', 'Failed'], ['running', 'Running'], ['queued', 'Queued'], ['cancelled', 'Cancelled'], ['succeeded', 'Succeeded'], ['cached', 'Cached']];
++
++function runs({ staff, csrf, form, filters, bad, rows, next, facets, failures24 }) {
++ const keep = { workflow: form.workflow, requester: form.requester, code: form.code, from: form.from, to: form.to };
++ return layout({
++ title: 'Runs', section: 'runs', staff, csrf, error: bad.length ? `ignored malformed filter(s): ${bad.join(', ')}` : null, body: html`<h1>Runs</h1>
++<nav aria-label="Run status"><ul class="tabs">${RUN_TABS.map(([s, label]) => html`<li><a href="/console/runs${qs({ ...keep, status: s })}"${(filters.status || '') === s ? html` aria-current="page"` : ''}>${label}</a></li>`)}</ul></nav>
++<form class="filters" method="get" action="/console/runs" role="search" aria-label="Filter runs">
++<label>Status<select name="status"><option value="">any</option>${RUN_TABS.slice(1).map(([s]) => html`<option value="${s}"${filters.status === s ? html` selected` : ''}>${s}</option>`)}</select></label>
++<label>Workflow<input type="text" name="workflow" value="${form.workflow}" list="dl-workflows" autocomplete="off" spellcheck="false"></label>
++<label>Requester<input type="text" name="requester" value="${form.requester}" list="dl-requesters" placeholder="service:live" autocomplete="off" spellcheck="false"></label>
++<label>Error code<input type="text" name="code" value="${form.code}" list="dl-codes" autocomplete="off" spellcheck="false"></label>
++<label>From (UTC)<input type="datetime-local" name="from" value="${form.from}"></label>
++<label>To (UTC)<input type="datetime-local" name="to" value="${form.to}"></label>
++<div class="actions"><button class="primary" type="submit">Filter</button><a href="/console/runs">Clear</a></div>
++<datalist id="dl-workflows">${facets.workflows.map((w) => html`<option value="${w}"></option>`)}</datalist>
++<datalist id="dl-requesters">${facets.requesters.map((r) => html`<option value="${r}"></option>`)}</datalist>
++<datalist id="dl-codes">${facets.codes.map((c) => html`<option value="${c}"></option>`)}</datalist>
++</form>
++${filters.status === 'failed' && failures24.length ? table('Failed runs in the last 24 hours, by error code', ['Error code', 'Runs', 'Workflows', 'Last'], failures24.map((f) => html`<tr>
++<td><a href="/console/runs${qs({ ...keep, status: 'failed', code: f.error_code })}"><code>${f.error_code || '(none)'}</code></a></td><td>${n(f.n)}</td><td>${n(f.workflows)}</td><td>${when(f.last_at)}</td></tr>`)) : ''}
++${table(`Runs, newest first${filters.before ? ' (older page)' : ''}`, ['Run', 'Status', 'Workflow', 'Requester', 'Provider / model', 'Attempts', 'Tokens in / out', 'Cost', 'Error', 'Created', 'Took'], rows.map((r) => html`<tr>
++<td><a class="mono" href="/console/runs/${enc(r.id)}">${r.id}</a></td><td>${badge(r.status)}${r.synthetic ? html`<br><span class="muted">synthetic</span>` : ''}</td>
++<td><code>${r.workflow_key}</code> v${r.workflow_version}</td><td><code>${r.requester_type}:${r.requester_id}</code></td>
++<td>${r.provider_key ? html`<code>${r.provider_key}</code>${r.model_key ? html` / <code>${r.model_key}</code>` : ''}${r.fallback_used ? html`<br><span class="warn">fallback</span>` : ''}` : r.cached_from ? 'cache' : '—'}</td>
++<td>${n(r.attempts)}</td><td>${n(r.tokens_in)} / ${n(r.tokens_out)}</td><td>${usd(r.cost_usd)}</td><td>${r.error_code ? html`<code>${r.error_code}</code>` : '—'}</td>
++<td class="nowrap">${when(r.created_at)}</td><td>${span(r.created_at, r.finished_at)}</td></tr>`), 'no run matches.')}
++<div class="pager">${filters.before ? html`<a href="/console/runs${qs({ ...keep, status: filters.status })}">Newest</a>` : ''}${next ? html`<a href="/console/runs${qs({ ...keep, status: filters.status, before: next })}">Older runs</a>` : ''}</div>`,
++ });
++}
++
++const ref = (x) => (x ? (x.service ? `${x.service}:${x.type}:${x.id}` : `${x.type}:${x.id}`) : '—');
++
++function run({ staff, csrf, r, citations, requests, input, output, canCancel, notice, error }) {
++ const g = r.grounding;
++ const open = r.status === 'queued' || r.status === 'running';
++ return layout({
++ title: `Run ${r.id}`, section: 'runs', staff, csrf, notice, error, body: html`<h1>Run <span class="mono">${r.id}</span> ${badge(r.status)}</h1>
++<div class="cols"><div class="card"><dl>
++<dt>Workflow</dt><dd><a href="/console/workflows/${enc(r.workflow.key)}${qs({ version: r.workflow.version })}"><code>${r.workflow.key}</code> v${r.workflow.version}</a></dd>
++<dt>Template</dt><dd>${r.template ? html`<a href="/console/templates/${enc(r.template.key)}${qs({ version: r.template.version })}"><code>${r.template.key}</code> v${r.template.version}</a>` : '—'}</dd>
++<dt>Route</dt><dd>${r.route ? html`<a href="/console/routes/${enc(r.route.key)}${r.route.version ? qs({ version: r.route.version }) : ''}"><code>${r.route.key}</code>${r.route.version ? ` v${r.route.version}` : ''}</a>` : '—'}</dd>
++<dt>Provider / model</dt><dd>${r.provenance.provider ? html`<code>${r.provenance.provider}</code>` : '—'}${r.provenance.model ? html` / <code>${r.provenance.model}</code>` : ''}${r.provenance.fallback_used ? html` <span class="warn">(fallback)</span>` : ''}</dd>
++<dt>Synthetic</dt><dd>${r.synthetic ? html`<span class="warn">yes (stub output)</span>` : 'no'}</dd>
++<dt>Attempts</dt><dd>${n(r.usage.attempts)}</dd>
++<dt>Tokens</dt><dd>${n(r.usage.tokens_in)} in · ${n(r.usage.tokens_out)} out</dd>
++<dt>Cost</dt><dd>${usd(r.usage.cost_usd)}</dd>
++${r.error ? html`<dt>Error</dt><dd><code class="bad">${r.error.code}</code><br>${clip(r.error.detail, 500)}</dd>` : ''}
++</dl></div><div class="card"><dl>
++<dt>Requester</dt><dd><code>${r.requester.type}:${r.requester.id}</code></dd>
++<dt>On behalf of</dt><dd>${r.on_behalf_of ? html`<code>${ref(r.on_behalf_of)}</code>` : '—'}</dd>
++<dt>Attribution</dt><dd>${r.attribution ? html`<code>${ref(r.attribution)}</code>` : '—'}</dd>
++<dt>Target</dt><dd>${r.target ? html`<code>${ref(r.target)}</code>` : '—'}</dd>
++<dt>Idempotency key</dt><dd>${r.idempotency_key ? html`<code>${r.idempotency_key}</code>` : '—'}</dd>
++<dt>Trace</dt><dd>${r.trace_id ? html`<code>${r.trace_id}</code>` : '—'}</dd>
++${r.retry_of ? html`<dt>Retry of</dt><dd><a class="mono" href="/console/runs/${enc(r.retry_of)}">${r.retry_of}</a></dd>` : ''}
++${r.provenance.cached_from ? html`<dt>Cached from</dt><dd><a class="mono" href="/console/runs/${enc(r.provenance.cached_from)}">${r.provenance.cached_from}</a></dd>` : ''}
++<dt>Created</dt><dd>${when(r.created_at)}</dd><dt>Started</dt><dd>${when(r.started_at)}</dd>
++<dt>Finished</dt><dd>${when(r.finished_at)}${r.finished_at ? html` <span class="muted">(${span(r.created_at, r.finished_at)})</span>` : ''}</dd>
++</dl>
++${open && canCancel ? html`<form class="stack" method="post" action="/console/runs/${enc(r.id)}/cancel">${csrfField(csrf)}<p class="muted mt">Cancelling ends the run as cancelled; the requester can retry it.</p><button class="danger" type="submit">Cancel this run</button></form>` : ''}
++</div></div>
++<h2>Grounding</h2>
++${g ? html`<div class="card"><p class="m0">${g.cited && g.cited.length ? html`Cites source(s) ${g.cited.join(', ')}.` : 'Cites no source.'}</p>
++${g.gaps && g.gaps.length ? html`<p class="m0">Gaps:</p><ul>${g.gaps.map((x) => html`<li>${clip(typeof x === 'string' ? x : JSON.stringify(x), 300)}</li>`)}</ul>` : html`<p class="muted m0">No gap recorded.</p>`}</div>` : html`<div class="card muted">No grounding recorded for this run.</div>`}
++${table('Citations', ['#', 'Source type', 'Title', 'URL / id', 'Attached by'], citations.map((c) => html`<tr><td>${c.ordinal}</td><td><code>${c.source_type}</code></td>
++<td>${clip(c.title || '—', 160)}</td><td class="mono">${clip(c.url || c.source_id || '—', 200)}</td><td>${c.attached_by || '—'}</td></tr>`), 'none.')}
++${table('Provider requests (the request log keeps hashes, never prompts)', ['#', 'Operation', 'Provider / model', 'Route', 'Status', 'Tokens in / out / cached', 'Cost', 'Latency', 'Error'], requests.map((q) => html`<tr>
++<td>${q.seq}</td><td>${q.operation}</td><td><code>${q.provider_key}</code>${q.model_key ? html` / <code>${q.model_key}</code>` : ''}${q.fallback ? html`<br><span class="warn">fallback</span>` : ''}</td>
++<td>${q.route_key ? html`<code>${q.route_key}</code>${q.route_version ? ` v${q.route_version}` : ''}` : '—'}</td><td>${badge(q.status)}${q.skip_reason ? html`<br><span class="muted">${q.skip_reason}</span>` : ''}</td>
++<td>${n(q.tokens_in)} / ${n(q.tokens_out)} / ${n(q.tokens_cached)}${q.tokens_estimated ? html`<br><span class="muted">estimated</span>` : ''}</td><td>${usd(q.cost_usd)}</td><td>${q.latency_ms != null ? `${n(q.latency_ms)} ms` : '—'}</td>
++<td>${q.error ? clip(q.error, 200) : '—'}</td></tr>`), 'no provider request (cached, refused before a call, or still queued).')}
++<h2>Input</h2><p class="muted">As stored: inline images are kept as hashes and passthrough prompts are not kept at all. Shown here bounded: long strings, long lists and credential-named keys are cut.</p>
++${input.text != null ? html`<pre>${input.text}</pre>` : html`<div class="card muted">Nothing stored.</div>`}
++<h2>Output</h2>
++${output.text != null ? html`<pre>${output.text}</pre>` : html`<div class="card muted">No output${r.status === 'failed' ? ' (the run failed)' : ''}.</div>`}`,
++ });
++}
++
++function quotas({ staff, csrf, counters, all, canManage, notice, error, form = {} }) {
++ const sel = (name, opts, cur) => html`<select name="${name}" id="q-${name}">${opts.map((o) => html`<option value="${o}"${o === cur ? html` selected` : ''}>${o}</option>`)}</select>`;
++ return layout({
++ title: 'Quotas', section: 'quotas', staff, csrf, notice, error, body: html`<h1>Quotas</h1>
++<p class="muted">A quota limits requests, tokens and/or cost for one scope in one UTC window, and is checked before any provider call (a refusal is 429 <code>quota.exceeded</code>). Scope <code>*</code> on a service, actor or attribution quota gives each principal its own window.</p>
++${table('Active quotas and their current windows', ['Scope', 'Window', 'Workflow prefix', 'Limit', 'Used this window', 'Window started', 'Resets in'], counters.map((c) => html`<tr>
++<td>${c.quota.scope_type} <code>${c.scope_id}</code></td><td>${c.quota.window}</td><td>${c.quota.workflow_prefix ? html`<code>${c.quota.workflow_prefix}</code>` : '—'}</td>
++<td>${limits(c.quota)}</td><td>${limitsUsed(c)}</td><td>${when(c.window_start)}</td><td>${secs(c.resets_in_seconds)}</td></tr>`), 'no active quota with a single scope.')}
++${table('Every quota', ['#', 'Scope', 'Window', 'Workflow prefix', 'Limit', 'Status', 'Origin', 'Updated'], all.map((q) => html`<tr>
++<td>${q.id}</td><td>${q.scope_type} <code>${q.scope_id}</code></td><td>${q.window}</td><td>${q.workflow_prefix ? html`<code>${q.workflow_prefix}</code>` : '—'}</td>
++<td>${limits(q)}</td><td>${badge(q.status)}</td><td>${q.origin}</td><td>${when(q.updated_at)}</td></tr>`), 'no quotas.')}
++${canManage ? html`<h2>Add or change a quota</h2>
++<form class="stack" method="post" action="/console/quotas">${csrfField(csrf)}
++<p class="muted m0">A quota with the same scope, window and workflow prefix is updated in place. Leave a limit empty for none; disable a quota with its status.</p>
++<label for="q-scope_type">Scope type${sel('scope_type', ['global', 'service', 'actor', 'attribution'], form.scope_type || 'service')}</label>
++<label>Scope id (<code>*</code> for global, or each principal)<input type="text" name="scope_id" maxlength="200" value="${form.scope_id || ''}" autocomplete="off" spellcheck="false" placeholder="live"></label>
++<label for="q-window">Window${sel('window', ['minute', 'hour', 'day'], form.window || 'day')}</label>
++<label>Max requests<input type="number" name="max_requests" min="0" step="1" value="${form.max_requests || ''}"></label>
++<label>Max tokens<input type="number" name="max_tokens" min="0" step="1" value="${form.max_tokens || ''}"></label>
++<label>Max cost (USD)<input type="number" name="max_cost_usd" min="0" step="any" value="${form.max_cost_usd || ''}"></label>
++<label>Workflow prefix (optional)<input type="text" name="workflow_prefix" maxlength="120" value="${form.workflow_prefix || ''}" autocomplete="off" spellcheck="false" placeholder="live."></label>
++<label for="q-status">Status${sel('status', ['active', 'disabled'], form.status || 'active')}</label>
++<button class="primary" type="submit">Save quota</button></form>` : html`<p class="muted">Changing quotas needs <code>ai.provider.manage</code> (the owner's <code>staff.secrets.manage</code>).</p>`}`,
++ });
++}
++
++function usageRows(caption, first, rows) {
++ return table(caption, [first, 'Requests', 'Tokens in', 'Tokens out', 'Cached', 'Cost'], rows.map((r) => html`<tr><td><code>${r.key}</code></td><td>${n(r.requests)}</td>
++<td>${n(r.tokens_in)}</td><td>${n(r.tokens_out)}</td><td>${n(r.tokens_cached)}</td><td>${usd(r.cost_usd)}</td></tr>`), 'no usage in this range.');
++}
++
++function usage({ staff, csrf, form, bad, report }) {
++ const t = report.total;
++ return layout({
++ title: 'Usage', section: 'usage', staff, csrf, error: bad.length ? `ignored malformed filter(s): ${bad.join(', ')}` : null, body: html`<h1>Usage</h1>
++<form class="filters" method="get" action="/console/usage" aria-label="Usage range">
++<label>From (UTC day)<input type="date" name="from" value="${form.from}"></label><label>To (UTC day, inclusive)<input type="date" name="to" value="${form.to}"></label>
++<label>Requester<input type="text" name="requester" value="${form.requester}" placeholder="service:live" autocomplete="off" spellcheck="false"></label>
++<div class="actions"><button class="primary" type="submit">Show</button><a href="/console/usage">Last 7 days</a></div></form>
++<div class="grid"><div class="card"><div class="k">Requests</div><div class="v">${n(t.requests)}</div></div>
++<div class="card"><div class="k">Tokens</div><div class="v">${n(t.tokens_in + t.tokens_out)}</div><div class="muted">${n(t.tokens_in)} in · ${n(t.tokens_out)} out · ${n(t.tokens_cached)} cached</div></div>
++<div class="card"><div class="k">Cost</div><div class="v">${usd(t.cost_usd)}</div></div></div>
++${report.truncated ? html`<p class="warn">Only the first 1,000 daily rows are summed; narrow the range.</p>` : ''}
++<h2>Breakdown</h2>
++${usageRows('By day', 'Day', report.byDay)}${usageRows('By requester', 'Requester', report.byRequester)}
++${usageRows('By workflow', 'Workflow', report.byWorkflow)}${usageRows('By provider and model', 'Provider / model', report.byModel)}`,
++ });
++}
++
++function cache({ staff, csrf, stats, canManage, notice, error }) {
++ const entries = stats.reduce((a, s) => a + s.entries, 0);
++ const hits = stats.reduce((a, s) => a + (s.hits || 0), 0);
++ return layout({
++ title: 'Cache', section: 'cache', staff, csrf, notice, error, body: html`<h1>Cache</h1>
++<p class="muted">Cached outputs are served only within their scope (private: requester + actor + target + attribution; service: requester only) and only for the same workflow, template and route versions and input. Synthetic output is never cached.</p>
++<div class="grid"><div class="card"><div class="k">Live entries</div><div class="v">${n(entries)}</div></div><div class="card"><div class="k">Hits served</div><div class="v">${n(hits)}</div><div class="muted">by the live entries</div></div></div>
++${table('Live entries by workflow', ['Workflow', 'Privacy', 'Entries', 'Hits'], stats.map((s) => html`<tr><td><code>${s.workflow_key}</code></td><td>${s.privacy}</td><td>${n(s.entries)}</td><td>${n(s.hits || 0)}</td></tr>`), 'the cache is empty.')}
++${canManage ? html`<h2>Purge</h2>
++<form class="stack" method="post" action="/console/cache/purge">${csrfField(csrf)}
++<label>Workflow key (exact; empty = every entry)<input type="text" name="workflow" maxlength="120" list="dl-cached" autocomplete="off" spellcheck="false"></label>
++<datalist id="dl-cached">${stats.map((s) => html`<option value="${s.workflow_key}"></option>`)}</datalist>
++<label class="check"><input type="checkbox" name="confirm_all" value="yes"> Yes, purge <strong>every</strong> entry when no workflow is given</label>
++<button class="danger" type="submit">Purge</button></form>` : html`<p class="muted">Purging needs <code>ai.provider.manage</code> (the owner's <code>staff.secrets.manage</code>).</p>`}`,
++ });
++}
++
++const TARGET_LINKS = { run: 'runs', workflow: 'workflows', template: 'templates', route: 'routes' };
++/** Audit metadata as key=value; URLs lose their query and user info here too (provider.* rows carry base_url). */
++function metaText(m) {
++ const s = Object.entries(m || {}).filter(([, v]) => v != null && v !== '')
++ .map(([k, v]) => `${k}=${/url$/i.test(k) && typeof v === 'string' ? safeUrl(v) : typeof v === 'object' ? JSON.stringify(v) : v}`).join(' ');
++ return clip(s, 400);
++}
++
++function audit({ staff, csrf, form, bad, rows, next, names, kinds }) {
++ const keep = { kind: form.kind, action: form.action, actor: form.actor, target_type: form.target_type, target_id: form.target_id };
++ const actor = (a) => (names.get(a) ? html`@${names.get(a)} <code>${a}</code>` : html`<code>${a}</code>`);
++ const tgt = (r) => {
++ if (!r.target_type) return '—';
++ const page = TARGET_LINKS[r.target_type];
++ return page && r.target_id ? html`${r.target_type} <a class="mono" href="/console/${page}/${enc(r.target_id)}">${r.target_id}</a>` : html`${r.target_type} <code>${r.target_id || ''}</code>`;
++ };
++ return layout({
++ title: 'Audit', section: 'audit', staff, csrf, error: bad.length ? `ignored malformed filter(s): ${bad.join(', ')}` : null, body: html`<h1>Audit log</h1>
++<p class="muted">Append-only. Every provider, model, route, template, workflow, quota and cache change, from the admin API (actor <code>svc:…</code>), this console (actor <code>usr_…</code>) or boot seeding (<code>seed</code>/<code>system</code>); every run create, cancel and retry; circuit changes and fallbacks; console sign-ins and refused requests.</p>
++<nav aria-label="Audit kind"><ul class="tabs">${Object.entries(kinds).map(([k, v]) => html`<li><a href="/console/audit${qs({ ...keep, kind: k })}"${form.kind === k ? html` aria-current="page"` : ''}>${v.label}</a></li>`)}</ul></nav>
++<form class="filters" method="get" action="/console/audit" role="search" aria-label="Filter the audit log"><input type="hidden" name="kind" value="${form.kind}">
++<label>Action<input type="text" name="action" value="${form.action}" placeholder="provider.update" autocomplete="off" spellcheck="false"></label>
++<label>Actor<input type="text" name="actor" value="${form.actor}" placeholder="svc:live or usr_…" autocomplete="off" spellcheck="false"></label>
++<label>Target type<input type="text" name="target_type" value="${form.target_type}" placeholder="workflow" autocomplete="off" spellcheck="false"></label>
++<label>Target id<input type="text" name="target_id" value="${form.target_id}" autocomplete="off" spellcheck="false"></label>
++<div class="actions"><button class="primary" type="submit">Filter</button><a href="/console/audit">Clear</a></div></form>
++${table('Audit rows, newest first', ['#', 'At', 'Actor', 'Action', 'Target', 'Details', 'Trace'], rows.map((r) => html`<tr><td>${r.id}</td><td class="nowrap">${when(r.at)}</td><td>${actor(r.actor)}</td>
++<td><code>${r.action}</code></td><td>${tgt(r)}</td><td class="mono muted">${metaText(r.metadata)}</td><td class="mono muted">${r.trace_id ? clip(r.trace_id, 40) : ''}</td></tr>`), 'no audit row matches.')}
++<div class="pager">${form.before ? html`<a href="/console/audit${qs(keep)}">Newest</a>` : ''}${next ? html`<a href="/console/audit${qs({ ...keep, before: next })}">Older rows</a>` : ''}</div>`,
++ });
++}
++
++module.exports = { CSP, html, esc, safeUrl, pages: { signIn, message, overview, providers, definitions, definition, runs, run, quotas, usage, cache, audit } };
ddiff --git a/server/db.js b/server/db.js
iindex 2d9668b..1391d5c 100644
---- a/server/db.js
++++ b/server/db.js
@@@ -12,7 +12,8 @@
*
* Templates, workflows and routes are append-only per key: an edit inserts version n+1 and the
* previous version stays readable, so every run can say exactly which versions produced it.
-- * import_ledger / import_holds belong to scripts/import-from-live.js.
++ * import_ledger / import_holds belong to scripts/import-from-live.js; console_sessions to the
++ * operator console (server/console/session.js).
*/
const fs = require('fs');
const path = require('path');
@@@ -281,6 +282,17 @@ CREATE TABLE IF NOT EXISTS audit_log (
);
CREATE INDEX IF NOT EXISTS idx_audit_target ON audit_log(target_type, target_id, id);
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action, id);
++CREATE TABLE IF NOT EXISTS console_sessions (
++ id_hash TEXT PRIMARY KEY, -- sha256 of the session cookie; the cookie value is never stored
++ subject TEXT NOT NULL, -- the Network person (usr_…)
++ username TEXT,
++ staff TEXT NOT NULL DEFAULT '{}', -- the sign-in token's staff claims (role, is_owner, staff_caps, staff_map)
++ csrf TEXT NOT NULL,
++ created_at TEXT NOT NULL,
++ expires_at TEXT NOT NULL,
++ revoked_at TEXT,
++ ip_hash TEXT -- keyed hash of the client address
++);
CREATE TABLE IF NOT EXISTS import_ledger (
source TEXT NOT NULL, -- live.ai_usage, live.translations, ...
source_key TEXT NOT NULL,
ddiff --git a/server/index.js b/server/index.js
iindex df376c9..a3f30ca 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -43,7 +43,7 @@ async function start({ config, clock = { now: () => Date.now() }, fetchImpl = gl
const keys = createKeyStore({ urls: [config.networkInternalUrl, config.networkUrl], pem: config.networkPublicKey, fetchImpl, log });
const auth = createAuth({ config, keys });
-- const app = createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, log });
++ const app = createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log });
const keyLoaded = keys.start().catch(() => null);
const housekeeping = setInterval(() => {
ddiff --git a/server/ops.js b/server/ops.js
nnew file mode 100644
iindex 0000000..ec1ab75
---- /dev/null
++++ b/server/ops.js
@@@ -0,0 +1,55 @@
++'use strict';
++/**
++ * Operator actions shared by the admin API (server/api/admin.js) and the operator console
++ * (server/console): one implementation of each, so both paths make the same change and write the
++ * same audit row. `who` is { actor, trace }: a service principal (svc:…) through the API, a Network
++ * person (usr_…) through the console.
++ *
++ * Changes that are already a single registry/quotas/runs call (versions, lifecycle status, models,
++ * quotas, run cancel) are called directly by both; what lives here is the few that were inline in
++ * the API routes.
++ */
++const { AiError } = require('./util');
++
++function createOps({ db, registry, pool, quotas, cache, runs }) {
++ /** A provider as every operator surface shows it: secret reference NAME + whether it resolves, health. */
++ const providerView = (p) => registry.publicProvider(p, pool.health(p.key));
++
++ /** GET /api/v1/status: providers, run counts, today's spend, definitions, queue, quota counters. */
++ function status() {
++ const counts = Object.fromEntries(db.prepare('SELECT status, COUNT(*) n FROM runs GROUP BY status').all().map(x => [x.status, x.n]));
++ const today = new Date().toISOString().slice(0, 10);
++ const spend = db.prepare('SELECT COALESCE(SUM(cost_usd),0) cost, COALESCE(SUM(requests),0) requests FROM usage_daily WHERE day = ?').get(today);
++ return {
++ providers: registry.listProviders().map(p => ({ key: p.key, kind: p.kind, status: p.status, credentials: providerView(p).credentials, health: pool.health(p.key).state })),
++ runs: counts, today: { day: today, requests: spend.requests, cost_usd: spend.cost },
++ workflows: registry.listWorkflows().length, templates: registry.listTemplates().length, routes: registry.listRoutes().length,
++ inflight: runs.inflight.size, quotas: quotas.counters(),
++ };
++ }
++
++ /** Enable or disable a provider (a registry upsert of its status; audited as provider.update). */
++ function setProviderStatus(key, status, { actor, trace = null }) {
++ if (!registry.getProvider(key)) throw new AiError(404, 'ai.not_found', 'no such provider');
++ if (!['active', 'disabled'].includes(status)) throw new AiError(422, 'ai.invalid', 'provider status must be active or disabled');
++ return registry.upsertProvider({ key, status }, { actor, trace, origin: 'admin' });
++ }
++
++ /** Close a provider's circuit (breaker reset); audited as provider.circuit_reset. */
++ function resetCircuit(key, { actor, trace = null }) {
++ pool.resetHealth(key);
++ registry.audit(actor, 'provider.circuit_reset', 'provider', key, { trace });
++ return pool.health(key);
++ }
++
++ /** Remove cache entries: one workflow's, or every entry when `workflow` is empty; audited as cache.purge. */
++ function purgeCache(workflow, { actor, trace = null }) {
++ const removed = cache.purge({ workflow: workflow || undefined });
++ registry.audit(actor, 'cache.purge', 'cache', workflow || '*', { trace, metadata: { removed } });
++ return removed;
++ }
++
++ return { providerView, status, setProviderStatus, resetCircuit, purgeCache };
++}
++
++module.exports = { createOps };
ddiff --git a/test/console.test.js b/test/console.test.js
nnew file mode 100644
iindex 0000000..643110e
---- /dev/null
++++ b/test/console.test.js
@@@ -0,0 +1,500 @@
++'use strict';
++// The operator console (WS-O task 4): Network SSO with PKCE as client `ai`; staff-only access through
++// the contracts staff map (anonymous -> sign-in page, a user or global_mod -> 403 and no session, an
++// admin reads and manages workflows, only the owner manages providers, quotas and the cache); the
++// session is re-mapped on every request; every page is server-rendered with no script, noindex,
++// no-store, a strict CSP, captioned tables and labelled controls, and carries no secret value; every
++// write needs the CSRF token and a same-origin request and writes the same audit row as the admin API
++// (actor = the person); the failed-runs filters; the run detail page's bounded input; robots.txt;
++// the nginx vhost; and 503 in production without the session secret.
++
++const assert = require('assert');
++const crypto = require('crypto');
++const fs = require('fs');
++const path = require('path');
++const { serviceAuth, ids } = require('openvibe-contracts');
++const { boot, request, token, suite, seamServer, privateKey, ISSUER, ALL } = require('./helpers');
++const { sanitizeNext, consoleCapabilities } = require('../server/console');
++const { preview } = require('../server/console/queries');
++
++const t = suite('console');
++const NETWORK = 'http://network.test';
++// Generated per run: nothing that looks like a credential is written into the repository.
++const PROVIDER_SECRET = `sentinel-provider-${crypto.randomBytes(12).toString('hex')}`;
++const CLIENT_SECRET = `sentinel-client-${crypto.randomBytes(12).toString('hex')}`;
++const SESSION_SECRET = crypto.randomBytes(32).toString('hex');
++const SECRETS = [PROVIDER_SECRET, CLIENT_SECRET, SESSION_SECRET];
++const ops = token('ops', ALL);
++const live = token('live', ['ai.run.create', 'ai.run.read']);
++const news = token('news', ['ai.run.create', 'ai.run.read']);
++
++// ── A fake Network: /oauth/token redeems PKCE-bound codes for user access tokens, /oauth/revoke ──
++const codes = new Map();
++const grants = [];
++const revoked = [];
++function authorize({ subject_id, role = 'user', is_owner = false, username = 'someone', staff_caps, challenge, redirect_uri }) {
++ const code = crypto.randomBytes(16).toString('hex');
++ codes.set(code, { subject_id, role, is_owner, username, staff_caps, challenge, redirect_uri });
++ return code;
++}
++function userToken(u) {
++ const now = Math.floor(Date.now() / 1000);
++ return serviceAuth.signServiceToken({
++ sub: 42, id: 42, ...(u.subject_id ? { subject_id: u.subject_id } : {}), username: u.username, role: u.role, ...(u.is_owner ? { is_owner: true } : {}),
++ ...(u.staff_caps ? { staff_caps: u.staff_caps, staff_map: '1.1.0' } : {}),
++ iss: ISSUER, aud: ['openvibe.live', 'openvibe.network'], iat: now, exp: now + 3600,
++ }, privateKey);
++}
++const json = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
++async function fakeFetch(url, opts = {}) {
++ const u = String(url);
++ if (u === `${NETWORK}/oauth/token`) {
++ const body = Object.fromEntries(new URLSearchParams(String(opts.body)));
++ grants.push(body);
++ if (body.client_id !== 'ai' || body.client_secret !== CLIENT_SECRET) return json(401, { error: 'invalid_client' });
++ const c = codes.get(body.code);
++ if (!c) return json(400, { error: 'invalid_grant' });
++ codes.delete(body.code);
++ if (c.redirect_uri !== body.redirect_uri) return json(400, { error: 'invalid_grant', error_description: 'Redirect URI mismatch' });
++ if (crypto.createHash('sha256').update(String(body.code_verifier || '')).digest('base64url') !== c.challenge) return json(400, { error: 'invalid_grant', error_description: 'PKCE verification failed' });
++ return json(200, { access_token: userToken(c), refresh_token: `rt_${crypto.randomBytes(6).toString('hex')}`, token_type: 'Bearer', expires_in: 3600 });
++ }
++ if (u === `${NETWORK}/oauth/revoke`) { revoked.push(JSON.parse(String(opts.body)).token); return json(200, { revoked: true }); }
++ return globalThis.fetch(url, opts);
++}
++
++let h;
++let seam;
++let slow;
++const people = { user: ids.newId('user'), mod: ids.newId('user'), admin: ids.newId('user'), owner: ids.newId('user') };
++const cookies = {};
++const csrf = {};
++const runIds = {};
++
++const setCookies = (res) => (res.headers.getSetCookie ? res.headers.getSetCookie() : []);
++async function get(p, cookie, headers = {}) {
++ const res = await fetch(h.base + p, { redirect: 'manual', headers: { ...(cookie ? { Cookie: cookie } : {}), ...headers } });
++ return { status: res.status, headers: res.headers, text: await res.text(), cookies: setCookies(res) };
++}
++async function post(p, cookie, form, headers = {}) {
++ const res = await fetch(h.base + p, {
++ method: 'POST', redirect: 'manual',
++ headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...(cookie ? { Cookie: cookie } : {}), ...headers },
++ body: new URLSearchParams(form).toString(),
++ });
++ return { status: res.status, headers: res.headers, text: await res.text(), cookies: setCookies(res) };
++}
++async function signIn(subject, { role = 'admin', is_owner = false, username = 'staffer', staff_caps, next = '/console' } = {}) {
++ const login = await get(`/auth/login?next=${encodeURIComponent(next)}`);
++ assert.strictEqual(login.status, 302);
++ const loc = new URL(login.headers.get('location'));
++ const flow = login.cookies.find((c) => c.startsWith('ovai_flow='));
++ const code = authorize({ subject_id: subject, role, is_owner, username, staff_caps, challenge: loc.searchParams.get('code_challenge'), redirect_uri: loc.searchParams.get('redirect_uri') });
++ const cb = await get(`/auth/callback?code=${code}&state=${encodeURIComponent(loc.searchParams.get('state'))}`, flow.split(';')[0]);
++ const sess = cb.cookies.find((c) => c.startsWith('__Host-ovai_staff=') && !c.startsWith('__Host-ovai_staff=;'));
++ return { cb, setCookie: sess || null, cookie: sess ? sess.split(';')[0] : null };
++}
++const csrfOf = (page) => (page.text.match(/name="_csrf" value="([^"]+)"/) || [])[1];
++const auditRows = (where, ...args) => h.db.prepare(`SELECT * FROM audit_log WHERE ${where} ORDER BY id`).all(...args);
++const sessionCount = () => h.db.prepare('SELECT COUNT(*) AS n FROM console_sessions').get().n;
++
++/** Controls that are neither hidden nor inside a <label>. */
++function unlabelled(text) {
++ const out = [];
++ const re = /<(input|select|textarea)\b[^>]*>/g;
++ let m;
++ while ((m = re.exec(text))) {
++ if (/type="(hidden|submit)"/.test(m[0])) continue;
++ const before = text.slice(0, m.index);
++ if (before.lastIndexOf('<label') === -1 || before.lastIndexOf('</label>') > before.lastIndexOf('<label')) out.push(m[0]);
++ }
++ return out;
++}
++function assertPage(r, p) {
++ assert.strictEqual(r.status, 200, `${p}: ${r.status} ${r.text.slice(0, 300)}`);
++ assert.match(r.headers.get('content-type'), /text\/html/, p);
++ assert.ok(!/<script/i.test(r.text), `${p}: no script`);
++ assert.ok(!/ style="/.test(r.text), `${p}: no inline style attribute (the CSP would block it)`);
++ const csp = r.headers.get('content-security-policy') || '';
++ assert.ok(csp.includes("default-src 'none'") && csp.includes("form-action 'self'") && csp.includes("frame-ancestors 'none'") && !csp.includes('unsafe-inline'), `${p}: CSP ${csp}`);
++ assert.strictEqual(r.headers.get('x-robots-tag'), 'noindex, nofollow', p);
++ assert.ok(r.text.includes('<meta name="robots" content="noindex,nofollow">'), `${p}: meta robots`);
++ assert.match(r.headers.get('cache-control') || '', /no-store/, p);
++ assert.strictEqual((r.text.match(/<h1[ >]/g) || []).length, 1, `${p}: one h1`);
++ assert.strictEqual((r.text.match(/<table/g) || []).length, (r.text.match(/<caption>/g) || []).length, `${p}: every table has a caption`);
++ assert.deepStrictEqual(unlabelled(r.text), [], `${p}: every control is labelled`);
++ for (const s of SECRETS) assert.ok(!r.text.includes(s), `${p}: a secret value leaked`);
++ assert.ok(!r.text.includes('[redacted]'), `${p}: the page itself rendered a secret (the last-resort scrubber caught it)`);
++}
++
++t.test('boot with a provider secret, a seam provider, a failing stub and a few runs', async () => {
++ seam = await seamServer(() => ({ text: 'a real answer', model: 'seam-model', usage: { input: 12, output: 4 } }));
++ slow = await seamServer(() => new Promise((r) => setTimeout(() => r({ text: 'late' }), 3000)));
++ h = await boot({
++ env: {
++ BASE_URL: 'https://ai.test', OV_NETWORK_INTERNAL_URL: NETWORK, OV_OAUTH_CLIENT_SECRET: CLIENT_SECRET, AI_CONSOLE_SESSION_SECRET: SESSION_SECRET,
++ AI_USER_MODULES: 'off', AI_API_KEY: PROVIDER_SECRET, AI_PROVIDER: 'openai', AI_BASE_URL: 'http://127.0.0.1:1/v1', AI_STUB_FALLBACK: 'false',
++ },
++ fetchImpl: fakeFetch,
++ });
++ const provider = async (body) => { const r = await request(h.base, 'POST', '/api/v1/providers', { tok: ops, body: { auth_mode: 'none', capabilities: ['chat', 'generate', 'summarize', 'json'], timeout_ms: 5000, ...body } }); assert.strictEqual(r.status, 201, r.text); };
++ await provider({ key: 'badstub', kind: 'stub', metadata: { fail: 'error' } });
++ await provider({ key: 'okseam', kind: 'http', base_url: `${seam.url}/complete?token=xxxxxxxxxxxx` });
++ await provider({ key: 'slowseam', kind: 'http', base_url: slow.url });
++ const route = async (p) => assert.strictEqual((await request(h.base, 'POST', '/api/v1/routes/default.chat/versions', { tok: ops, body: { primary: { provider: p }, fallbacks: [] } })).status, 201);
++ const gen = async (tok, prompt, extra = {}) => (await request(h.base, 'POST', '/api/v1/runs?wait=5000', { tok, body: { workflow: 'ai.generate', input: { prompt, ...extra } } })).body.run;
++ await route('badstub');
++ const f1 = await gen(live, 'this will fail');
++ const f2 = await gen(news, 'this will fail too');
++ assert.strictEqual(f1.status, 'failed');
++ assert.strictEqual(f2.status, 'failed');
++ runIds.failLive = f1.id;
++ runIds.failNews = f2.id;
++ runIds.code = f1.error.code;
++ await route('okseam');
++ const ok = await gen(live, `${'long prompt text '.repeat(40)}END-OF-PROMPT`);
++ assert.strictEqual(ok.status, 'succeeded', JSON.stringify(ok.error));
++ runIds.ok = ok.id;
++ assert.ok(h.cache.stats().length >= 1, 'a real answer was cached');
++});
++
++t.test('the staff map: admin reads and manages workflows, only the owner manages providers, a global_mod gets nothing', () => {
++ assert.deepStrictEqual(consoleCapabilities({ role: 'admin' }), ['ai.usage.read', 'ai.workflow.manage']);
++ assert.deepStrictEqual(consoleCapabilities({ role: 'admin', is_owner: true }), ['ai.usage.read', 'ai.workflow.manage', 'ai.provider.manage']);
++ assert.deepStrictEqual(consoleCapabilities({ role: 'global_mod' }), []);
++ assert.deepStrictEqual(consoleCapabilities({ role: 'user' }), []);
++ assert.deepStrictEqual(consoleCapabilities({ role: 'owner' }), [], 'owner is never a stored role claim');
++ assert.deepStrictEqual(consoleCapabilities({ role: 'admin', staff_caps: ['staff.site.view'], staff_map: '1.1.0' }), ['ai.usage.read'], 'issued staff capabilities win over the role');
++});
++
++t.test('anonymous: console pages answer the sign-in page (401), never data; writes change nothing', async () => {
++ for (const p of ['/console', '/console/runs?status=failed', '/console/providers', '/console/audit', `/console/runs/${runIds.failLive}`, '/console/nope']) {
++ const r = await get(p);
++ assert.strictEqual(r.status, 401, p);
++ assert.ok(r.text.includes('Sign in with OpenVibe.Network'), p);
++ assert.ok(!r.text.includes('<table'), `${p}: no data`);
++ assert.strictEqual(r.headers.get('x-robots-tag'), 'noindex, nofollow');
++ }
++ assert.ok((await get('/console/runs?status=failed')).text.includes(`href="/auth/login?next=${encodeURIComponent('/console/runs?status=failed')}"`));
++ const before = h.quotas.list().length;
++ assert.strictEqual((await post('/console/quotas', null, { scope_type: 'service', scope_id: 'live', window: 'day', max_requests: '1' })).status, 401);
++ assert.strictEqual((await post('/console/providers/okseam/status', null, { status: 'disabled' })).status, 401);
++ assert.strictEqual(h.quotas.list().length, before);
++ assert.strictEqual(h.registry.getProvider('okseam').status, 'active');
++});
++
++t.test('sign-in redirects to the Network with PKCE S256 as client ai, with a signed host-only flow cookie', async () => {
++ const login = await get('/auth/login?next=/console/runs');
++ assert.strictEqual(login.status, 302);
++ const loc = new URL(login.headers.get('location'));
++ assert.strictEqual(loc.origin + loc.pathname, 'https://openvibe.network/oauth/authorize');
++ assert.strictEqual(loc.searchParams.get('client_id'), 'ai');
++ assert.strictEqual(loc.searchParams.get('redirect_uri'), 'https://ai.test/auth/callback');
++ assert.strictEqual(loc.searchParams.get('response_type'), 'code');
++ assert.strictEqual(loc.searchParams.get('code_challenge_method'), 'S256');
++ assert.match(loc.searchParams.get('code_challenge'), /^[A-Za-z0-9_-]{43}$/);
++ assert.ok(loc.searchParams.get('state').length >= 32);
++ const flow = login.cookies.find((c) => c.startsWith('ovai_flow='));
++ for (const attr of ['HttpOnly', 'Secure', 'Path=/auth', 'SameSite=Lax']) assert.ok(flow.includes(attr), `flow cookie ${attr}: ${flow}`);
++ assert.ok(!/domain=/i.test(flow));
++ assert.strictEqual(login.headers.get('x-robots-tag'), 'noindex, nofollow');
++});
++
++t.test('the callback refuses a missing or mismatched state, a forged flow cookie and a code bound to another challenge', async () => {
++ const login = await get('/auth/login');
++ const loc = new URL(login.headers.get('location'));
++ const flow = login.cookies.find((c) => c.startsWith('ovai_flow=')).split(';')[0];
++ const redirect = loc.searchParams.get('redirect_uri');
++ let code = authorize({ subject_id: people.admin, role: 'admin', challenge: loc.searchParams.get('code_challenge'), redirect_uri: redirect });
++ assert.strictEqual((await get(`/auth/callback?code=${code}&state=${loc.searchParams.get('state')}`)).status, 400, 'no flow cookie');
++ assert.strictEqual((await get(`/auth/callback?code=${code}&state=wrong`, flow)).status, 400, 'wrong state');
++ const [body] = flow.slice('ovai_flow='.length).split('.');
++ assert.strictEqual((await get(`/auth/callback?code=${code}&state=${loc.searchParams.get('state')}`, `ovai_flow=${body}.forged`)).status, 400, 'forged signature');
++ code = authorize({ subject_id: people.admin, role: 'admin', challenge: 'A'.repeat(43), redirect_uri: redirect });
++ assert.strictEqual((await get(`/auth/callback?code=${code}&state=${encodeURIComponent(loc.searchParams.get('state'))}`, flow)).status, 400, 'PKCE mismatch');
++ assert.strictEqual(sessionCount(), 0);
++});
++
++t.test('non-staff get 403 and no session: a user, a global_mod, a token without a subject; refusals are audited', async () => {
++ for (const [subject, role] of [[people.user, 'user'], [people.mod, 'global_mod'], [null, 'admin']]) {
++ const r = await signIn(subject, { role });
++ assert.strictEqual(r.cb.status, 403, `${role} ${subject}`);
++ assert.strictEqual(r.cookie, null);
++ assert.ok(r.cb.text.includes('only for OpenVibe.Network staff'));
++ }
++ assert.strictEqual(sessionCount(), 0);
++ const refused = auditRows("action = 'console.sign_in.refused'");
++ assert.deepStrictEqual(refused.map((x) => x.actor).sort(), ['anonymous', people.mod, people.user].sort());
++ assert.ok(refused.every((x) => /^[0-9a-f]{32}$/.test(JSON.parse(x.metadata).ip_hash)), 'the client address is kept as a keyed hash');
++ assert.ok(revoked.length >= 3, 'the Network refresh token is revoked straight away');
++});
++
++t.test('an admin signs in: host-only, HttpOnly, Secure, SameSite=Strict, short-lived session; back to the page asked for', async () => {
++ const r = await signIn(people.admin, { username: 'goosely', next: '/console/runs?status=failed' });
++ assert.strictEqual(r.cb.status, 303);
++ assert.strictEqual(r.cb.headers.get('location'), '/console/runs?status=failed');
++ for (const attr of ['Path=/', 'HttpOnly', 'Secure', 'SameSite=Strict']) assert.ok(r.setCookie.includes(attr), `${attr}: ${r.setCookie}`);
++ assert.ok(!/domain=/i.test(r.setCookie), 'host-only');
++ const maxAge = Number((r.setCookie.match(/Max-Age=(\d+)/) || [])[1]);
++ assert.ok(maxAge > 0 && maxAge <= 3600, `max-age ${maxAge}`);
++ cookies.admin = r.cookie;
++ const row = h.db.prepare('SELECT * FROM console_sessions WHERE subject = ?').get(people.admin);
++ assert.notStrictEqual(row.id_hash, r.cookie.split('=')[1], 'only a hash of the session id is stored');
++ assert.strictEqual(JSON.parse(row.staff).role, 'admin');
++ assert.strictEqual(auditRows("action = 'console.sign_in' AND actor = ?", people.admin).length, 1);
++ const g = grants.filter((x) => x.grant_type === 'authorization_code').pop();
++ assert.strictEqual(g.client_id, 'ai');
++ assert.strictEqual(g.redirect_uri, 'https://ai.test/auth/callback');
++});
++
++t.test('next is always a console page: open-redirect shapes go to /console', async () => {
++ for (const bad of ['//evil.com', '/\\evil.com', '/\t/evil.com', '/\n/evil.com', 'https://evil.com', '/auth/login', '/api/v1/runs', '/consolex', `/console/${'a'.repeat(400)}`, '', null]) {
++ assert.strictEqual(sanitizeNext(bad), '/console', JSON.stringify(bad));
++ }
++ for (const good of ['/console', '/console/runs?status=failed', '/console/workflows/ai.generate']) assert.strictEqual(sanitizeNext(good), good);
++ const r = await signIn(people.admin, { next: '//evil.com' });
++ assert.strictEqual(r.cb.headers.get('location'), '/console');
++});
++
++t.test('every page: server-rendered, no script, noindex, no-store, strict CSP, captions and labels, no secret value', async () => {
++ const list = ['/console', '/console/providers', '/console/routes', '/console/routes/default.chat', '/console/routes/default.chat?version=1', '/console/templates',
++ '/console/templates/ai.generate', '/console/workflows', '/console/workflows/ai.generate', '/console/runs', '/console/runs?status=failed',
++ `/console/runs/${runIds.failLive}`, `/console/runs/${runIds.ok}`, '/console/quotas', '/console/usage', '/console/cache', '/console/audit', '/console/audit?kind=all'];
++ for (const p of list) assertPage(await get(p, cookies.admin), p);
++ const prov = await get('/console/providers', cookies.admin);
++ assert.ok(prov.text.includes('<code>env:AI_API_KEY</code>'), 'the secret reference name is shown');
++ assert.ok(prov.text.includes(`${seam.url}/complete`) && !prov.text.includes('token='), 'base URLs lose their query string');
++ assert.ok(!prov.text.includes('action="/console/providers/okseam/status"'), 'an admin gets no provider buttons');
++ assert.ok(prov.text.includes('need <code>ai.provider.manage</code> (the owner'));
++ const wf = await get('/console/workflows/ai.generate', cookies.admin);
++ assert.ok(wf.text.includes('/versions/1/status"'), 'an admin can change workflow version status');
++ assert.strictEqual((await get('/console/runs/run_00000000000000000000000000', cookies.admin)).status, 404);
++ assert.strictEqual((await get('/console/nope', cookies.admin)).status, 404);
++});
++
++t.test('an admin cannot use owner-only actions (ai.provider.manage): 403, audited, nothing changes', async () => {
++ const page = await get('/console/providers', cookies.admin);
++ csrf.admin = csrfOf(page);
++ assert.ok(csrf.admin, 'the sign-out form carries the CSRF token');
++ for (const [p, form] of [['/console/providers/okseam/status', { status: 'disabled' }], ['/console/providers/okseam/reset', {}], ['/console/quotas', { scope_type: 'service', scope_id: 'live', window: 'day', max_requests: '5' }], ['/console/cache/purge', { workflow: 'ai.generate' }]]) {
++ const r = await post(p, cookies.admin, { ...form, _csrf: csrf.admin });
++ assert.strictEqual(r.status, 403, p);
++ assert.ok(r.text.includes('This needs ai.provider.manage'), p);
++ }
++ assert.strictEqual(h.registry.getProvider('okseam').status, 'active');
++ assert.ok(h.cache.stats().length >= 1);
++ assert.strictEqual(auditRows("action = 'console.request.refused' AND actor = ?", people.admin).length, 4);
++});
++
++t.test('CSRF: a write needs the session token and a same-origin request', async () => {
++ const draft = await request(h.base, 'POST', '/api/v1/workflows/ai.generate/versions', { tok: ops, body: { status: 'draft' } });
++ assert.strictEqual(draft.status, 201, draft.text);
++ const v = draft.body.workflow.version;
++ const p = `/console/workflows/ai.generate/versions/${v}/status`;
++ const refusedBefore = auditRows("action = 'console.request.refused'").length;
++ for (const [form, headers, why] of [
++ [{ status: 'archived' }, {}, 'no token'],
++ [{ status: 'archived', _csrf: 'x'.repeat(43) }, {}, 'wrong token'],
++ [{ status: 'archived', _csrf: csrf.admin }, { 'Sec-Fetch-Site': 'cross-site' }, 'cross-site'],
++ [{ status: 'archived', _csrf: csrf.admin }, { Origin: 'https://evil.test' }, 'foreign origin'],
++ ]) {
++ const r = await post(p, cookies.admin, form, headers);
++ assert.strictEqual(r.status, 403, why);
++ assert.strictEqual(h.registry.getWorkflow('ai.generate', v).status, 'draft', why);
++ }
++ assert.strictEqual(auditRows("action = 'console.request.refused'").length, refusedBefore + 4);
++ const ok = await post(p, cookies.admin, { status: 'archived', _csrf: csrf.admin }, { Origin: 'https://ai.test', 'Sec-Fetch-Site': 'same-origin' });
++ assert.strictEqual(ok.status, 303, ok.text);
++ assert.strictEqual(ok.headers.get('location'), `/console/workflows/ai.generate?version=${v}&done=version_status`);
++ assert.strictEqual(h.registry.getWorkflow('ai.generate', v).status, 'archived');
++ const row = auditRows("action = 'workflow.status' AND target_id = 'ai.generate'").pop();
++ assert.strictEqual(row.actor, people.admin);
++ assert.deepStrictEqual(JSON.parse(row.metadata), { version: v, from: 'draft', to: 'archived' });
++ const bad = await post(p, cookies.admin, { status: 'published', _csrf: csrf.admin });
++ assert.strictEqual(bad.status, 422, 'an unknown status is refused by registry.setStatus');
++});
++
++t.test('owner: providers, circuits, quotas and cache purge through the console, each audited with the person as actor', async () => {
++ const r = await signIn(people.owner, { is_owner: true, username: 'owner' });
++ assert.strictEqual(r.cb.status, 303);
++ cookies.owner = r.cookie;
++ const page = await get('/console/providers', cookies.owner);
++ assertPage(page, '/console/providers (owner)');
++ assert.ok(page.text.includes('action="/console/providers/okseam/status"'));
++ csrf.owner = csrfOf(page);
++ let x = await post('/console/providers/badstub/status', cookies.owner, { status: 'disabled', _csrf: csrf.owner });
++ assert.strictEqual(x.status, 303);
++ assert.strictEqual(h.registry.getProvider('badstub').status, 'disabled');
++ assert.strictEqual(auditRows("action = 'provider.update' AND target_id = 'badstub' AND actor = ?", people.owner).length, 1);
++ x = await post('/console/providers/badstub/reset', cookies.owner, { _csrf: csrf.owner });
++ assert.strictEqual(x.status, 303);
++ assert.strictEqual(auditRows("action = 'provider.circuit_reset' AND target_id = 'badstub' AND actor = ?", people.owner).length, 1);
++ assert.strictEqual((await post('/console/providers/nope/reset', cookies.owner, { _csrf: csrf.owner })).status, 404);
++
++ x = await post('/console/quotas', cookies.owner, { scope_type: 'service', scope_id: 'live', window: 'fortnight', max_requests: '5', _csrf: csrf.owner });
++ assert.strictEqual(x.status, 422, 'quotas.upsert refuses an unknown window');
++ assert.ok(x.text.includes('window must be minute, hour or day'));
++ x = await post('/console/quotas', cookies.owner, { scope_type: 'service', scope_id: 'live', window: 'day', max_requests: '500', max_tokens: '', max_cost_usd: '2.5', _csrf: csrf.owner });
++ assert.strictEqual(x.status, 303);
++ const q = h.quotas.list().find((z) => z.scope_type === 'service' && z.scope_id === 'live' && z.window === 'day');
++ assert.strictEqual(q.max_requests, 500);
++ assert.strictEqual(q.max_tokens, null);
++ assert.strictEqual(q.max_cost_usd, 2.5);
++ assert.strictEqual(auditRows("action = 'quota.create' AND actor = ?", people.owner).length, 1);
++
++ x = await post('/console/cache/purge', cookies.owner, { _csrf: csrf.owner });
++ assert.strictEqual(x.status, 422, 'purging everything needs the confirmation box');
++ assert.ok(h.cache.stats().length >= 1);
++ x = await post('/console/cache/purge', cookies.owner, { workflow: 'ai.generate', _csrf: csrf.owner });
++ assert.strictEqual(x.status, 303);
++ assert.match(x.headers.get('location'), /done=cache_purged&removed=1$/);
++ assert.strictEqual(h.cache.stats().length, 0);
++ const purge = auditRows("action = 'cache.purge' AND actor = ?", people.owner).pop();
++ assert.strictEqual(purge.target_id, 'ai.generate');
++ assert.strictEqual(JSON.parse(purge.metadata).removed, 1);
++});
++
++t.test('admin API changes write the same audit rows, and the audit page shows who changed what', async () => {
++ assert.strictEqual((await request(h.base, 'POST', '/api/v1/providers/badstub/enable', { tok: ops })).status, 200);
++ assert.strictEqual((await request(h.base, 'POST', '/api/v1/quotas', { tok: ops, body: { scope_type: 'global', window: 'hour', max_cost_usd: 10 } })).status, 201);
++ assert.strictEqual((await request(h.base, 'POST', '/api/v1/providers/badstub/reset', { tok: ops })).status, 200);
++ assert.strictEqual((await request(h.base, 'DELETE', '/api/v1/cache?workflow=ai.generate', { tok: ops })).status, 200);
++ for (const action of ['provider.update', 'quota.create', 'provider.circuit_reset', 'cache.purge', 'route.version', 'workflow.version']) {
++ assert.ok(auditRows('action = ? AND actor = ?', action, 'svc:ops').length >= 1, `${action} by svc:ops`);
++ }
++ const a = await get('/console/audit', cookies.admin);
++ assertPage(a, '/console/audit');
++ assert.ok(a.text.includes('<code>svc:ops</code>'), 'API changes are listed');
++ assert.ok(a.text.includes(`@owner <code>${people.owner}</code>`), 'console changes name the person');
++ assert.ok(a.text.includes('<code>quota.create</code>') && a.text.includes('<code>provider.circuit_reset</code>') && a.text.includes('<code>workflow.status</code>'));
++ assert.ok(!a.text.includes('<code>run.create</code>'), 'runs are not configuration changes');
++ assert.ok(a.text.includes(`base_url=${seam.url}/complete`) && !a.text.includes('token='), 'audited base URLs lose their query string too');
++ const byActor = await get(`/console/audit?actor=${people.owner}&action=provider.update`, cookies.admin);
++ assert.ok(byActor.text.includes('badstub'));
++ assert.ok(!byActor.text.includes('<code>svc:ops</code>'));
++ const runsKind = await get('/console/audit?kind=runs', cookies.admin);
++ assert.ok(runsKind.text.includes('<code>run.create</code>'));
++ const consoleKind = await get('/console/audit?kind=console', cookies.admin);
++ assert.ok(consoleKind.text.includes('<code>console.sign_in.refused</code>') && consoleKind.text.includes('<code>console.request.refused</code>'));
++ assert.strictEqual((await get('/console/audit?before=x', cookies.admin)).status, 400);
++});
++
++t.test('failed-runs filters: status, requester, workflow, error code and time', async () => {
++ let r = await get('/console/runs?status=failed', cookies.admin);
++ assert.ok(r.text.includes(runIds.failLive) && r.text.includes(runIds.failNews), 'both failed runs');
++ assert.ok(!r.text.includes(runIds.ok), 'not the succeeded one');
++ assert.ok(r.text.includes('Failed runs in the last 24 hours, by error code') && r.text.includes(`<code>${runIds.code}</code>`));
++ r = await get('/console/runs?status=failed&requester=news', cookies.admin);
++ assert.ok(r.text.includes(runIds.failNews) && !r.text.includes(runIds.failLive), 'a bare service name means service:<name>');
++ r = await get('/console/runs?status=failed&requester=service:live&workflow=ai.generate', cookies.admin);
++ assert.ok(r.text.includes(runIds.failLive) && !r.text.includes(runIds.failNews));
++ r = await get(`/console/runs?code=${runIds.code}`, cookies.admin);
++ assert.ok(r.text.includes(runIds.failLive) && r.text.includes(runIds.failNews) && !r.text.includes(runIds.ok));
++ const tomorrow = new Date(Date.now() + 86400000).toISOString().slice(0, 16);
++ r = await get(`/console/runs?status=failed&from=${tomorrow}`, cookies.admin);
++ assert.ok(r.text.includes('no run matches') && !r.text.includes(runIds.failLive));
++ const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
++ r = await get(`/console/runs?status=failed&from=${yesterday}&to=${new Date().toISOString().slice(0, 10)}`, cookies.admin);
++ assert.ok(r.text.includes(runIds.failLive), 'a day range includes the whole last day');
++ r = await get('/console/runs?status=bogus&requester=%3Cscript%3E', cookies.admin);
++ assert.strictEqual(r.status, 400);
++ assert.ok(r.text.includes('ignored malformed filter(s): status, requester'));
++ assert.ok(!r.text.includes('<script>'), 'what was typed is escaped');
++});
++
++t.test('run detail: error code, attempts, provider, request log; input bounded and credential keys hidden', async () => {
++ let r = await get(`/console/runs/${runIds.failLive}`, cookies.admin);
++ assert.ok(r.text.includes(`<code class="bad">${runIds.code}</code>`));
++ assert.ok(r.text.includes('<code>badstub</code>'), 'the request log names the provider tried');
++ assert.ok(r.text.includes('Provider requests'));
++ r = await get(`/console/runs/${runIds.ok}`, cookies.admin);
++ assert.ok(r.text.includes('<code>okseam</code> / <code>seam-model</code>'));
++ assert.ok(r.text.includes('more characters)'), 'long strings are cut');
++ assert.ok(!r.text.includes('END-OF-PROMPT'), 'the whole prompt is not shown');
++ const p = preview({ record: { api_key: 'zzzzzzzzzzzzzzzz', accessToken: 'x', token: 'y', title: 'kept', list: Array.from({ length: 30 }, (_, i) => i) } });
++ assert.ok(p.cut);
++ assert.ok(!p.text.includes('zzzzzzzzzzzzzzzz') && !p.text.includes('"x"') && !p.text.includes('"y"'), 'credential-named keys are blanked');
++ assert.ok(p.text.includes('"api_key": "[not shown]"') && p.text.includes('"title": "kept"') && p.text.includes('(10 more items)'));
++ assert.strictEqual(preview(null).text, null);
++ assert.ok(r.text.includes('a real answer'), 'the output is shown');
++ assert.ok(!/debug_(prompt|response)/.test(r.text));
++ assert.ok(r.text.includes('Grounding'));
++});
++
++t.test('cancel: an admin cancels a running run (ai.workflow.manage), audited with the person as actor', async () => {
++ assert.strictEqual((await request(h.base, 'POST', '/api/v1/routes/default.chat/versions', { tok: ops, body: { primary: { provider: 'slowseam' }, fallbacks: [] } })).status, 201);
++ const c = await request(h.base, 'POST', '/api/v1/runs', { tok: live, body: { workflow: 'ai.generate', input: { prompt: 'slow one' }, options: { cache: false } } });
++ assert.strictEqual(c.status, 202);
++ const id = c.body.run.id;
++ const page = await get(`/console/runs/${id}`, cookies.admin);
++ assert.ok(page.text.includes(`action="/console/runs/${id}/cancel"`));
++ const x = await post(`/console/runs/${id}/cancel`, cookies.admin, { _csrf: csrf.admin });
++ assert.strictEqual(x.status, 303);
++ assert.strictEqual(h.runs.get(id).status, 'cancelled');
++ assert.strictEqual(auditRows("action = 'run.cancel' AND target_id = ? AND actor = ?", id, people.admin).length, 1);
++ const again = await post(`/console/runs/${id}/cancel`, cookies.admin, { _csrf: csrf.admin });
++ assert.strictEqual(again.status, 409, 'a finished run cannot be cancelled');
++ await request(h.base, 'POST', '/api/v1/routes/default.chat/versions', { tok: ops, body: { primary: { provider: 'okseam' }, fallbacks: [] } });
++});
++
++t.test('the staff mapping is applied on every request: a session whose claims no longer map is ended', async () => {
++ const r = await signIn(people.mod, { role: 'admin', username: 'demoted' });
++ assert.strictEqual(r.cb.status, 303);
++ assert.strictEqual((await get('/console', r.cookie)).status, 200);
++ h.db.prepare("UPDATE console_sessions SET staff = ? WHERE subject = ? AND revoked_at IS NULL").run(JSON.stringify({ role: 'global_mod' }), people.mod);
++ const x = await get('/console', r.cookie);
++ assert.strictEqual(x.status, 403);
++ assert.ok(x.cookies.some((c) => c.startsWith('__Host-ovai_staff=;')), 'the cookie is cleared');
++ assert.strictEqual((await get('/console', r.cookie)).status, 401, 'and the session is gone');
++});
++
++t.test('sign out revokes the session', async () => {
++ const x = await post('/auth/logout', cookies.admin, { _csrf: csrf.admin });
++ assert.strictEqual(x.status, 303);
++ assert.ok(x.cookies.some((c) => c.startsWith('__Host-ovai_staff=;')));
++ assert.strictEqual((await get('/console', cookies.admin)).status, 401);
++ assert.strictEqual(auditRows("action = 'console.sign_out' AND actor = ?", people.admin).length, 1);
++ for (const s of SECRETS) assert.ok(!JSON.stringify(h.db.prepare('SELECT * FROM audit_log').all()).includes(s), 'no secret in the audit log');
++});
++
++t.test('robots.txt keeps crawlers out of the console and sign-in; there is no sitemap', async () => {
++ const r = await request(h.base, 'GET', '/robots.txt');
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('Disallow: /console') && r.text.includes('Disallow: /auth/'));
++ assert.ok(!/sitemap/i.test(r.text));
++ assert.strictEqual((await request(h.base, 'GET', '/sitemap.xml')).status, 404);
++ assert.ok((await request(h.base, 'GET', '/')).text.includes('/console'));
++});
++
++t.test('the nginx vhost proxies /console, /auth/ and /robots.txt with the client address from $remote_addr only', () => {
++ const conf = fs.readFileSync(path.join(__dirname, '..', 'deploy', 'nginx', 'ai.openvibe.network.conf'), 'utf8');
++ const active = conf.split('\n').filter((l) => !/^\s*#/.test(l)).join('\n');
++ for (const loc of ['location /console', 'location /auth/', 'location = /robots.txt']) {
++ const i = active.indexOf(`${loc} {`);
++ assert.ok(i >= 0, `${loc} is proxied`);
++ const block = active.slice(i, active.indexOf('}', i));
++ assert.ok(block.includes('proxy_pass http://127.0.0.1:4700;'), loc);
++ for (const hdr of ['X-Real-IP $remote_addr', 'X-Forwarded-For $remote_addr', 'CF-Connecting-IP $remote_addr']) assert.ok(block.includes(hdr), `${loc}: ${hdr}`);
++ }
++ assert.ok(!active.includes('$proxy_add_x_forwarded_for'));
++});
++
++t.test('production without AI_CONSOLE_SESSION_SECRET: the console answers 503 and the API is unaffected', async () => {
++ const p = await boot({ env: { NODE_ENV: 'production', BASE_URL: 'https://ai.test', OV_NETWORK_INTERNAL_URL: NETWORK, OV_OAUTH_CLIENT_SECRET: CLIENT_SECRET, AI_USER_MODULES: 'off' }, fetchImpl: fakeFetch });
++ try {
++ for (const x of ['/console', '/auth/login']) {
++ const r = await fetch(p.base + x, { redirect: 'manual' });
++ assert.strictEqual(r.status, 503, x);
++ assert.ok((await r.text()).includes('Console unavailable'));
++ }
++ assert.strictEqual((await fetch(`${p.base}/api/health`)).status, 200);
++ } finally { await p.stop(); }
++});
++
++t.test('shutdown', async () => {
++ await h.stop();
++ await seam.close();
++ await slow.close();
++});
++
++t.run();