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
Changes
@@@ -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@@@ -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 Networkkey 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 tokensnothing. `AI_NS_FALLBACK=none` removes the fallback; `AI_NS_REQUIRED=false` is a rollback lever tothe 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) providersecrets; 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 Livetranslations 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@@@ -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;@@@ -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 = consconst 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()));// Providersr.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 = consreturn { 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)) })));// Modelsr.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;}@@@ -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.',@@@ -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".nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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,++};nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 } };@@@ -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,@@@ -43,7 +43,7 @@ async function start({ config, clock = { now: () => Date.now() }, fetchImpl = glconst 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(() => {nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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();
@@ -25,6 +25,20 @@ OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
@@ -25,6 +25,20 @@ OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
#AI_NS_REQUIRED=true
#AI_NS_REQUIRED=true
#OV_NETWORK_PUBLIC_KEY=
#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 ──
# ── 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_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
# ai_input/output_cost_per_mtok, ai_max_cost_usd_per_day). scripts/import-from-live.js prints the
@@ -62,6 +62,8 @@ idempotent replay):
@@ -62,6 +62,8 @@ idempotent replay):
| `GET /api/v1/status\|usage\|quotas\|requests\|audit\|cache\|providers\|models` | `ai.usage.read` |
| `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 /api/health`, `GET /api/ready`, `GET /release.json` | public |
| `GET /metrics` | direct loopback callers only (a request carrying X-Forwarded-For gets 404) |
| `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
`/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
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
@@ -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
nothing. `AI_NS_FALLBACK=none` removes the fallback; `AI_NS_REQUIRED=false` is a rollback lever to
the old rule (no `ns` = every namespace).
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
## The eleven record groups
| Group | Table(s) |
| Group | Table(s) |
@@ -154,6 +197,7 @@ stub joins every route as a last resort outside production only (`AI_STUB_FALLBA
@@ -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` |
| 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` |
| 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` |
| 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` |
| 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` |
| 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` |
| 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`.
@@ -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`
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
(`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
## Not done yet
- `ai.run.*` events to OpenVibe.Events (and their schemas in Contracts); per-actor (BYO) provider
- `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;
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
- 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.
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
- No fallback is declared on any production route, so an outage of the one real provider fails every
@@ -11,6 +11,9 @@
@@ -11,6 +11,9 @@
## /api/v1/* service-to-service API (Network service tokens, audience openvibe.ai). Services on
## /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
## 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).
## 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 {
server {
@@ -62,6 +65,25 @@ server {
@@ -62,6 +65,25 @@ server {
# proxy_read_timeout 90s; # ?wait= holds a request up to 60 s
# 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 = / {
location = / {
proxy_pass http://127.0.0.1:4700;
proxy_pass http://127.0.0.1:4700;
proxy_set_header Host $host;
proxy_set_header Host $host;
@@ -16,13 +16,17 @@
@@ -16,13 +16,17 @@
* GET /api/v1/quotas POST /api/v1/quotas (ai.provider.manage)
* 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)
* 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)
* 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 express = require('express');
const { CAPS } = require('../auth');
const { CAPS } = require('../auth');
const { AiError } = require('../util');
const { AiError } = require('../util');
const { sendError } = require('./runs');
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 r = express.Router();
const provRead = auth.requireCap(CAPS.providerManage, CAPS.usageRead);
const provRead = auth.requireCap(CAPS.providerManage, CAPS.usageRead);
const provWrite = auth.requireCap(CAPS.providerManage);
const provWrite = auth.requireCap(CAPS.providerManage);
@@ -31,20 +35,10 @@ function adminRouter({ db, registry, pool, quotas, cache, runs, auth, log = cons
@@ -31,20 +35,10 @@ function adminRouter({ db, registry, pool, quotas, cache, runs, auth, log = cons
const usageRead = auth.requireCap(CAPS.usageRead);
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 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 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 : {});
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
// Providers
r.get('/api/v1/providers', provRead, wrap(() => ({ providers: registry.listProviders().map(view) })));
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
@@ -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' })) };
return { provider: view(registry.upsertProvider({ ...b, key: req.params.key }, { ...actor(req), origin: 'admin' })) };
}));
}));
for (const [action, status] of [['disable', 'disabled'], ['enable', 'active']]) {
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
// Models
r.get('/api/v1/models', provRead, wrap((req) => ({ models: registry.listModels({ provider: req.query.provider }) })));
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
@@ -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/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.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;
return r;
}
}
@@ -8,9 +8,11 @@ const { createRelease } = require('openvibe-shared/release');
@@ -8,9 +8,11 @@ const { createRelease } = require('openvibe-shared/release');
const { createAiReadiness, registerAiGauges } = require('./observability');
const { createAiReadiness, registerAiGauges } = require('./observability');
const { runsRouter } = require('./api/runs');
const { runsRouter } = require('./api/runs');
const { adminRouter } = require('./api/admin');
const { adminRouter } = require('./api/admin');
+const { createOps } = require('./ops');
+const { consoleRouter } = require('./console');
const pkg = require('../package.json');
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();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
app.set('trust proxy', 'loopback');
app.set('trust proxy', 'loopback');
@@ -41,8 +43,17 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
@@ -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).
// GET /release.json (ADR-016) and POST /release-metrics (open tabs' update reports into /metrics).
release.mount(app, { registry: metrics.registry });
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(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) => {
app.get('/', (_req, res) => {
res.type('text/plain').send([
res.type('text/plain').send([
@@ -53,6 +64,7 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
@@ -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}',
'POST /api/v1/{chat,generate,summarize,classify,extract,enrich,embed}',
'GET /api/v1/workflows | templates | routes | providers | models | quotas | usage | audit',
'GET /api/v1/workflows | templates | routes | providers | models | quotas | usage | audit',
'GET /api/health, /api/ready, /release.json',
'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).',
'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.',
'AI output is a draft/evidence package attributed to a workflow, model and run, never to a person.',
@@ -61,12 +61,13 @@ function load(env = process.env) {
@@ -61,12 +61,13 @@ function load(env = process.env) {
if (env.AI_PRICING_JSON) {
if (env.AI_PRICING_JSON) {
try { pricing = JSON.parse(env.AI_PRICING_JSON) || {}; } catch { throw new Error('AI_PRICING_JSON is not valid 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 {
return {
port,
port,
host: env.HOST || '127.0.0.1',
host: env.HOST || '127.0.0.1',
nodeEnv,
nodeEnv,
isProduction,
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).
// Identity: OpenVibe.Network signs the service tokens callers present (audience openvibe.ai).
networkUrl: trimUrl(env.OV_NETWORK_URL || 'https://openvibe.network'),
networkUrl: trimUrl(env.OV_NETWORK_URL || 'https://openvibe.network'),
@@ -80,6 +81,22 @@ function load(env = process.env) {
@@ -80,6 +81,22 @@ function load(env = process.env) {
dbPath: env.AI_DB_PATH || './data/ai.db',
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") ──
// ── The shared provider (Live's "shared key") ──
// AI_ENABLED is Live's ai_enabled master switch. Unset means "on when a key or a
// AI_ENABLED is Live's ai_enabled master switch. Unset means "on when a key or a
// self-hosted base URL is configured".
// self-hosted base URL is configured".
new file mode 100644
new file mode 100644
@@ -0,0 +1,391 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,219 @@
@@ -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,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,112 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,120 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,479 @@
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 } };
@@ -12,7 +12,8 @@
@@ -12,7 +12,8 @@
*
*
* Templates, workflows and routes are append-only per key: an edit inserts version n+1 and the
* 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.
* 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 fs = require('fs');
const path = require('path');
const path = require('path');
@@ -281,6 +282,17 @@ CREATE TABLE IF NOT EXISTS audit_log (
@@ -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_target ON audit_log(target_type, target_id, id);
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action, 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 (
CREATE TABLE IF NOT EXISTS import_ledger (
source TEXT NOT NULL, -- live.ai_usage, live.translations, ...
source TEXT NOT NULL, -- live.ai_usage, live.translations, ...
source_key TEXT NOT NULL,
source_key TEXT NOT NULL,
@@ -43,7 +43,7 @@ async function start({ config, clock = { now: () => Date.now() }, fetchImpl = gl
@@ -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 keys = createKeyStore({ urls: [config.networkInternalUrl, config.networkUrl], pem: config.networkPublicKey, fetchImpl, log });
const auth = createAuth({ config, keys });
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 keyLoaded = keys.start().catch(() => null);
const housekeeping = setInterval(() => {
const housekeeping = setInterval(() => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,55 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,500 @@
@@ -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();