openvibers/openvibe.ai Commits
Move AI operator console routes to Exchange (#376)
Changes
@@@ -5,8 +5,8 @@**Status:** alpha (roadmap Wave 13). Deployed on the host since 2026-09-23 on `127.0.0.1:4700` and inproduction use: OpenVibe.Live runs with `AI_SERVICE=remote` and Network's footer copy runs`network.site_copy` here. Public since 2026-10-07 at [ai.openvibe.services](https://ai.openvibe.services):--the API (`ai.app.run`, the OpenAI-compatible `/v1`), a run's receipt (`/runs/:id`), a person's own provider--keys (`/you`) and the operator console.++the API (`ai.app.run`, the OpenAI-compatible `/v1`) and a run's receipt (`/runs/:id`). The operator console and++personal credential settings live on OpenVibe.Exchange.**The public site moved to [openvibe.exchange](https://openvibe.exchange) on 2026-11-07:** the home, pricesand latency, the changelog, the legal pages and the discovery files answer 301 there on an exact page map(`server/app.js`), while every API route on this origin is unchanged. The content and the map are in@@@ -31,13 +31,14 @@ owns publication truth: Wiki, Blog, News, Live, … decide what to publish.- people's own provider keys (`ai.credential.manage`), encrypted at rest, one per provider (Codes C3: `anthropic`,`openai`, `deepseek`, `minimax`, `openrouter` or a `custom` endpoint), and per-streamerattribution quotas (`ai.quota.attribution.manage`)--- **"Your AI" (L36 Y1/Y3/Y8):** the person's own keys under the reserved owner `self` for every console provider++- **"Your AI" (L36 Y1/Y3/Y8):** the person's own keys under the reserved owner `self` for every supported provider(Anthropic, OpenAI, Gemini, DeepSeek, Mistral, xAI, Groq, Cerebras, Together, Fireworks, OpenRouter, MiniMax and acustom endpoint), tested on save (its models, or one token) with status, `models_count` and a redacted `last_error`;their `only_my_keys`, `free_first`, price ceiling and default model per kind of work; a run `on_behalf_of` a persontries their keys first, `explain.paid_by` and the attempts name who paid and why each failed, and a 401/403 marks thekey failing and publishes `ai.credential.failed` (`/api/v1/me/*`, `server/me.js`; only the person's own token or-- AI-site session, never a service token or `on_behalf_of`)++ AI-site session, never a service token or `on_behalf_of`). The person's settings UI is on++ [OpenVibe.Exchange](https://openvibe.exchange/privacy/keys).- a person's own computer as their provider (credential provider `node`, contracts 0.220.0): a model on their ownmachine (Ollama, LM Studio, llama.cpp through the OpenVibe Link), reached through OpenVibe.Bot with `bot.machine.ai`for that person only, free and with no key (`server/machines.js`; Bot on this host by default, `OV_BOT_INTERNAL_URL`);@@@ -141,7 +142,7 @@ idempotent replay):| `GET /api/v1/usage/series?days=N` (N 1–90) — aggregate demand per UTC day and model: requests, successes and the four token totals, never a caller id or text (`AI_USAGE_SERIES=on`) | `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 /console…` | 301 to the same path on [OpenVibe.Exchange](https://openvibe.exchange/console) || `GET /runs/:id` — one run's receipt (status, workflow, model, cost, times, output or error; never the input) | the run's person, signed in (see [Run receipts](#run-receipts)) || `GET /auth/person/login\|callback` | any Network person, for a run receipt || `GET /robots.txt` | public: disallows `/console`, `/runs`, `/auth/`, `/api/` |@@@ -478,47 +479,9 @@ AI takes one on these routes alone, and every other route refuses `env: sandbox`## Operator console--`/console` on ai.openvibe.services (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/`.++The owner-only AI operator console is on [OpenVibe.Exchange](https://openvibe.exchange/console). It uses Exchange sign-in and Exchange's page shell; Exchange reads and writes AI's JSON admin API with the `exchange` service grant. The old `ai.openvibe.services/console` paths return 301 to the matching Exchange path. AI keeps its JSON admin APIs and run receipts, not operator-console HTML.--| 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; for the owner, per provider: replace its key, register a model (with prices typed from the provider's own price page, which the form requires) |--| `/console/providers/new` | the owner adds a provider in a few clicks: DeepSeek, OpenRouter, Groq, Gemini, Mistral, Together, Fireworks, Cerebras, xAI, OpenAI, Anthropic or any OpenAI-compatible API (`server/console/presets.js`), with its API key, which is kept sealed in the config store, bound to the provider's origin (`vault:<provider>`, below) |--| `/console/routes/:key` (owner form) | **use another provider**: a new route version whose primary is the provider and model chosen, with the previous primary kept as the first fallback; **back to the default** writes the newest seeded version again |--| `/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, the Billing reading with its delivery state, free share and last send error, 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`); the **free allowance this period** per subject × provider × metric (free used, allowance, remaining, period end; `quotas.freeAllowance.current()`); the `platform.usage-sample@1` readings by delivery state (queued, retrying, sent, failed) and the last send errors with their run. Staff only: the public `/stats` page never shows a subject |--| `/console/exchange` | AI Exchange operational state, only with `AI_CREDITS` and/or `AI_ROUTING_EVIDENCE` on: the Credits settle queue's counts by status (queued, sent, review), the oldest queued row's age, the review rows (hold id, request id, tries, last error, created) with one **mark resolved** action that never retries, and the four settle-body usage counts only; per deployment samples, success and timeout rates, TTFT and decode EWMA and cache-hit ratio; the incumbents per decision key with the challenger and its streak |--| `/console/cache` | live entries and hits by workflow |--| `/console/audit` | the `audit_log`, filtered by kind (configuration changes by default, runs, AI Exchange, 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; mark a Credits settle review row resolved (never retried) |--| `staff.secrets.manage` | owner | `ai.provider.manage` | add a provider with its key, replace a key, register a model, choose a route's provider and model, 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.++A person's provider keys and supported browser session cookies are managed at [Your keys and accounts](https://openvibe.exchange/privacy/keys). AI's `/api/v1/me/credentials` endpoints remain person-authenticated JSON APIs; list and balance responses contain metadata only, never secret values.## Run receipts@@@ -737,7 +700,7 @@ OpenRouter's public model list is read as well, but only as a second opinion; noA price never changes silently. Every change lands in the `price_changes` log with the old price, the newprice, the listing it was read from and the day it was read. A change of **more than 20%** waits there for the--owner, who approves or rejects it on the operator console (`/console/pricebook`, `ai.provider.manage`); a++owner, who approves or rejects it on the operator console ([price book](https://openvibe.exchange/console/pricebook), `ai.provider.manage`); asmaller one the job applies at once, and the model row carries the new price with the day it was checked. Alisting that cannot be read leaves every price alone: an unreadable page is not evidence of a change. A price aperson typed by hand is never touched.@@@ -1010,7 +973,7 @@ mix vectors from different providers (re-embed when the provider changes).| 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` || Developer apps: `ai.app.run` on the six direct operations and `GET /runs/:id`, 403 on every other route, project-scoped runs, readings and free allowance per project, govern tiers keyed by the project, sandbox and ungoverned runs free/local capacity only, a byok profile and `credential` never selected | `test/app-access.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` |++| Operator console: owner-only pages and controls on Exchange; AI keeps JSON admin APIs and redirects the old paths | Exchange `test/console-merge.test.js`, AI `test/home.test.js` || Public price/latency page: 200 without auth, rows from seeded stats and rate cards, the empty state, no caller ids, own-key providers, URLs or secrets | `test/stats-page.test.js` || The OpenAI Responses adapter (`kind: responses`) with a stubbed fetch: request shape, output and usage parsing, `text.format`, error mapping, the key never in an error | `test/provider-responses.test.js` || Ported adapters against fake OpenAI/Anthropic servers, stub determinism, whisper filter, templates | `test/providers.test.js` |@@@ -1030,8 +993,8 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md). The rules the code keeps:- **Auth.** Every API route needs a Network service token for audience `openvibe.ai`, with the route'scapability and, for runs, a namespace its `ns` claim allows. Runs are private to their requester.A developer-app token is accepted only on `ai.app.run`'s routes ([Developer apps](#developer-apps)),-- where it acts as its project; every other route refuses it. The operator console is Network staff-- only, signed in with SSO and PKCE, with CSRF on every write.++where it acts as its project; every other route refuses it. The owner-only operator console is on OpenVibe.Exchange;++AI's admin JSON endpoints still require their named capabilities.- **Secrets.** Shared provider keys are `env:NAME` references, never values; a person's own key isAES-256-GCM encrypted with `AI_CREDENTIALS_KEY`, bound to its owner and subject, and never returned(a four-character hint only). No response, log or console page carries a secret value.@@@ -69,7 +69,7 @@ last week's per-minute baseline for that bucket. Above `AI_ABUSE_VELOCITY_MIN_TO`abuse.throttled` row goes to the operator audit log. The rate limit above records `abuse.rate_limited` the sameway.--Operators see both on `/console/abuse`, and the gauge `ai_abuse_throttles{reason}` (events in the last 15 minutes)++Operators see both on [Exchange's abuse page](https://openvibe.exchange/console/abuse), and the gauge `ai_abuse_throttles{reason}` (events in the last 15 minutes)is on `/metrics`. OpenVibe.Host's `deploy/prometheus/openvibe-rules.yml` raises `OpenVibeAiAbuseThrottling`(severity ticket) above 20 in 15 minutes.@@@ -82,7 +82,7 @@ money, their destroy failures and their measured cold start are in `gpu_instance## The console page--`GET /console/gpus` (the "GPUs" entry in the console) shows whether the provisioner is on and why not,++`GET https://openvibe.exchange/console/gpus` (the "GPUs" entry in the console) shows whether the provisioner is on and why not,the spend this month, the committed worst case and the cap, the models with their weights and theirstate, the measured cold start (from the launch to the first answered request), and every instancewith its price (the hour and the second), endpoint, cold start and money. "Start" and "Stop" write a request row;@@@ -169,7 +169,7 @@ instances continue cleanup when these switches are unset. Quote previews show thprice; the live placement audit records base, allowance and total separately.No new accounts, payment methods or provider keys are created.--`GET /console/gpus/:model/quote?strategy=cheapest` previews without a Billing hold or provider++`GET https://openvibe.exchange/console/gpus/:model/quote?strategy=cheapest` previews without a Billing hold or providerpurchase. Exchange uses the public, minute-cached `/api/v1/exchange/gpu-quotes/:model` and canpreview even when rentals are disabled. A private-endpoint create may include `strategy`;the choice persists on the endpoint. Every live GPU choice is recorded as `gpu.placement`@@@ -26,8 +26,8 @@ The free tier's controls are described in full in [`abuse-prevention.md`](abuse-- **Network's trust signals** (account age, a passkey, a link count, 30-day activity) are read through theexisting identity route and cached in memory for five minutes; AI never sees an email or which sites are linked.- **Operator events** (`audit_log`, `target_type='abuse'`) carry the reason, ASN, tier and counts, never an-- address, a device id or a prompt, and are visible only in the operator console (`/console/abuse`,-- `/console/audit`).++ address, a device id or a prompt, and are visible only in the owner-only operator console++ ([abuse](https://openvibe.exchange/console/abuse), [audit](https://openvibe.exchange/console/audit)) on [OpenVibe.Exchange](https://openvibe.exchange/console).`AI_ABUSE_SALT` keys every one of those hashes and signs the device cookie. Without it, a per-boot key is used:the rows cannot be linked across boots and device cookies are re-issued, which is the safe default. Set it in@@@ -35,7 +35,11 @@ function adminRouter({ config = {}, db, registry, pool, quotas, cache, runs, autconst defWrite = auth.requireCap(CAPS.workflowManage);const usageRead = auth.requireCap(CAPS.usageRead);const wrap = (fn) => async (req, res) => { try { const out = await 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) => {++ const delegated = String(req.get('x-ov-subject') || '');++ const isExchange = String(req.principal && req.principal.sub || '').replace(/^svc:/, '') === 'exchange';++ return { actor: isExchange && /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(delegated) ? delegated : req.principal.sub, trace: req.ov.traceId };++ };// Operators see the billing profile and shared pool name (migrations/0008) alongside the secret reference:// names only, never a value.const view = async (p) => { const v = await ops.providerView(p); return v && { ...v, billing_profile: p.billing_profile, pool_key: p.pool_key }; };nnew file mode 100644@@@ -0,0 +1,181 @@++'use strict';++++const express = require('express');++const q = require('../console/queries');++const { CAPS } = require('../auth');++const { AiError } = require('../util');++const providerPresets = require('../console/presets');++const providerCatalog = require('../providers/catalog');++const poolSeed = require('../providers/pool-seed');++++/** Read-only JSON read models for Exchange's owner console. Every query is bounded and the query module redacts runs. */++function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, gpus = null, abuse = null, priceBook = null, creditGrants = null, sessionCookies = null, vault = null, settings = null, config = {}, log = console }) {++ const r = express.Router();++ const json = express.json({ limit: '64kb' });++ const read = auth.requireCap(CAPS.usageRead);++ const write = auth.requireCap(CAPS.providerManage);++ const workflowWrite = auth.requireCap(CAPS.workflowManage);++ const actor = (req) => {++ const subject = String(req.get('x-ov-subject') || '');++ return /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(subject) ? subject : req.principal.sub;++ };++ const wrap = (fn) => async (req, res) => {++ try { res.set('Cache-Control', 'no-store').json(await fn(req)); }++ catch (err) { log.error(`[console-json] ${req.method} ${req.path} failed (${err && err.code || 'ai.internal'})`); res.status(err.status || 500).json({ error: 'AI admin data is unavailable.', code: err.code || 'ai.internal' }); }++ };++ r.get('/api/v1/admin/console/:section', read, wrap(async (req) => {++ const section = req.params.section;++ if (section === 'overview') {++ const since = new Date(clock.now() - 86400000).toISOString();++ return { status: await ops.status(), counts24: await q.statusCounts(db, since), failures24: await q.failures(db, since), queue: runs.stats(), cache: await cache.stats() };++ }++ if (section === 'providers') {++ const rows = await Promise.all((await registry.listProviders()).map(ops.providerView));++ const estimates = creditGrants ? await Promise.all(['anthropic', 'openai'].map(async (provider) => ({++ provider, grants: await creditGrants.list(provider), estimate: creditGrants.values().find((x) => x.provider === provider) || null,++ preference: await creditGrants.preference(provider),++ }))) : [];++ const cookies = sessionCookies ? await sessionCookies.list('operator', 'owner') : [];++ const balances = sessionCookies ? await Promise.all(cookies.map((c) => sessionCookies.balance('operator', 'owner', c.id))) : [];++ return { providers: rows, models: await registry.listModels(), credit_estimates: estimates, session_credentials: cookies, session_balances: balances };++ }++ if (section === 'runs') {++ const { filters, form, bad } = q.parseRunFilters(req.query);++ const list = await q.listRuns(db, filters);++ return { rows: list.rows, next: list.next, filters: form, invalid_filters: bad, facets: await q.runFacets(db) };++ }++ if (section === 'run') {++ const run = await runs.explained(await runs.get(req.query.id));++ if (!run) return { run: null };++ return { run, citations: await runs.citations(run.id), requests: await runs.requestsFor(run.id), input: q.preview(run.input), output: q.preview(run.output) };++ }++ if (section === 'quotas') return { quotas: await quotas.list(), current: await quotas.counters() };++ if (section === 'usage') {++ const { filters, form, bad } = q.parseUsageFilters(req.query, clock.now());++ return { ...await q.usageReport(quotas, filters), filters: form, invalid_filters: bad };++ }++ if (section === 'cache') return { cache: await cache.stats() };++ if (section === 'audit') {++ const { filters, form, bad } = q.parseAuditFilters(req.query);++ const result = await q.listAudit(db, filters);++ return { rows: result.rows, next: result.next, filters: form, invalid_filters: bad, kinds: q.AUDIT_KINDS };++ }++ if (section === 'gpus') return { state: gpus ? await gpus.state() : null };++ if (section === 'pricebook') return { report: priceBook ? priceBook.last : null, state: priceBook ? await priceBook.state.read() : null,++ pending: priceBook ? await priceBook.pending(50) : [], recent: priceBook ? await priceBook.changes({ status: 'applied', limit: 50 }) : [] };++ if (section === 'exchange') return { credits: await q.settleQueue(db, clock.now()), routing: await q.routingEvidence(db) };++ if (section === 'abuse') return { report: abuse ? await abuse.overview() : null };++ if (section === 'glossary') return { providers: await registry.listProviders(), models: await registry.listModels() };++ throw Object.assign(new Error('no such console section'), { status: 404 });++ }));++ r.get('/api/v1/admin/console/gpus/:model/quote', read, wrap(async (req) => {++ if (!gpus) throw new AiError(409, 'ai.conflict', 'GPU quotes are off on this host');++ return await gpus.preview(String(req.params.model), { strategy: req.query.strategy || undefined });++ }));++ r.post('/api/v1/admin/console/providers/:key/key', json, write, wrap(async (req) => {++ if (!vault || !settings) throw new AiError(503, 'vault.unavailable', 'provider keys cannot be stored on this host');++ const provider = await registry.getProvider(req.params.key);++ if (!provider) throw new AiError(404, 'ai.not_found', 'no such provider');++ const value = String((req.body || {}).api_key || '');++ if (value.length < 8 || value.length > 400) throw new AiError(422, 'ai.invalid', 'the provider key must be 8-400 characters');++ const who = { actor: actor(req), trace: req.ov && req.ov.traceId };++ await vault.put(provider.key, value, who);++ await registry.upsertProvider({ key: provider.key, secret_ref: `vault:${provider.key}` }, { ...who, origin: 'admin' });++ await registry.audit(who.actor, 'provider.key_replaced', 'provider', provider.key, { trace: who.trace });++ return { ok: true, provider: provider.key };++ }));++ r.post('/api/v1/admin/console/providers', json, write, wrap(async (req) => {++ if (!vault || !settings) throw new AiError(503, 'vault.unavailable', 'provider keys cannot be stored on this host');++ const b = req.body || {};++ const preset = providerPresets.byId(String(b.preset || 'custom')) || providerPresets.byId('custom');++ const key = String(b.key || preset.id).trim().toLowerCase();++ if (!/^[a-z0-9][a-z0-9._-]{1,40}$/.test(key)) throw new AiError(422, 'ai.invalid', 'the provider name must be 2-41 lowercase letters, digits, dots and dashes');++ if (await registry.getProvider(key)) throw new AiError(409, 'ai.conflict', 'a provider with that name already exists');++ const apiKey = String(b.api_key || '');++ if (apiKey.length < 8 || apiKey.length > 400) throw new AiError(422, 'ai.invalid', 'the provider key must be 8-400 characters');++ const baseUrl = String(b.base_url || preset.base_url || '').trim();++ if (!/^https:\/\/[^\s]+$/i.test(baseUrl)) throw new AiError(422, 'ai.invalid', 'the base URL must use https');++ const actorInfo = { actor: actor(req), trace: req.ov && req.ov.traceId };++ const provider = await registry.upsertProvider({ key, display_name: String(b.display_name || preset.name || key).slice(0, 80),++ kind: preset.kind, status: 'active', base_url: baseUrl.replace(/\/+$/, ''),++ auth_mode: preset.kind === 'anthropic' ? 'x-api-key' : 'bearer', secret_ref: `vault:${key}`,++ default_model: String(b.default_model || preset.model || '').trim() || null,++ capabilities: [...providerPresets.TEXT_CAPS, ...(b.vision === true ? ['vision'] : [])],++ timeout_ms: 60000, priority: 50, billing_profile: 'metered', metadata: { added_from: 'exchange-console', preset: preset.id },++ }, { ...actorInfo, origin: 'admin' });++ await vault.put(key, apiKey, actorInfo);++ if (settings.providerKeys && settings.providerKeys.setOrigin) await settings.providerKeys.setOrigin(key, baseUrl, { actor: { type: 'service', id: 'ai' }, reason: 'provider added' });++ await providerCatalog.sync({ registry, provider, apiKey, fetchImpl, actor: actorInfo.actor }).catch(() => null);++ await poolSeed.applyCatalogPrices({ registry, provider, actor: actorInfo.actor }).catch(() => null);++ return { ok: true, provider: await ops.providerView(provider) };++ }));++ r.post('/api/v1/admin/console/provider-credits', json, write, wrap(async (req) => {++ if (!creditGrants) throw new AiError(503, 'ai.unavailable', 'provider credit records are not configured');++ const b = req.body || {};++ const provider = ['anthropic', 'openai'].includes(String(b.provider)) ? String(b.provider) : 'anthropic';++ const grant = await creditGrants.add({ provider_key: provider, kind: String(b.kind || 'promotional'), amount_usd: Number(b.amount_usd),++ as_of: String(b.as_of || ''), received_at: String(b.received_at || b.as_of || ''),++ remaining_as_of_usd: b.remaining_as_of_usd == null ? Number(b.amount_usd) : Number(b.remaining_as_of_usd),++ remaining_as_of: String(b.remaining_as_of || b.as_of || ''),++ expires_at: b.expires_at ? new Date(`${b.expires_at}T23:59:59Z`).toISOString() : null,++ applies_to: String(b.applies_to || '').slice(0, 200) || null });++ return { grant };++ }));++ r.post('/api/v1/admin/console/provider-credit-preference', json, write, wrap(async (req) => {++ if (!creditGrants) throw new AiError(503, 'ai.unavailable', 'provider credit preferences are not configured');++ const b = req.body || {};++ const provider = ['anthropic', 'openai'].includes(String(b.provider)) ? String(b.provider) : 'anthropic';++ return { preference: await creditGrants.setPreference({ provider_key: provider, enabled: b.enabled === true, floor_usd: Number(b.floor_usd) }) };++ }));++ r.post('/api/v1/admin/console/provider-session-credentials', json, write, wrap(async (req) => {++ if (!sessionCookies) throw new AiError(503, 'ai.unavailable', 'provider session credentials are not configured');++ const b = req.body || {};++ return { credential: await sessionCookies.add('operator', 'owner', { provider: b.provider, cookie: String(b.cookie || ''), label: b.label, expires_at: b.expires_at || null, org_id: b.org_id }) };++ }));++ r.delete('/api/v1/admin/console/provider-session-credentials/:id', write, wrap(async (req) => {++ if (!sessionCookies) throw new AiError(503, 'ai.unavailable', 'provider session credentials are not configured');++ return { removed: await sessionCookies.remove('operator', 'owner', req.params.id) };++ }));++ r.post('/api/v1/admin/console/gpus/:model/:action', write, wrap(async (req) => {++ if (!gpus) throw new AiError(409, 'ai.conflict', 'GPU controls are off on this host');++ const action = String(req.params.action);++ if (!['launch', 'stop'].includes(action)) throw new AiError(404, 'ai.not_found', 'no such GPU action');++ await gpus.request(String(req.params.model || ''), action, actor(req));++ return { ok: true };++ }));++ r.post('/api/v1/admin/console/pricebook/changes/:id', write, wrap(async (req) => {++ if (!priceBook) throw new AiError(409, 'ai.conflict', 'the price book is off on this host');++ const decision = String((req.body || {}).decision) === 'approve' ? 'approve' : 'reject';++ const id = String(req.params.id).slice(0, 60);++ const out = await priceBook.decide(id, { decision, actor: actor(req) });++ if (!out.ok) throw new AiError(409, 'ai.conflict', out.error);++ await registry.audit(actor(req), decision === 'approve' ? 'price_change.approved' : 'price_change.rejected', 'price_change', id, { trace: req.ov && req.ov.traceId, metadata: { decision } });++ return { ok: true, decision, change: out.change };++ }));++ r.post('/api/v1/admin/console/pricebook/run', write, wrap(async (req) => {++ if (!priceBook) throw new AiError(409, 'ai.conflict', 'the price book is off on this host');++ const report = await priceBook.runOnce({ force: true });++ await registry.audit(actor(req), 'pricebook.run', 'pricebook', 'run', { trace: req.ov && req.ov.traceId, metadata: { sources: (report.sources || []).length, applied: (report.applied || []).length, pending: (report.pending || []).length } });++ return report;++ }));++ r.post('/api/v1/admin/console/runs/:id/cancel', workflowWrite, wrap(async (req) => {++ const subject = actor(req);++ const run = await runs.cancel(req.params.id, { sub: subject, subject: { type: 'user', id: subject } }, {++ trace: req.ov && req.ov.traceId, principalHas: (cap) => cap === CAPS.workflowManage,++ });++ return { run };++ }));++ r.post('/api/v1/admin/console/exchange/credits/:id/resolve', workflowWrite, wrap(async (req) => {++ const id = String(req.params.id || '').slice(0, 300);++ const row = id ? await db.prepare('SELECT id, hold_id, request_id, status FROM credit_settle_queue WHERE id = ?').get(id) : null;++ if (!row) throw new AiError(404, 'ai.not_found', 'no such settle queue row');++ if (row.status !== 'review') throw new AiError(409, 'ai.conflict', 'only a row in review can be marked resolved');++ const now = new Date(clock.now()).toISOString();++ await db.prepare(`UPDATE credit_settle_queue SET status = 'resolved', resolved_at = ?::timestamptz, resolved_by = ?, updated_at = ?::timestamptz WHERE id = ? AND status = 'review'`)++ .run(now, actor(req), now, id);++ return { ok: true, id };++ }));++ return r;++}++++module.exports = { consoleDataRouter };@@@ -221,21 +221,26 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys// dashboard, an API for Exchange. Nothing here writes an offer; the offer belongs to Network and the Link owner.if (community) app.use('/api/v1', require('./api/community').communityRouter({ community, auth, log }));app.use(adminRouter({ config, db, registry, pool, quotas, cache, runs, auth, settings, ops, log }));-- // The operator console (/console, /auth/*): Network staff, server-rendered, no scripts (server/console).++ app.use(require('./api/console-data').consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth, clock, fetchImpl, gpus, abuse, priceBook, creditGrants, sessionCookies, vault, settings, config, log }));++ // The operator console moved to OpenVibe.Exchange. Keep the legacy AI paths as permanent redirects;++ // run receipts still use the person-session routes below and the AI JSON admin APIs stay here.// Person sessions are separate, read-only and open one run receipt each (server/person-session.js).const personSessions = createPersonSessions({ db, config, clock });// The person's own AI settings (L36 Y1/Y3): their keys under owner `self`, and their routing preferences. Only// the person's Network token or their AI site session (same origin) reaches them; a service token is refused.if (credentials && preferences && grants) {app.use(require('./me').meRouter({ credentials, preferences, grants, auth, registry, config, clock, log, personSessions, sessionCookies, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));-- // The same settings as a page for people (GET /you, plain forms on AI's own person session).-- app.use(require('./you-page').youPageRouter({ credentials, preferences, grants, personSessions, config, log, machines: pool && pool.machines }));}-- app.use(consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, env, clock, fetchImpl, log, vault, settings, personSessions, abuse, priceBook, creditGrants, sessionCookies }));++ // The person's human settings moved to Exchange; keep the JSON API here for first-party clients.++ app.use('/you', (req, res) => res.redirect(301, `${config.exchangeUrl}/privacy/keys${req.originalUrl.includes('?') ? req.originalUrl.slice(req.originalUrl.indexOf('?')) : ''}`));++ app.use('/console', (req, res) => res.redirect(301, `${config.exchangeUrl}${req.originalUrl}`));++ app.get('/auth/login', (req, res) => res.redirect(301, `${config.exchangeUrl}/auth/login${req.originalUrl.includes('?') ? req.originalUrl.slice(req.originalUrl.indexOf('?')) : ''}`));++ // Person sign-in for /runs/:id shares the console SSO router, but the legacy operator pages are intercepted above.++ app.use(consoleRouter({ config, db, registry, env, clock, fetchImpl, log, personSessions }));app.use(runPageRouter({ runs, personSessions, log }));// ── The public pages moved to OpenVibe.Exchange (2026-11-07) ────-- // ai.openvibe.services keeps the API, a run's receipt and the operator console. Every human page it used++ // ai.openvibe.services keeps the API and a run's receipt. Every human page it used// to serve answers 301 to its Exchange home: the page map is exact, an unknown human path goes to the home,// and no API path is ever redirected. The site's page list, price page, update log, legal pages and// crawl artifacts moved with it.@@@ -261,8 +266,8 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys'GET /api/v1/workflows | templates | routes | providers | models | quotas | usage | audit','GET /api/health, /api/ready, /release.json','GET /runs/:id your run receipt (OpenVibe.Network sign-in; your own runs only)',-- 'GET /you your own provider keys and how your AI is paid (OpenVibe.Network sign-in)',-- 'GET /console the operator console (OpenVibe.Network staff sign-in)',++ `GET ${EXCHANGE}/privacy/keys your own provider keys and session cookies`,++ `GET ${EXCHANGE}/console owner-only AI operator console`,'','Callers authenticate with OpenVibe.Network tokens (audience openvibe.ai): first-party service tokens, or a',"developer app's token (ai.app.run) on the six direct operations and GET /api/v1/runs/:id.",@@@ -1,700 +1,63 @@'use strict';--/**-- * The AI operator console (roadmap WS-O task 4): server-rendered, no JavaScript, on-- * ai.openvibe.services under /console, for OpenVibe.Network staff.-- *-- * GET /auth/login, /auth/callback Network SSO (authorization code + PKCE S256, OAuth client `ai`)-- * GET /auth/person/login the same sign-in for any person, for their run receipt page-- * 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; free allowance this period, quotas.freeAllowance.current;-- * Billing readings by delivery state, usage-samples deliveryReport)-- * GET /console/cache; POST /console/cache/purge (writes: ai.provider.manage; ops.purgeCache)-- * GET /console/audit[?kind&action&actor&target_type&target_id&before]-- * GET /console/pricebook the living price book: the prices that wait for the owner-- * POST /console/pricebook/changes/:id approve or reject a waiting price change (ai.provider.manage)-- * POST /console/pricebook/run read the providers' listings now (ai.provider.manage)-- * GET /console/exchange AI Exchange state (AI_CREDITS or AI_ROUTING_EVIDENCE)-- * POST /console/exchange/credits/:id/resolve mark a review settle row resolved, never retried-- *-- * 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.-- */++++// AI keeps only the sign-in flow used by private run receipts. Operator pages live on Exchange.const crypto = require('crypto');const express = require('express');--const { AiError, resolveSecret, providerSecret, iso } = require('../util');--const usageSamples = require('../usage-samples');--const { CAPS } = require('../auth');--const { staff: staffMap } = require('openvibe-contracts');++const { resolveSecret } = require('../util');const { createSessions, sameString } = require('./session');--const sso = require('./sso');--const q = require('./queries');--const catalog = require('../providers/catalog');--const poolSeed = require('../providers/pool-seed');--const { CSP, esc, pages, healthText } = 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;--}----/** Where to go after a person signs in for a run receipt: exactly one /runs/run_<ULID> page; anything else goes home. */--function sanitizeRunNext(v) {-- if (typeof v === 'string' && /[\u0000-\u001f\u007f\\]/.test(v)) return '/';-- const s = String(v || '');-- return s.length <= 300 && /^\/runs\/run_[0-9A-HJKMNP-TV-Z]{26}$/.test(s) ? s : '/';++const { createConsoleSso } = require('./sso');++const { pages } = require('./views');++++/** A person can return only to one run receipt after signing in. */++function sanitizeRunNext(value) {++ if (typeof value === 'string' && /[\u0000-\u001f\u007f\\]/.test(value)) return '/';++ const next = String(value || '');++ return next.length <= 300 && /^\/runs\/run_[0-9A-HJKMNP-TV-Z]{26}$/.test(next) ? next : '/';}--const NOTICES = {-- provider_status: 'Provider status changed.',-- provider_added: 'Provider added. Point a route at it below, or on the route\'s page.',-- provider_key: 'Key replaced. Runs use it from now on.',-- model_added: 'Model registered.',-- models_synced: 'Model list read. New models are listed switched off: turn on the ones routes may use.',-- model_status: 'Model switched.',-- route_used: 'Route changed: new runs use the provider and model you chose (the previous one is kept as a fallback).',-- route_default: 'Route back to its default.',-- 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.',-- settle_resolved: 'Settle queue row marked resolved. Nothing was retried.',-- price_approved: 'The price change was approved: the price the router charges is the new one.',-- price_rejected: 'The price change was rejected: the price stands.',-- pricebook_ran: 'The provider listings were read.',--};--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, ops, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, vault = null, settings = null, personSessions = null, abuse = null, priceBook = null, creditGrants = null, sessionCookies = null }) {++function consoleRouter({ config, db, registry, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, personSessions = null }) {const cc = config.console;-- const creditsOn = Boolean(config.credits && config.credits.enabled);-- const routingOn = Boolean(config.routingEvidence && config.routingEvidence.enabled);-- const exchangeOn = creditsOn || routingOn;-- // The GPU provisioner's state (the same module the service runs): the page shows the instances, the-- // spend this month and the cap. The actions only write a request; the reconciler fulfils it.-- const gpus = require('../gpu/provisioner').createGpuProvisioner({ db, registry, settings, clock, fetchImpl, log });-- 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' });-- // openvibe-sdk/sso owns the OAuth handshake and /auth/health; the staff session below stays this site's.-- const consoleSso = sso.createConsoleSso({ config, clientSecret: clientSecret(), fetchImpl, log });---- /** Every secret value this process can resolve, so that no page can ever carry one (belt and braces). */-- async function secretValues() {-- const vals = new Set([clientSecret(), secret]);-- for (const p of await registry.listProviders()) vals.add(providerSecret(p, env));-- return [...vals].filter((v) => v && v.length >= 8);-- }-- async function scrub(text) {-- let out = text;-- for (const v of await secretValues()) for (const form of new Set([v, esc(v)])) out = out.split(form).join('[redacted]');-- return out;-- }-- const send = async (res, status, text) => res.status(status).type('html').send(await scrub(text));-- const who = (req) => ({ actor: req.staff.subject, trace: req.ov ? req.ov.traceId : null });-- // staff.exchange only picks the extra navigation entry; it is a copy, so the signed-in claims stay as read.-- const common = (req) => ({ staff: req.staff ? { ...req.staff, exchange: exchangeOn } : req.staff, csrf: req.staff ? req.staff.csrf : '' });-- const audit = async (actor, action, targetType, targetId, metadata, req) => await 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, async (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 await 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, async (req, res, next) => {-- req.staff = null;-- const row = await sessions.read(req);-- if (!row) return next();-- const caps = consoleCapabilities(row.staff);-- if (!caps.includes(CAPS.usageRead)) {-- await sessions.revoke(row);-- sessions.clear(res);-- await audit(row.subject, 'console.request.refused', 'console', 'session', { reason: 'the session no longer maps to ai.usage.read' }, req);-- return await 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 async (req, res, next) => {-- if (!req.staff) {-- if (req.method === 'GET') return await send(res, 401, pages.signIn({ next: sanitizeNext(req.originalUrl) }));-- return await 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') await audit(req.staff.subject, 'console.request.refused', 'console', req.path, { reason: `needs ${cap}` }, req);-- return await send(res, 403, pages.message({ title: 'Not authorized', text: `This needs ${cap}.`, ...common(req) }));-- }-- if (req.method === 'POST' && !csrfOk(req)) {-- await audit(req.staff.subject, 'console.request.refused', 'console', req.path, { reason: 'missing or invalid CSRF token, or a cross-site request' }, req);-- return await 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(async () => await fn(req, res, next)).catch(async (e) => {-- if (e instanceof AiError) return await 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) await 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 started = consoleSso.start();-- sessions.setFlow(res, { s: started.state, v: started.verifier, n: sanitizeNext(req.query.next) });-- res.redirect(302, started.url);++ if (!secret || secret.length < 32) secret = crypto.randomBytes(32).toString('hex');++ const clientSecret = resolveSecret(cc.clientSecretRef, env);++ const sessions = createSessions({ db, config, clock, secret });++ const sso = createConsoleSso({ config, clientSecret, fetchImpl, log });++ const r = express.Router();++ const send = (res, status, body) => res.status(status).type('html').send(body);++ const audit = (subject, action, metadata, req) => registry.audit(subject || 'anonymous', action, 'run_page', 'sign_in', {++ trace: req.ov && req.ov.traceId || null, metadata,});-- // A person's sign-in for one run receipt. It uses the same OAuth client and redirect URI, but the-- // flow cookie says which callback branch to take, so a non-staff person never receives a staff session.r.get('/auth/person/login', (req, res) => {-- const started = consoleSso.start();-- sessions.setFlow(res, { s: started.state, v: started.verifier, n: sanitizeRunNext(req.query.next), kind: 'person' });-- res.redirect(302, started.url);++ const flow = sso.start();++ sessions.setFlow(res, { s: flow.state, v: flow.verifier, n: sanitizeRunNext(req.query.next), kind: 'person' });++ return res.redirect(302, flow.url);});-- // GET /auth/health: does the Network accept this site's OAuth client? 503 only when it refuses it.-- r.get('/auth/health', wrap(async (req, res) => {-- const client = await consoleSso.checkClient();-- res.setHeader('Cache-Control', 'no-store');-- return res.status(client === 'invalid' ? 503 : 200).json({ ok: client !== 'invalid', client });-- }));---- r.get('/auth/callback', wrap(async (req, res) => {++ r.get('/auth/callback', async (req, res) => {const flow = sessions.takeFlow(req, res);-- const personFlow = flow && flow.kind === 'person';-- if (req.query.error) {-- const text = `Sign-in was not completed (${String(req.query.error).slice(0, 60)}).`;-- return await send(res, 400, personFlow ? pages.message({ title: 'Sign-in not completed', text, back: '/', site: 'your runs' }) : pages.signIn({ message: text }));-- }-- if (!flow || !req.query.code || !sameString(req.query.state, flow.s)) {-- const text = 'The sign-in could not be verified (expired or mismatched state). Please try again.';-- return await send(res, 400, personFlow ? pages.message({ title: 'Sign-in not verified', text, back: '/', site: 'your runs' }) : pages.signIn({ message: text }));++ if (!flow || flow.kind !== 'person' || !req.query.code || !sameString(req.query.state, flow.s)) {++ return send(res, 400, pages.message({ title: 'Sign-in not verified', text: 'The sign-in could not be verified (expired or mismatched state). Please try again.', back: '/', site: 'your runs' }));}let person;-- try {-- person = await consoleSso.exchange({ code: req.query.code, verifier: flow.v, now: clock.now() });-- } catch (e) {-- log.warn(`[console] sign-in failed: ${e.message}`);-- const text = 'OpenVibe.Network did not confirm the sign-in. Please try again.';-- return await send(res, e.status && e.status < 500 ? 400 : 502, personFlow ? pages.message({ title: 'Sign-in failed', text, back: '/', site: 'your runs' }) : pages.signIn({ message: text }));-- }-- const ipHash = sessions.ipHash(req.ip);-- if (flow.kind === 'person') {-- if (!personSessions) return await send(res, 503, pages.message({ title: 'Sign-in unavailable', text: 'The run page sign-in is not configured on this host.', back: '/', site: 'your runs' }));-- if (person.error) {-- await audit('anonymous', 'run_page.sign_in.refused', 'run_page', 'sign_in', { reason: person.error, ip_hash: ipHash }, req);-- return await send(res, 403, pages.message({ title: 'Not authorized', text: 'This page needs an OpenVibe.Network person account. Your sign-in was recorded.', back: '/', site: 'your runs' }));-- }-- await db.tx(async () => {-- await personSessions.create(res, { subject: person.subject, username: person.username });-- await audit(person.subject, 'run_page.sign_in', 'run_page', 'sign_in', { ip_hash: ipHash }, req);-- });-- return res.redirect(303, flow.n || '/');++ try { person = await sso.exchange({ code: req.query.code, verifier: flow.v, now: clock.now() }); }++ catch (error) {++ log.warn(`[run-page] sign-in failed (${error && error.status || 502})`);++ return send(res, 502, pages.message({ title: 'Sign-in failed', text: 'OpenVibe.Network did not confirm the sign-in. Please try again.', back: '/', site: 'your runs' }));}++ if (!personSessions) return send(res, 503, pages.message({ title: 'Sign-in unavailable', text: 'The run page sign-in is not configured on this host.', back: '/', site: 'your runs' }));if (person.error) {-- await audit('anonymous', 'console.sign_in.refused', 'console', 'sign_in', { reason: person.error, ip_hash: ipHash }, req);-- return await 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)) {-- await 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 await 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.' }));++ await audit('anonymous', 'run_page.sign_in.refused', { reason: person.error }, req);++ return send(res, 403, pages.message({ title: 'Not authorized', text: 'This page needs an OpenVibe.Network person account. Your sign-in was recorded.', back: '/', site: 'your runs' }));}await db.tx(async () => {-- await sessions.create(res, { subject: person.subject, username: person.username, staff: person.staff, ip: req.ip });-- await 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, async (req, res) => {-- await db.tx(async () => {-- await sessions.revoke(req.staff.session);-- await audit(req.staff.subject, 'console.sign_out', 'console', 'sign_in', {}, req);++ await personSessions.create(res, { subject: person.subject, username: person.username });++ await audit(person.subject, 'run_page.sign_in', {}, req);});-- sessions.clear(res);-- res.redirect(303, '/console');-- });---- // ── Overview ─────────────────────────────────────────────-- r.get('/console', read, wrap(async (req, res) => {-- const since = new Date(clock.now() - DAY_MS).toISOString();-- const st = await ops.status();-- const problems = [];-- for (const p of (await registry.listProviders()).filter((x) => x.status === 'active' && x.kind !== 'stub')) {-- const v = await ops.providerView(p);-- if (v.credentials === 'missing') {-- const ref = /^env:([A-Z][A-Z0-9_]*)$/.exec(String(p.secret_ref || ''));-- problems.push({ key: p.key, kind: p.kind, problem: ref ? `No key: the server's settings file has no ${ref[1]}, and none was entered here. Enter one in the admin panel's Setup tab.` : 'No key: enter one in the admin panel\'s Setup tab or on the AI providers page.' });-- }-- if (v.health && v.health.state !== 'closed') problems.push({ key: p.key, kind: p.kind, problem: healthText(v.health, { breaker: config.breaker, now: clock.now() }).text });-- }-- const stats = await 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);-- };-- await send(res, 200, pages.overview({-- ...common(req), st, counts24: await q.statusCounts(db, since), failures24: await 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(async (req, res) => {-- const estimates = creditGrants ? await Promise.all(['anthropic', 'openai'].map(async (provider) => {-- const grantRows = await creditGrants.list(provider);-- const estimate = creditGrants.values().find((x) => x.provider === provider);-- return { provider, estimate: estimate || (grantRows.length ? { provider, promotional: null, purchased: null, grants: grantRows, source: null, at: null } : null), preference: await creditGrants.preference(provider) };-- })) : [];-- const canManageCookies = req.staff.caps.includes(CAPS.configManage);-- const sessionCredentials = sessionCookies && canManageCookies ? await sessionCookies.list('operator', 'owner') : [];-- const sessionBalances = sessionCookies && canManageCookies ? await Promise.all(sessionCredentials.map((c) => sessionCookies.balance('operator', 'owner', c.id))) : [];-- return await send(res, 200, pages.providers({-- ...common(req), rows: (await Promise.all((await registry.listProviders()).map(ops.providerView))), models: await registry.listModels(), stubFallback: config.stubFallback,-- canManage: req.staff.caps.includes(CAPS.providerManage), notice: notice(req), keyHints: vault ? await vault.hints() : new Map(),-- breaker: config.breaker, now: clock.now(), creditEstimates: estimates.map(({ provider, estimate, preference }) => ({ provider, estimate: estimate || { provider, promotional: null, purchased: null, grants: [], source: null, at: null }, preference })), sessionCredentials, sessionBalances, canManageCookies,-- }));-- }));-- r.post('/console/provider-credits', needs(CAPS.providerManage), wrap(async (req, res) => {-- if (!creditGrants) throw new Error('credit grants are unavailable');-- const provider = ['anthropic', 'openai'].includes(String(req.body.provider)) ? String(req.body.provider) : 'anthropic';-- await creditGrants.add({ provider_key: provider, kind: String(req.body.kind || 'promotional'), amount_usd: Number(req.body.amount_usd), as_of: String(req.body.as_of || ''), received_at: String(req.body.received_at || req.body.as_of || ''), remaining_as_of_usd: req.body.remaining_as_of_usd == null ? Number(req.body.amount_usd) : Number(req.body.remaining_as_of_usd), remaining_as_of: String(req.body.remaining_as_of || req.body.as_of || ''), expires_at: req.body.expires_at ? new Date(`${req.body.expires_at}T23:59:59Z`).toISOString() : null, applies_to: String(req.body.applies_to || '').slice(0, 200) || null });-- res.redirect(303, '/console/providers?done=provider_credit');-- }));-- r.post('/console/provider-credit-preference', needs(CAPS.providerManage), wrap(async (req, res) => {-- if (!creditGrants) throw new Error('credit preferences are unavailable');-- const provider = ['anthropic', 'openai'].includes(String(req.body.provider)) ? String(req.body.provider) : 'anthropic';-- await creditGrants.setPreference({ provider_key: provider, enabled: req.body.enabled === '1', floor_usd: Number(req.body.floor_usd) });-- creditGrants.poll().catch((err) => log.warn(`[ai] credit estimate failed: ${String(err && err.message || err).slice(0, 180)}`));-- res.redirect(303, '/console/providers?done=provider_credit');-- }));-- r.post('/console/provider-session-credentials', needs(CAPS.configManage), wrap(async (req, res) => {-- if (!sessionCookies) throw new Error('session credentials are unavailable');-- await sessionCookies.add('operator', 'owner', { provider: req.body.provider, cookie: String(req.body.cookie || ''), label: req.body.label, expires_at: req.body.expires_at || null, org_id: req.body.org_id });-- res.redirect(303, '/console/providers?done=session_credential');-- }));-- r.get('/console/glossary', read, wrap(async (req, res) => await send(res, 200, pages.glossary({ ...common(req) }))));-- r.post('/console/providers/:key/status', needs(CAPS.providerManage), wrap(async (req, res) => {-- await 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(async (req, res) => {-- if (!await registry.getProvider(req.params.key)) throw new AiError(404, 'ai.not_found', 'no such provider');-- await ops.resetCircuit(req.params.key, who(req));-- res.redirect(303, '/console/providers?done=circuit_reset');-- }));---- // Adding a provider with its key, replacing a key, registering a model (owner: ai.provider.manage). The key goes to-- // the vault (server/vault.js, encrypted at rest); the provider's secret_ref names it, never holds it.-- const presets = require('./presets');-- const KEY_RE = /^[a-z0-9][a-z0-9._-]{1,40}$/;-- r.get('/console/providers/new', needs(CAPS.providerManage), wrap(async (req, res) => await send(res, 200, pages.providerNew({-- ...common(req), presets: presets.PRESETS, preset: presets.byId(String(req.query.preset || '')) || null, vaultReady: !!vault && !!settings,-- }))));-- r.post('/console/providers', needs(CAPS.providerManage), wrap(async (req, res) => {-- if (!vault || !settings) throw new AiError(503, 'vault.unavailable', 'provider keys cannot be stored here');-- const b = req.body || {};-- const preset = presets.byId(String(b.preset || 'custom')) || presets.byId('custom');-- const key = String(b.key || preset.id).trim().toLowerCase();-- if (!KEY_RE.test(key)) throw new AiError(422, 'ai.invalid', 'the provider name is 2-41 lowercase letters, digits, dots and dashes');-- if (await registry.getProvider(key)) throw new AiError(409, 'ai.conflict', `a provider called ${key} already exists: replace its key on the providers page`);-- const baseUrl = String(b.base_url || preset.base_url || '').trim();-- if (!/^https:\/\/[^\s]+$/i.test(baseUrl)) throw new AiError(422, 'ai.invalid', 'the base URL is an https address');-- const caps = [...presets.TEXT_CAPS, ...(b.vision ? ['vision'] : [])];-- // The provider comes first: its base URL is the origin its key is sealed to (D11).-- await registry.upsertProvider({-- key, display_name: String(b.display_name || preset.name || key).slice(0, 80), kind: preset.kind, status: 'active', base_url: baseUrl.replace(/\/+$/, ''),-- auth_mode: preset.kind === 'anthropic' ? 'x-api-key' : 'bearer', secret_ref: `vault:${key}`, default_model: String(b.default_model || preset.model || '').trim() || null,-- capabilities: caps, timeout_ms: 60000, priority: 50, billing_profile: 'metered', metadata: { added_from: 'console', preset: preset.id },-- }, { ...who(req), origin: 'admin' });-- await vault.put(key, b.api_key, who(req));-- // Its model list, read right away (best effort: a provider whose list cannot be read is still added).-- const added = await registry.getProvider(key);-- await catalog.sync({ registry, provider: added, apiKey: providerSecret(added, env), fetchImpl, actor: who(req).actor }).catch(() => null);-- // The provider's own model ids get the price the catalogue recorded for them, matched by name (pool-seed).-- await poolSeed.applyCatalogPrices({ registry, provider: added, actor: who(req).actor }).catch(() => null);-- res.redirect(303, '/console/providers?done=provider_added');-- }));-- r.post('/console/providers/:key/key', needs(CAPS.providerManage), wrap(async (req, res) => {-- if (!vault || !settings) throw new AiError(503, 'vault.unavailable', 'provider keys cannot be stored here');-- const p = await registry.getProvider(req.params.key);-- if (!p) throw new AiError(404, 'ai.not_found', 'no such provider');-- await vault.put(p.key, (req.body || {}).api_key, who(req));-- if (p.secret_ref !== `vault:${p.key}`) await registry.upsertProvider({ key: p.key, secret_ref: `vault:${p.key}` }, { ...who(req), origin: 'admin' });-- else await registry.upsertProvider({ key: p.key }, { ...who(req), origin: 'admin' }); // a new updated_at: the pool rebuilds its adapter-- await registry.audit(who(req).actor, 'provider.key_replaced', 'provider', p.key, { trace: who(req).trace });-- res.redirect(303, '/console/providers?done=provider_key');-- }));-- // Read the provider's own model list into the catalogue (L33): new models land switched off, prices stay a person's.-- r.post('/console/providers/:key/sync', needs(CAPS.providerManage), wrap(async (req, res) => {-- const p = await registry.getProvider(req.params.key);-- if (!p) throw new AiError(404, 'ai.not_found', 'no such provider');-- const apiKey = providerSecret(p, env);-- if (!apiKey && p.auth_mode !== 'none') throw new AiError(422, 'ai.invalid', `${p.key} has no key to read its model list with`);-- const out = await catalog.sync({ registry, provider: p, apiKey, fetchImpl, actor: who(req).actor });-- const priced = await poolSeed.applyCatalogPrices({ registry, provider: p, actor: who(req).actor }).catch(() => ({ priced: [], retired: [] }));-- await registry.audit(who(req).actor, 'provider.models_synced', 'provider', p.key, { trace: who(req).trace, metadata: { added: out.added.length, updated: out.updated.length, missing: out.missing.length, priced: priced.priced.length, retired: priced.retired.length } });-- res.redirect(303, '/console/providers?done=models_synced');-- }));-- // Turn one model on or off (a model routes may use, or not).-- r.post('/console/models/:provider/:model/status', needs(CAPS.providerManage), wrap(async (req, res) => {-- const m = await registry.getModel(req.params.provider, req.params.model);-- if (!m) throw new AiError(404, 'ai.not_found', 'no such model');-- const status = (req.body || {}).status === 'active' ? 'active' : 'disabled';-- await registry.upsertModel({ provider_key: m.provider_key, model_key: m.model_key, status }, who(req));-- res.redirect(303, '/console/providers?done=model_status');-- }));-- r.post('/console/providers/:key/models', needs(CAPS.providerManage), wrap(async (req, res) => {-- const p = await registry.getProvider(req.params.key);-- if (!p) throw new AiError(404, 'ai.not_found', 'no such provider');-- const b = req.body || {};-- const price = (v) => (v === '' || v == null ? undefined : Number(v));-- const source = String(b.price_source || '').trim();-- if (source && !/^https:\/\/[^\s]+$/.test(source)) throw new AiError(422, 'ai.invalid', 'the price source is the provider\'s https price page');-- if ((price(b.cost_in) != null || price(b.cost_out) != null) && !source) throw new AiError(422, 'ai.invalid', 'name the price page the prices come from');-- await registry.upsertModel({-- provider_key: p.key, model_key: String(b.model_key || '').trim(), display_name: String(b.display_name || '').trim() || null, type: 'chat',-- supports: { json: true, vision: !!b.vision, tools: !!b.tools, streaming: true },-- // Prices are the person's, typed from the provider's own price page (which they name), checked today.-- // The cache prices are optional (L40): nobody typed them means the record says 'no cache price' and the-- // cache tokens are priced at the plain input price.-- ...(price(b.cost_in) != null || price(b.cost_out) != null ? { cost: { in_per_mtok: price(b.cost_in) ?? null, out_per_mtok: price(b.cost_out) ?? null, cached_per_mtok: price(b.cost_cache_read) ?? null, cache_write_per_mtok: price(b.cost_cache_write) ?? null, ...(source ? { source, verified_at: new Date(clock.now()).toISOString().slice(0, 10) } : {}) } } : {}),-- }, who(req));-- res.redirect(303, '/console/providers?done=model_added');-- }));-- // Which provider and model a route uses (owner: it changes what OpenVibe spends). A new route version with the-- // choice as its primary and the route's previous primary as the first fallback; "default" goes back to what the-- // service seeds (the newest version the seed wrote, as a new version).-- r.post('/console/routes/:key/use', needs(CAPS.providerManage), wrap(async (req, res) => {-- const route = await registry.getRoute(req.params.key);-- if (!route) throw new AiError(404, 'ai.not_found', 'no such route');-- const b = req.body || {};-- if (b.default === '1') {-- const seeded = (await registry.listRoutes({ history: true })).filter((x) => x.key === route.key && x.created_by === 'seed').sort((a, c) => c.version - a.version)[0];-- if (!seeded) throw new AiError(409, 'ai.conflict', 'this route has no default to go back to');-- const { version, created_by, created_at, key: _k, ...def } = seeded;-- await registry.createRouteVersion(route.key, { ...def, status: 'active' }, who(req));-- return res.redirect(303, `/console/routes/${encodeURIComponent(route.key)}?done=route_default`);-- }-- const [provider, model] = String(b.target || '').split('|');-- const p = await registry.getProvider(provider || '');-- if (!p) throw new AiError(422, 'ai.invalid', 'choose a provider');-- const effective = route.alias_of ? await registry.resolveRoute(route.key) : route;-- const previous = effective && effective.primary && effective.primary.provider && !(effective.primary.provider === p.key && (effective.primary.model || null) === (model || null)) ? [{ provider: effective.primary.provider, model: effective.primary.model || null }] : [];-- await registry.createRouteVersion(route.key, {-- primary: { provider: p.key, model: model || p.default_model || null }, fallbacks: previous, capability: null, constraints: {}, pinned: [], alias_of: null,-- options: (effective && effective.options) || {}, max_output_tokens: effective ? effective.max_output_tokens : null,-- response_format: (effective && effective.response_format) || 'text', timeout_ms: effective ? effective.timeout_ms : null, status: 'active',-- learn_from_bench: Boolean(effective && effective.learn_from_bench),-- bench_kind: (effective && effective.bench_kind) || null, bench_category: (effective && effective.bench_category) || null,-- }, who(req));-- res.redirect(303, `/console/routes/${encodeURIComponent(route.key)}?done=route_used`);-- }));---- // ── 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(async (req, res) => await send(res, 200, pages.definitions({-- ...common(req), kind, rows: await registry[k.list](), inUse: inUseMap(kind, await registry[k.list]({ history: true })),-- }))));-- r.get(`/console/${kind}/:key`, read, wrap(async (req, res) => {-- const versions = (await registry[k.list]({ history: true })).filter((x) => x.key === req.params.key).sort((a, b) => b.version - a.version);-- if (!versions.length) return await 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];-- const current = await registry[k.get](req.params.key, selected.version);-- // BENCH-11: read-only evidence on a route's page. It changes what runs pick, so it is shown with-- // the leader, the tied offers and the reason; it is not editable here (bench_kind is an API field).-- let bench = null;-- if (kind === 'routes' && current.status === 'active' && pool.benchView) {-- try {-- const target = current.alias_of ? await registry.resolveRoute(current.key) : current;-- if (target && target.status === 'active') bench = await pool.benchView(target, target.capability || 'chat');-- } catch (err) {-- log.warn(`[console] bench evidence for route ${current.key}: ${err.message}`);-- }-- }-- return await send(res, 200, pages.definition({-- ...common(req), kind, key: req.params.key, versions, selected: current, bench,-- inUse: inUseMap(kind, versions).get(req.params.key), canManage: req.staff.caps.includes(CAPS.workflowManage), notice: notice(req),-- ...(kind === 'routes' ? { canChoose: req.staff.caps.includes(CAPS.providerManage), providers: (await registry.listProviders()).filter((p) => p.status === 'active' && p.kind !== 'whisper' && p.kind !== 'stub'), models: await registry.listModels() } : {}),-- }));-- }));-- r.post(`/console/${kind}/:key/versions/:version/status`, needs(CAPS.workflowManage), wrap(async (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}`);-- await 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(async (req, res) => {-- const { filters, form, bad } = q.parseRunFilters(req.query);-- const list = await q.listRuns(db, filters);-- await send(res, bad.length ? 400 : 200, pages.runs({-- ...common(req), form, filters, bad, rows: list.rows, next: list.next, facets: await q.runFacets(db),-- failures24: filters.status === 'failed' ? await q.failures(db, new Date(clock.now() - DAY_MS).toISOString()) : [],-- }));-- }));-- r.get('/console/runs/:id', read, wrap(async (req, res) => {-- const run = await runs.explained(await runs.get(req.params.id));-- if (!run) return await send(res, 404, pages.message({ title: 'Not found', text: `No run ${req.params.id}.`, back: '/console/runs', ...common(req) }));-- return await send(res, 200, pages.run({-- ...common(req), r: run, citations: await runs.citations(run.id), requests: await 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(async (req, res) => {-- await 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 = async (req, res, status, extra = {}) => await send(res, status, pages.quotas({-- ...common(req), counters: await quotas.counters(), all: await quotas.list(), canManage: req.staff.caps.includes(CAPS.providerManage), notice: notice(req), ...extra,-- }));-- r.get('/console/quotas', read, wrap(async (req, res) => await quotaPage(req, res, 200)));-- r.post('/console/quotas', needs(CAPS.providerManage), wrap(async (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 {-- await 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 await 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(async (req, res) => {-- const { filters, form, bad } = q.parseUsageFilters(req.query, clock.now());-- await send(res, bad.length ? 400 : 200, pages.usage({-- ...common(req), form, bad, report: await q.usageReport(quotas, filters),-- free: quotas.freeAllowance ? await quotas.freeAllowance.current() : [], readings: await usageSamples.deliveryReport(db),-- }));-- }));---- // ── OpenVibe GPUs: the rented instances, the spend this month and the cap ──-- // The page is read-only; the two actions ask for a change (a request row) and the reconciler does it.-- r.get('/console/gpus', read, wrap(async (req, res) => {-- await send(res, 200, pages.gpus({-- ...common(req), canManage: req.staff.caps.includes(CAPS.providerManage), notice: notice(req),-- state: await gpus.state(),-- }));-- }));-- r.get('/console/gpus/:model/quote', read, wrap(async (req, res) => {-- res.json(await gpus.preview(String(req.params.model), { strategy: req.query.strategy || undefined }));-- }));-- r.post('/console/gpus/:model/:action', needs(CAPS.providerManage), wrap(async (req, res) => {-- await gpus.request(String(req.params.model || ''), String(req.params.action || ''), req.staff.subject);-- res.redirect(303, '/console/gpus?done=gpu_requested');-- }));---- // ── The living price book: the prices the router charges, checked against each provider's own listing ──-- // The owner decides the changes the weekly job may not make on its own (a change of more than 20%).-- r.get('/console/pricebook', read, wrap(async (req, res) => {-- await send(res, 200, pages.pricebook({-- ...common(req), canManage: req.staff.caps.includes(CAPS.providerManage), notice: notice(req),-- report: priceBook ? priceBook.last : null,-- state: priceBook ? await priceBook.state.read() : null,-- pending: priceBook ? await priceBook.pending(50) : [],-- recent: priceBook ? await priceBook.changes({ status: 'applied', limit: 50 }) : [],-- }));-- }));-- r.post('/console/pricebook/changes/:id', needs(CAPS.providerManage), wrap(async (req, res) => {-- if (!priceBook) throw new AiError(409, 'ai.conflict', 'the price book is off on this host');-- const id = String(req.params.id || '').slice(0, 60);-- const decision = String((req.body || {}).decision || '') === 'approve' ? 'approve' : 'reject';-- const out = await priceBook.decide(id, { decision, actor: req.staff.subject });-- if (!out.ok) throw new AiError(409, 'ai.conflict', out.error);-- await audit(req.staff.subject, decision === 'approve' ? 'price_change.approved' : 'price_change.rejected', 'price_change', id, { decision }, req);-- return res.redirect(303, '/console/pricebook?done=' + (decision === 'approve' ? 'price_approved' : 'price_rejected'));-- }));-- r.post('/console/pricebook/run', needs(CAPS.providerManage), wrap(async (req, res) => {-- if (!priceBook) throw new AiError(409, 'ai.conflict', 'the price book is off on this host');-- const report = await priceBook.runOnce({ force: true });-- await audit(req.staff.subject, 'pricebook.run', 'pricebook', 'run', {-- sources: report.sources ? report.sources.length : 0,-- applied: (report.applied || []).length,-- pending: (report.pending || []).length,-- }, req);-- return res.redirect(303, '/console/pricebook?done=pricebook_ran');-- }));---- // ── AI Exchange (P7): Credits settle queue and routing evidence ──-- // Read-only except "mark resolved", which acknowledges one review row (a 409/422 needs a person) and-- // deliberately does not retry it. Both sections are absent unless their flag is on.-- if (exchangeOn) {-- r.get('/console/exchange', read, wrap(async (req, res) => {-- await send(res, 200, pages.exchange({-- ...common(req), canResolve: req.staff.caps.includes(CAPS.workflowManage), notice: notice(req),-- credits: creditsOn ? await q.settleQueue(db, clock.now()) : null,-- routing: routingOn ? await q.routingEvidence(db) : null,-- }));-- }));-- }-- if (creditsOn) {-- r.post('/console/exchange/credits/:id/resolve', needs(CAPS.workflowManage), wrap(async (req, res) => {-- const id = String(req.params.id || '').slice(0, 300);-- const row = id ? await db.prepare('SELECT id, hold_id, request_id, status FROM credit_settle_queue WHERE id = ?').get(id) : null;-- if (!row) throw new AiError(404, 'ai.not_found', 'no such settle queue row');-- if (row.status !== 'review') throw new AiError(409, 'ai.conflict', 'only a row in review can be marked resolved');-- let changed = false;-- await db.tx(async () => {-- const upd = await db.prepare(`UPDATE credit_settle_queue SET status = 'resolved', resolved_at = ?::timestamptz,-- resolved_by = ?, updated_at = ?::timestamptz WHERE id = ? AND status = 'review'`)-- .run(iso(clock.now()), req.staff.subject, iso(clock.now()), id);-- changed = Number(upd && upd.changes) > 0;-- if (changed) await audit(req.staff.subject, 'credit_settle.resolve', 'credit_settle', id, { hold_id: row.hold_id, request_id: row.request_id, from: 'review', to: 'resolved' }, req);-- });-- if (!changed) throw new AiError(409, 'ai.conflict', 'the row is no longer in review');-- return res.redirect(303, '/console/exchange?done=settle_resolved');-- }));-- }---- // ── Cache ────────────────────────────────────────────────-- const cachePage = async (req, res, status, extra = {}) => await send(res, status, pages.cache({-- ...common(req), stats: await cache.stats(), canManage: req.staff.caps.includes(CAPS.providerManage), notice: notice(req), ...extra,-- }));-- r.get('/console/cache', read, wrap(async (req, res) => await cachePage(req, res, 200)));-- r.post('/console/cache/purge', needs(CAPS.providerManage), wrap(async (req, res) => {-- const workflow = String((req.body || {}).workflow || '').trim();-- if (workflow && !/^[a-z][a-z0-9_-]*(\.[a-z0-9_-]+)*$/.test(workflow)) return await cachePage(req, res, 422, { error: 'that is not a workflow key', notice: null });-- if (!workflow && (req.body || {}).confirm_all !== 'yes') return await cachePage(req, res, 422, { error: 'give a workflow key, or tick the box to purge every entry', notice: null });-- const removed = await ops.purgeCache(workflow || null, who(req));-- return res.redirect(303, `/console/cache?done=cache_purged&removed=${removed}`);-- }));---- // ── Abuse prevention (plan E5) ───────────────────────────-- // A read-only page: the counters, the throttles the module already recorded in the audit log, and the size of-- // the network/device signals. The actions (throttling, allowance) are automatic; this is where a person looks.-- r.get('/console/abuse', read, wrap(async (req, res) => {-- const report = abuse ? await abuse.overview() : null;-- await send(res, 200, pages.abuse({ ...common(req), report }));-- }));---- // ── Audit ────────────────────────────────────────────────-- r.get('/console/audit', read, wrap(async (req, res) => {-- const { filters, form, bad } = q.parseAuditFilters(req.query);-- const page = await q.listAudit(db, filters);-- await send(res, bad.length ? 400 : 200, pages.audit({ ...common(req), form, bad, rows: page.rows, next: page.next, names: await 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/*'], async (req, res) => {-- if (!req.staff) return await send(res, 401, pages.signIn({ next: req.method === 'GET' ? sanitizeNext(req.originalUrl) : '/console' }));-- return await send(res, 404, pages.message({ title: 'Not found', text: 'There is no such console page.', ...common(req) }));++ return res.redirect(303, flow.n || '/');});return r;}--module.exports = { consoleRouter, consoleCapabilities, sanitizeNext, sanitizeRunNext, STAFF_TO_AI };++module.exports = { consoleRouter, sanitizeRunNext };@@@ -310,9 +310,9 @@ function createCredentials({ db, config, clock = { now: () => Date.now() }, fetc* the person: it names the page and carries the two values the page needs.*/function allowHint(service, provider) {-- const base = String(config.baseUrl || 'https://ai.openvibe.services').replace(/\/+$/, '');++ const base = String(config.exchangeUrl || 'https://openvibe.exchange').replace(/\/+$/, '');const q = new URLSearchParams({ allow: String(service || ''), provider: String(provider || '') });-- return `allow ${service} to use your ${provider} key at ${base}/you?${q.toString()}`;++ return `allow ${service} to use your ${provider} key at ${base}/privacy/keys?${q.toString()}`;}/**@@@ -384,6 +384,16 @@ function createCredentials({ db, config, clock = { now: () => Date.now() }, fetcreturn (await selfRows(subject)).length > 0;}++ /** A person's provider balance, when that provider exposes one; the key stays inside AI. */++ async function balanceSelf(subject, provider) {++ const own = await selfForRun(subject, provider);++ if (!own || !own.apiKey) return null;++ const base = own.base_url || PROVIDER_BASE_URL[provider];++ if (!base) return null;++ const balance = await balanceOf(provider, base, own.apiKey, doFetch, testTimeout());++ return balance == null ? null : { provider, balance };++ }++/** A person's own credential for one provider (no key returned to the caller; server-side use only). */async function selfForRun(subject, provider) {if (provider === 'node') {@@@ -587,7 +597,7 @@ function createCredentials({ db, config, clock = { now: () => Date.now() }, fetcreturn {put, get, remove, admit, forRun, spentToday, providerKeyFor,-- listSelf, hasSelf, selfForRun, selfForAllRuns, putSelf, removeSelf, testSelf, markFailed, getSelfNode, putSelfNode, removeSelfNode,++ listSelf, hasSelf, selfForRun, selfForAllRuns, putSelf, removeSelf, testSelf, balanceSelf, markFailed, getSelfNode, putSelfNode, removeSelfNode,allowedProvidersWithKey, consentUse,};}@@@ -428,6 +428,8 @@ function llmsTxt({ siteUrl }) {],});const optional = seo.llmsTxt({ name: '', sections: [], optional: [++ { title: 'Owner AI operator console', url: 'https://openvibe.exchange/console' },++ { title: 'Your keys and accounts', url: 'https://openvibe.exchange/privacy/keys' },{ title: 'What shipped on OpenVibe.AI', url: `${siteUrl}/updates` },{ title: 'Source (AGPL-3.0)', url: SOURCE },{ title: 'Sitemap', url: `${siteUrl}/sitemap.xml` },@@@ -15,9 +15,9 @@* `on_behalf_of`, whatever capabilities it holds (memory rule, AI #204/#205): a Codes run token holds ai.run.create,* so a capability check alone would admit an agent. `ai.me.*` are person capabilities, never a service's.*-- * Writes are stricter than reads. A user token must carry audience `openvibe.ai` explicitly: a token minted for-- * `openvibe.network` is every OpenVibe site's session, and a site's server holding it must not be able to point a-- * person's prompts at its own endpoint or delete their keys. Such a token may still read (hints and settings only).++ * Writes are stricter than reads. A user token must carry audience `openvibe.ai` explicitly, except Exchange may++ * forward the signed-in person's `openvibe.network` token from its configured same-origin server request. Service++ * tokens are always refused, including those with on_behalf_of; no caller can write another person's credentials.* AI's own person session must prove same-origin for a write: with neither Origin nor Sec-Fetch-Site the write is* refused, so a site cannot use the cookie with a bare request. Reads keep accepting that cookie as before.*@@@ -51,7 +51,12 @@ function sameOrigin(req, config, { write = false } = {}) {if (!site && !origin) return !write;if (site && site !== 'same-origin' && site !== 'none') return false;if (origin) {-- try { return new URL(origin).origin === new URL(config.baseUrl).origin; } catch { return false; }++ try {++ const actual = new URL(origin).origin;++ return [config.baseUrl, config.exchangeUrl].some((base) => {++ try { return actual === new URL(base).origin; } catch { return false; }++ });++ } catch { return false; }}return true;}@@@ -84,9 +89,13 @@ function meRouter({ credentials, preferences, grants, auth, registry, config, clreturn http.sendProblem(res, 403, 'capability.denied', { detail: "a principal token is not accepted on the person's own route", ctx });}if (claims.on_behalf_of != null) return http.sendProblem(res, 403, 'capability.denied', { detail: 'a token acting on behalf of someone is not the person themselves', ctx });-- // A token minted for openvibe.network is every OpenVibe site's session: it may read hints and settings,-- // but a write needs a token whose aud explicitly names AI itself.-- if (write && !audiencesOf(claims).includes(WRITE_AUDIENCE)) {++ // A token minted for openvibe.network is every OpenVibe site's session. It may write only when the++ // request came from Exchange (which holds the user's token in its httpOnly session),++ // or when the person minted an AI-audience token directly. No service token can enter this route.++ const audiences = audiencesOf(claims);++ const exchangeWrite = audiences.includes('openvibe.network') && sameOrigin(req, config, { write: true })++ && req.get('origin') && new URL(req.get('origin')).origin === new URL(config.exchangeUrl).origin;++ if (write && !audiences.includes(WRITE_AUDIENCE) && !exchangeWrite) {return http.sendProblem(res, 403, 'capability.denied', { detail: 'a write needs a user token for audience openvibe.ai; a site session token may only read these settings', ctx });}const subject = SUBJECT_RE.test(String(claims.subject_id || '')) ? claims.subject_id : (SUBJECT_RE.test(String(claims.sub || '')) ? claims.sub : '');@@@ -126,7 +135,11 @@ function meRouter({ credentials, preferences, grants, auth, registry, config, clconst saved = await credentials.putSelf(personOf(req), body.provider, body);return { credential: { id: saved.provider, kind: 'api_key', provider: saved.provider, created: saved.created_at, label: body.label || saved.provider } };}));-- r.get('/api/v1/me/credentials/:id/balance', guard, run(async (req) => ({ balance: await sessionCookies.balance('self', personOf(req), req.params.id) })));++ r.get('/api/v1/me/credentials/:id/balance', guard, run(async (req) => {++ const cookieBalance = await sessionCookies.balance('self', personOf(req), req.params.id);++ if (cookieBalance) return { balance: cookieBalance };++ return { balance: await credentials.balanceSelf(personOf(req), req.params.id) };++ }));r.delete('/api/v1/me/credentials/:id', guard, async (req, res) => { try { const gone = await sessionCookies.remove('self', personOf(req), req.params.id) || await credentials.removeSelf(personOf(req), req.params.id); res.status(gone ? 204 : 404).end(); } catch (err) { sendError(res, err, req.ov); } });}r.put('/api/v1/me/credentials/:provider', guard, run(async (req) => {@@@ -240,7 +240,7 @@ const GPU_SCHEMA = Object.freeze({'x-owner-input': {group: 'gpus',unlocks: 'Lets AI rent GPUs up to this amount each month. Without a cap nothing is rented, even with a key.',-- get_it_at: 'https://ai.openvibe.services/console/gpus',++ get_it_at: 'https://openvibe.exchange/console/gpus',get_it_how: 'Pick the most you are happy to spend on GPUs in a month; the GPU page shows what this month has cost so far.',test: false,},ddeleted file mode 100644@@@ -1,315 +0,0 @@--'use strict';--/**-- * Your AI (plan L36 Y1, Y3, Y8): GET /you on ai.openvibe.services. A person's own provider keys and how their AI is-- * paid, in one place for every OpenVibe site and agent. Save a key once, test it, replace it or remove it; choose-- * "only my keys" (never anything paid) or "my keys first, then OpenVibe's allowance", free tiers first, and a price-- * ceiling. What it all cost lives on openvibe.report/you (L40, L41).-- *-- * Server-rendered forms on AI's own person session (the run receipt's sign-in, /auth/person/login), each POST behind-- * a strict same-origin check: Sec-Fetch-Site same-origin, or an Origin header naming this site; neither is refused.-- * The page calls the same functions the person's API does (server/credentials.js, server/person-preferences.js), so-- * a key is sealed the same way and only its last four characters ever come back.-- *-- * GET /you the page (signed out: what it is and a sign-in)-- * POST /you/keys/:provider save or replace a key (key, base_url for "custom")-- * POST /you/keys/:provider/test test it now-- * POST /you/keys/:provider/remove forget it-- * POST /you/preferences only_my_keys, free_first, price_ceiling_usd_per_mtok-- * POST /you/computer a model on your own computer (model; any of your computers)-- * POST /you/computer/remove forget it-- *-- * Your computer (plan L55 C3): a model the person runs at home, reached through OpenVibe.Bot and their OpenVibe Link,-- * kept here like a key but with no key: the model, and any of their computers that lets their AI use its models. An-- * app uses it only when the person allows that app below (provider node), and it never stands in for a key.-- */--const express = require('express');--const shell = require('openvibe-shared/shell');--const ovServe = require('openvibe-shared/serve');--const appIcon = require('openvibe-shared/app-icon');--const { CREDENTIAL_PROVIDERS, CREDENTIAL_PRESET, byId } = require('./console/presets');--const { HOME_CSP } = require('./home');----const SITE_NAME = 'OpenVibe.AI';--const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);--// The order people look for them in: the big labs, then the fast and cheap, then the routers, then their own.--const ORDER = ['anthropic', 'openai', 'gemini', 'deepseek', 'mistral', 'xai', 'groq', 'cerebras', 'together', 'fireworks', 'openrouter', 'minimax', 'custom'];--const NAMES = { deepseek: 'DeepSeek', minimax: 'MiniMax', custom: 'Another OpenAI-compatible API', node: 'Your computer' };--// What an app is allowed to use, in words: all your keys, one key, or your computer's models.--const useWords = (provider) => (provider === '*' ? 'keys' : provider === 'node' ? 'computer\'s models' : `${providerName(provider)} key`);--const providerName = (id) => NAMES[id] || (byId(CREDENTIAL_PRESET[id]) || {}).name || id;--const keysUrl = (id) => (byId(CREDENTIAL_PRESET[id]) || {}).keys || null;--const ago = (iso) => {-- const t = Date.parse(iso || '');-- if (!Number.isFinite(t)) return '';-- const m = Math.round((Date.now() - t) / 60000);-- return m < 1 ? 'just now' : m < 60 ? `${m} min ago` : m < 1440 ? `${Math.round(m / 60)} h ago` : `${Math.round(m / 1440)} d ago`;--};----const STYLE = `<style>--.you{max-width:900px;margin:0 auto;padding:clamp(18px,4vw,44px) clamp(16px,3vw,28px) 90px;font-family:Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:var(--text-primary,#e6edf7)}--.you h1,.you h2{font-family:'OV Brand',Nunito,Inter,-apple-system,sans-serif;font-weight:900}--.you-eye{margin:0 0 8px;color:var(--accent,#3b82f6);font-size:12px;font-weight:750;letter-spacing:.12em;text-transform:uppercase}--.you h1{margin:0;font-size:clamp(30px,5vw,48px);line-height:1.06;letter-spacing:-.02em;text-wrap:balance}--.you-lede{margin:12px 0 0;max-width:62ch;color:var(--text-secondary,#96a7c2);font-size:16.5px;line-height:1.6}--.you-state{display:flex;flex-wrap:wrap;gap:8px;margin:18px 0 0;padding:0;list-style:none}--.you-state li{padding:5px 12px;border-radius:999px;border:1px solid var(--border,#1f2d47);font-size:13.5px;color:var(--text-secondary,#96a7c2)}--.you-state b{color:var(--text-primary,#e6edf7)}--.you section{margin-top:34px}--.you h2{margin:0 0 6px;font-size:20px}--.you-sub{margin:0 0 14px;color:var(--text-secondary,#96a7c2);font-size:14.5px;line-height:1.55;max-width:66ch}--.you-keys{list-style:none;margin:0;padding:0;border:1px solid var(--border,#1f2d47);border-radius:16px;overflow:hidden}--.you-key{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 14px;align-items:center;padding:14px 16px;border-top:1px solid var(--border,#1f2d47);background:var(--bg-secondary,#101828)}--.you-key:first-child{border-top:0}--.you-key-t{display:flex;flex-wrap:wrap;align-items:center;gap:6px 10px;min-width:0}--.you-key-t b{font-size:15.5px}--.you-key-n{grid-column:1/-1;color:var(--text-muted,#7d8ba3);font-size:13px;overflow-wrap:anywhere}--.you-pill{padding:2px 9px;border-radius:999px;font-size:12px;font-weight:750;background:var(--bg-primary,#0b1220);color:var(--text-secondary,#96a7c2)}--.you-pill.is-ok{color:#16a34a;background:color-mix(in srgb,#16a34a 15%,transparent)}--.you-pill.is-bad{color:#ef4444;background:color-mix(in srgb,#ef4444 15%,transparent)}--.you-acts{display:flex;flex-wrap:wrap;gap:6px;justify-content:flex-end}--.you-acts form{margin:0}--.you-btn{display:inline-flex;align-items:center;justify-content:center;min-height:38px;padding:0 14px;border-radius:999px;border:1px solid var(--border,#1f2d47);background:transparent;color:var(--text-primary,#e6edf7);font:inherit;font-size:13.5px;font-weight:650;text-decoration:none;cursor:pointer}--.you-btn:hover{background:var(--bg-primary,#0b1220)}--.you-btn.is-go{border-color:transparent;background:var(--accent-strong,var(--accent,#3b82f6));color:var(--on-accent-strong,#fff)}--.you-btn:focus-visible{outline:2px solid var(--accent,#3b82f6);outline-offset:2px}--.you-add{grid-column:1/-1}--.you-add summary{display:inline-flex;cursor:pointer;list-style:none}--.you-add summary::-webkit-details-marker{display:none}--.you-add form{display:flex;flex-wrap:wrap;gap:8px;align-items:flex-end;margin-top:10px}--.you-add label{display:grid;gap:4px;flex:1 1 220px;font-size:12.5px;font-weight:700;color:var(--text-secondary,#96a7c2)}--.you input[type=password],.you input[type=url],.you input[type=number],.you input[type=text]{min-height:42px;box-sizing:border-box;width:100%;padding:0 12px;border-radius:10px;border:1px solid var(--border,#1f2d47);background:var(--bg-primary,#0b1220);color:var(--text-primary,#e6edf7);font:inherit;font-size:15px}--.you-pay{display:grid;gap:10px;padding:16px;border-radius:16px;border:1px solid var(--border,#1f2d47);background:var(--bg-secondary,#101828)}--.you-pay fieldset{margin:0;padding:0;border:0;display:grid;gap:8px}--.you-pay legend{margin-bottom:6px;font-weight:700}--.you-opt{display:flex;gap:10px;align-items:flex-start;padding:10px 12px;border-radius:12px;border:1px solid var(--border,#1f2d47);cursor:pointer}--.you-opt:has(input:checked){border-color:var(--accent,#3b82f6);background:color-mix(in srgb,var(--accent,#3b82f6) 10%,transparent)}--.you-opt input{margin-top:3px;accent-color:var(--accent,#3b82f6)}--.you-opt span{display:grid;gap:2px}.you-opt small{color:var(--text-secondary,#96a7c2);font-size:13px}--.you-row{display:flex;flex-wrap:wrap;gap:10px 18px;align-items:flex-end}--.you-row>label:not(.you-opt){display:grid;gap:4px;font-size:12.5px;font-weight:700;color:var(--text-secondary,#96a7c2)}--.you-pc-form{display:flex;flex-wrap:wrap;gap:8px;align-items:flex-end;margin-top:12px}--.you-pc-form label{display:grid;gap:4px;flex:1 1 220px;font-size:12.5px;font-weight:700;color:var(--text-secondary,#96a7c2)}--.you-pcs{margin:0;padding-left:20px;color:var(--text-secondary,#96a7c2);font-size:14px;line-height:1.75}--.you-pcs b{color:var(--text-primary,#e6edf7)}--.you code{padding:1px 6px;border-radius:6px;background:var(--bg-primary,#0b1220);font-size:13px}--.you-new{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:8px}--.you-new>li{padding:12px 14px;border-radius:14px;border:1px solid var(--border,#1f2d47);background:var(--bg-secondary,#101828)}--.you-new>li:has(details[open]){grid-column:1/-1}--.you-new summary{display:flex;align-items:center;justify-content:space-between;gap:8px;cursor:pointer;list-style:none;font-weight:700}--.you-new summary::-webkit-details-marker{display:none}--.you-new summary span{color:var(--accent,#3b82f6);font-size:13px;font-weight:650}--.you-new form{display:flex;flex-wrap:wrap;gap:8px;align-items:flex-end;margin-top:12px}--.you-new label{display:grid;gap:4px;flex:1 1 220px;font-size:12.5px;font-weight:700;color:var(--text-secondary,#96a7c2)}--.you-empty{padding:16px;border-radius:14px;border:1px dashed var(--border,#1f2d47);color:var(--text-secondary,#96a7c2)}--.you-note{margin:10px 0 0;padding:10px 14px;border-radius:12px;font-size:14px}--.you-note.is-ok{background:color-mix(in srgb,#16a34a 14%,transparent)}.you-note.is-bad{background:color-mix(in srgb,#ef4444 14%,transparent)}--.you-fine{color:var(--text-muted,#7d8ba3);font-size:13px;line-height:1.55;max-width:70ch}--@media (max-width:560px){.you-key{grid-template-columns:minmax(0,1fr)}.you-acts{justify-content:flex-start}}--</style>`;----function frame({ title, body, siteUrl }) {-- return shell.page({-- name: SITE_NAME, service: 'ai', lang: 'en', title, siteName: SITE_NAME,-- description: 'Your own AI keys for every OpenVibe site and agent: saved once, no fee, and you choose how your AI is paid.',-- canonical: `${String(siteUrl).replace(/\/+$/, '')}/you`, robots: 'noindex, nofollow',-- navbar: { service: 'ai', apiBase: 'https://openvibe.network', links: [{ label: 'Your AI', href: '/you', active: true }, { label: 'Prices', href: '/stats' }], history: { type: 'page', title } },-- footer: { service: 'ai', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' },-- home: '/', navLinks: [{ label: 'Your AI', href: '/you' }, { label: 'Prices', href: '/stats' }],-- head: [appIcon.headTags({ site: 'ai' }), STYLE].join('\n'),-- body: `<div id="navbar-mount"></div>\n<main id="main" class="you">${body}</main>`,-- });--}----function signedOut({ siteUrl }) {-- return frame({ siteUrl, title: 'Your AI', body: `<p class="you-eye">Your AI</p><h1>Your keys, on every OpenVibe site.</h1>--<p class="you-lede">Save a key from Anthropic, OpenAI, Google, DeepSeek or any provider once, and every OpenVibe site and agent can use it, with no fee, ever. You choose whether anything else may pay.</p>--<p style="margin-top:20px"><a class="you-btn is-go" href="/auth/person/login?next=${encodeURIComponent('/you')}">Sign in to see yours</a></p>` });--}----/** The app names a grant may name, for a person who reads the list. */--const APP_NAMES = { actor: 'OpenVibe.Actor', codes: 'OpenVibe.Codes', chat: 'OpenVibe.Chat', live: 'OpenVibe.Live', vision: 'OpenVibe.Vision' };--const appName = (id) => APP_NAMES[id] || id;--/** The link a service sends: /you?allow=<app>&provider=<provider>. Only a confirm button follows it; a POST changes it. */--function allowRequest(query) {-- const q = query || {};-- const service = String(q.allow || '').trim().toLowerCase();-- // Only OpenVibe's own apps are offered: a link naming anything else shows no button (server/key-grants.js APPS).-- if (!/^[a-z][a-z0-9._-]{1,39}$/.test(service) || !require('./key-grants').APPS.includes(service)) return null;-- const raw = String(q.provider == null ? '*' : q.provider).trim().toLowerCase();-- const provider = raw === '*' || raw === '' ? '*' : (/^[a-z][a-z0-9._-]{0,39}$/.test(raw) ? raw : null);-- if (!provider) return null;-- return { service, provider };--}----/** Your computer: the saved model and where it runs, the form, and which of your computers can answer now. */--function computerSection(pc, machines) {-- const models = [...new Set(machines.flatMap((m) => m.models))].slice(0, 60);-- const onNow = machines.filter((m) => m.online);-- const line = (m) => `<li><b>${esc(m.name)}</b>${m.online ? '' : ' <span class="you-pill">away</span>'}${m.models.length ? ` · ${esc(m.models.slice(0, 6).join(', '))}${m.models.length > 6 ? ` and ${m.models.length - 6} more` : ''}` : ' · no models listed'}${m.tools ? '' : ' · <span class="you-pill">update OpenVibe Link to v0.36.0 for agents</span>'}</li>`;-- const where = machines.length-- ? `<ul class="you-pcs">${machines.map(line).join('')}</ul>`-- : '<p class="you-empty">None of your computers lets OpenVibe use its models yet. On the computer, run a model server (Ollama, LM Studio or llama.cpp) and OpenVibe Link with <code>--ai</code>, then turn on “Let my AI use this computer’s models” at <a href="https://openvibe.bot/machines">openvibe.bot/machines</a>.</p>';-- const saved = pc ? `<ul class="you-keys"><li class="you-key"><div class="you-key-t"><b>Any of your computers</b><span class="you-pill${onNow.some((m) => m.models.includes(pc.model)) ? ' is-ok' : ''}">${esc(pc.model || 'no model')}</span></div>--<div class="you-acts"><form method="post" action="/you/computer/remove"><button class="you-btn" type="submit">Remove</button></form></div>--<div class="you-key-n">${onNow.some((m) => m.models.includes(pc.model)) ? 'On now, with this model' : 'None of your computers with this model is on right now'}${pc.last_used_at ? ` · used ${esc(ago(pc.last_used_at))}` : ''}</div></li></ul>` : '';-- const form = `<form class="you-pc-form" method="post" action="/you/computer"><label>Model${pc ? ' (change it)' : ''}<input type="text" name="model" required maxlength="120" pattern="\\S+" list="you-pc-models" autocomplete="off" spellcheck="false" placeholder="${esc(models[0] || 'qwen3:8b')}" value="${esc(pc && pc.model ? pc.model : '')}"></label><datalist id="you-pc-models">${models.map((m) => `<option value="${esc(m)}">`).join('')}</datalist><button class="you-btn is-go" type="submit">Save</button></form>`;-- return `<section aria-labelledby="you-pc"><h2 id="you-pc">Your computer</h2><p class="you-sub">A model you run at home answers your own work: no key, nothing spent, and the conversation stays between OpenVibe and your machine. Whichever of your computers is on with the model answers, the fastest first. Only the apps you allow below may use it.</p>--${saved}${form}--<h3 style="margin:18px 0 8px;font-size:15px">Your computers</h3>${where}</section>`;--}----function page({ siteUrl, keys = [], prefs = {}, apps = [], allow = null, ok = '', error = '', pc = null, machines = [] }) {-- const mine = new Map(keys.map((k) => [k.provider, k]));-- const working = keys.filter((k) => k.status === 'ok').length;-- const keyForm = (id, k) => `<form method="post" action="/you/keys/${esc(id)}"><label>${k ? 'New key' : 'Key'}<input type="password" name="key" required minlength="8" maxlength="400" autocomplete="off" spellcheck="false" placeholder="${id === 'anthropic' ? 'sk-ant-…' : 'sk-…'}"></label>${id === 'custom' ? `<label>Base URL<input type="url" name="base_url" required placeholder="https://…/v1" autocomplete="off"></label>` : ''}<button class="you-btn is-go" type="submit">Save</button>${keysUrl(id) ? `<a class="you-btn" href="${esc(keysUrl(id))}" rel="noopener" target="_blank">Get a key ↗</a>` : ''}</form>`;-- const row = (id) => {-- const k = mine.get(id);-- const pill = k.status === 'ok' ? '<span class="you-pill is-ok">Working</span>'-- : k.status === 'failing' ? '<span class="you-pill is-bad">Not working</span>' : '<span class="you-pill">Not tested yet</span>';-- const note = [`…${esc(k.hint)}`, k.base_url ? esc(k.base_url) : '', k.models_count ? `${k.models_count} models` : '', k.checked_at ? `checked ${ago(k.checked_at)}` : '', k.status === 'failing' && k.last_error ? esc(String(k.last_error).slice(0, 140)) : ''].filter(Boolean).join(' · ');-- return `<li class="you-key"><div class="you-key-t"><b>${esc(providerName(id))}</b>${pill}</div>--<div class="you-acts"><form method="post" action="/you/keys/${esc(id)}/test"><button class="you-btn" type="submit">Test</button></form><form method="post" action="/you/keys/${esc(id)}/remove"><button class="you-btn" type="submit">Remove</button></form></div>--${note ? `<div class="you-key-n">${note}</div>` : ''}--<details class="you-add"><summary class="you-btn">Replace the key</summary>${keyForm(id, k)}</details></li>`;-- };-- const known = ORDER.filter((id) => CREDENTIAL_PROVIDERS.includes(id));-- const saved = known.filter((id) => mine.has(id));-- const toAdd = known.filter((id) => !mine.has(id));-- const addCard = (id) => `<li><details><summary>${esc(providerName(id))}<span>Add</span></summary>${keyForm(id, null)}</details></li>`;-- const only = prefs.only_my_keys === true;-- // L55 C3: the apps the person allowed to use their own key, and the one that is asking.-- const appRow = (g) => `<li class="you-key"><div class="you-key-t"><b>${esc(appName(g.service))}</b><span class="you-pill">${g.provider === '*' ? 'all your keys' : g.provider === 'node' ? 'your computer' : esc(providerName(g.provider))}</span></div>--<div class="you-acts"><form method="post" action="/you/apps/remove"><input type="hidden" name="service" value="${esc(g.service)}"><input type="hidden" name="provider" value="${esc(g.provider)}"><button class="you-btn" type="submit">Remove</button></form></div>--<div class="you-key-n">allowed ${esc(g.created_at ? ago(g.created_at) : 'just now')}</div></li>`;-- const appList = apps.length ? `<ul class="you-keys">${apps.map(appRow).join('')}</ul>` : '<p class="you-empty">No app may use your keys yet. An app that needs one asks you here, and never takes it by itself.</p>';-- const allowBlock = allow ? `<section aria-labelledby="you-allow"><h2 id="you-allow">Let ${esc(appName(allow.service))} use your ${esc(useWords(allow.provider))}?</h2><p class="you-sub">${esc(appName(allow.service))} asked to run your work on ${allow.provider === 'node' ? 'a model on your own computer' : 'your own key'}, so the call costs you nothing. No other app may use it, and you can take it away at any time.</p>${allow.provider === 'node' && !pc ? '<p class="you-sub">Add the model under “Your computer” first, then allow it here.</p>' : ''}--<form method="post" action="/you/apps/allow"><input type="hidden" name="service" value="${esc(allow.service)}"><input type="hidden" name="provider" value="${esc(allow.provider)}"><button class="you-btn is-go" type="submit">Allow</button></form></section>` : '';-- const body = `<p class="you-eye">Your AI</p><h1>Your keys, on every OpenVibe site.</h1>--<p class="you-lede">A key saved here works on every OpenVibe site and agent, with no fee, ever. It is sealed the moment it arrives; only its last four characters are ever shown again.</p>--<ul class="you-state"><li><b>${keys.length}</b> ${keys.length === 1 ? 'key' : 'keys'}${keys.length ? `, ${working} working` : ''}</li><li>${only ? '<b>Only your keys</b>' : 'Your keys first'}</li><li>Free tiers first: <b>${prefs.free_first === false ? 'off' : 'on'}</b></li></ul>--${ok ? `<p class="you-note is-ok" role="status">${esc(ok)}</p>` : ''}${error ? `<p class="you-note is-bad" role="alert">${esc(error)}</p>` : ''}--<section aria-labelledby="you-keys"><h2 id="you-keys">Your keys</h2><p class="you-sub">Each is tested when you save it: one tiny request, and the models it can reach. A key that stops working is marked here and never quietly swapped for a paid one.</p>--${saved.length ? `<ul class="you-keys">${saved.map(row).join('')}</ul>` : '<p class="you-empty">No keys yet. Add one below: Anthropic, OpenAI, Google or DeepSeek are good first ones.</p>'}--<h3 style="margin:22px 0 10px;font-size:15px">Add a key</h3>--<ul class="you-new">${toAdd.map(addCard).join('')}</ul></section>--${allowBlock}--${computerSection(pc, machines)}--<section aria-labelledby="you-pay"><h2 id="you-pay">How your AI is paid</h2><p class="you-sub">For every site and agent that runs AI for you.</p>--<form class="you-pay" method="post" action="/you/preferences">--<fieldset><legend>When none of your keys can do a job</legend>--<label class="you-opt"><input type="radio" name="only_my_keys" value="0"${only ? '' : ' checked'}><span><b>Use OpenVibe’s allowance</b><small>Your keys always go first; a job none of them can do runs on the network’s own, within its free allowance.</small></span></label>--<label class="you-opt"><input type="radio" name="only_my_keys" value="1"${only ? ' checked' : ''}><span><b>Only my keys</b><small>Nothing else ever pays for your AI. A job none of your keys can do stops and says which provider would do it.</small></span></label>--</fieldset>--<div class="you-row"><label class="you-opt" style="flex:1 1 260px"><input type="checkbox" name="free_first" value="1"${prefs.free_first === false ? '' : ' checked'}><span><b>Free tiers first</b><small>Use a provider’s free tier before anything that costs.</small></span></label>--<label>Most per million tokens (USD)<input type="number" name="price_ceiling_usd_per_mtok" min="0" max="1000" step="0.01" value="${prefs.price_ceiling_usd_per_mtok == null ? '' : esc(prefs.price_ceiling_usd_per_mtok)}" placeholder="No limit"></label>--<button class="you-btn is-go" type="submit">Save</button></div>--</form></section>--<section aria-labelledby="you-apps"><h2 id="you-apps">Apps that may use my keys</h2><p class="you-sub">Only the apps you allow here may run on your own key. Nothing uses it by default, and a key never leaves OpenVibe.AI.</p>--${appList}</section>--<section aria-labelledby="you-cost"><h2 id="you-cost">What it cost</h2><p class="you-sub">Tokens, how much came from cache, what each call cost and whose key paid, across every site: <a href="https://openvibe.report/you">on openvibe.report</a>.</p></section>--<p class="you-fine">Removing a key deletes it from OpenVibe.AI. Keys are personal: one serves your own work and nobody else’s. Sites and agents that run AI for you use these settings; the ones you have not opened never see your keys.</p>`;-- return frame({ siteUrl, title: 'Your AI', body });--}----function youPageRouter({ credentials, preferences, grants, personSessions, config, log = console, machines = null }) {-- const r = express.Router();-- const form = express.urlencoded({ extended: false, limit: '4kb' });-- const site = new URL(config.baseUrl).origin;-- // A POST from this site only: Sec-Fetch-Site same-origin, or an Origin naming this site. Neither is refused.-- const sameSite = (req) => {-- const sfs = req.headers['sec-fetch-site'];-- if (sfs) return sfs === 'same-origin';-- const origin = req.headers.origin;-- if (!origin) return false;-- try { return new URL(origin).origin === site; } catch { return false; }-- };-- const headers = (res) => res.set({ 'Content-Security-Policy': HOME_CSP, 'Cache-Control': 'no-store', 'X-Robots-Tag': 'noindex, nofollow', 'Referrer-Policy': 'same-origin' });-- const back = (res, q) => res.redirect(303, `/you?${new URLSearchParams(q)}`);-- async function person(req, res) {-- if (!personSessions) { headers(res).status(503).type('text/plain').send('Sign-in is not configured on this host.\n'); return null; }-- const viewer = await personSessions.read(req);-- if (!viewer) { res.redirect(303, `/auth/person/login?next=${encodeURIComponent('/you')}`); return null; }-- return viewer;-- }-- const guard = (fn) => [form, async (req, res) => {-- if (!sameSite(req)) return headers(res).status(403).type('text/plain').send('Open this form on ai.openvibe.services.\n');-- const viewer = await person(req, res);-- if (!viewer) return;-- try { await fn(req, res, viewer.subject); } catch (err) {-- const word = err && err.status && err.status < 500 ? err.message : 'That did not work this time; try again in a minute.';-- if (!err || !err.status || err.status >= 500) log.warn(`[you] ${req.path}: ${err && err.message}`);-- back(res, { error: String(word).slice(0, 200) });-- }-- }];---- r.get('/you', async (req, res) => {-- headers(res);-- const viewer = personSessions ? await personSessions.read(req) : null;-- if (!viewer) return res.type('html').send(signedOut({ siteUrl: config.baseUrl }));-- const [keys, prefs, apps, pc, mine] = await Promise.all([-- credentials.listSelf(viewer.subject).catch(() => []),-- preferences.get(viewer.subject).catch(() => ({})),-- grants.list(viewer.subject).catch(() => []),-- credentials.getSelfNode ? credentials.getSelfNode(viewer.subject).catch(() => null) : null,-- machines && machines.mine ? machines.mine(viewer.subject).catch(() => []) : [],-- ]);-- res.type('html').send(page({ siteUrl: config.baseUrl, keys, prefs, apps, pc, machines: mine, allow: allowRequest(req.query), ok: String(req.query.ok || '').slice(0, 200), error: String(req.query.error || '').slice(0, 200) }));-- });-- r.post('/you/keys/:provider', ...guard(async (req, res, subject) => {-- const id = req.params.provider;-- if (!CREDENTIAL_PROVIDERS.includes(id)) return back(res, { error: 'That provider is not one OpenVibe.AI knows.' });-- const body = { provider: id, key: String((req.body || {}).key || '').trim(), ...(id === 'custom' && req.body.base_url ? { base_url: String(req.body.base_url).trim() } : {}) };-- const out = await credentials.putSelf(subject, id, body);-- const tested = out.status === 'untested' && credentials.testSelf ? await credentials.testSelf(subject, id).catch(() => null) : null;-- const okNow = (tested ? tested.ok : out.status === 'ok');-- back(res, okNow ? { ok: `Saved your ${providerName(id)} key, and it works.` } : { error: `Saved your ${providerName(id)} key, but it did not work: ${String((tested && tested.error) || out.last_error || 'the provider refused it').slice(0, 120)}` });-- }));-- r.post('/you/keys/:provider/test', ...guard(async (req, res, subject) => {-- const out = await credentials.testSelf(subject, req.params.provider);-- back(res, out.ok ? { ok: `Your ${providerName(req.params.provider)} key works${out.models_count ? `: ${out.models_count} models` : ''}.` } : { error: `Your ${providerName(req.params.provider)} key did not work: ${String(out.error || 'the provider refused it').slice(0, 120)}` });-- }));-- r.post('/you/keys/:provider/remove', ...guard(async (req, res, subject) => {-- const gone = await credentials.removeSelf(subject, req.params.provider);-- back(res, gone ? { ok: `Removed your ${providerName(req.params.provider)} key.` } : { error: 'There was no key to remove.' });-- }));-- // L55 C3: the person allows an app to use their own key (one provider, or all of them). A POST on this site-- // only; the link an app sends (/you?allow=...) only shows the button.-- r.post('/you/apps/allow', ...guard(async (req, res, subject) => {-- const body = req.body || {};-- const service = String(body.service || '').trim().toLowerCase();-- const provider = String(body.provider == null ? '*' : body.provider).trim().toLowerCase() || '*';-- if (!/^[a-z][a-z0-9._-]{1,39}$/.test(service) || !require('./key-grants').APPS.includes(service)) return back(res, { error: 'That is not an OpenVibe app that may use a key.' });-- await grants.allow(subject, service, provider);-- back(res, { ok: `${appName(service)} may use your ${useWords(provider)} now.` });-- }));-- // Your computer (L55 C3): the model, on any of the person's computers. No key: nothing to seal or test here.-- r.post('/you/computer', ...guard(async (req, res, subject) => {-- const saved = await credentials.putSelfNode(subject, { machine: 'any', model: String((req.body || {}).model || '').trim() });-- back(res, { ok: `Saved: ${saved.model}, on whichever of your computers is on with it.` });-- }));-- r.post('/you/computer/remove', ...guard(async (req, res, subject) => {-- const gone = await credentials.removeSelfNode(subject);-- back(res, gone ? { ok: 'Removed your computer\'s model. No app can use it now.' } : { error: 'There was nothing to remove.' });-- }));-- r.post('/you/apps/remove', ...guard(async (req, res, subject) => {-- const body = req.body || {};-- const service = String(body.service || '').trim().toLowerCase();-- const provider = body.provider == null || body.provider === '' ? null : String(body.provider).trim().toLowerCase();-- const gone = await grants.remove(subject, service, provider);-- back(res, { ok: gone ? `${appName(service)} may not use your keys any more.` : 'There was nothing to take away.' });-- }));-- r.post('/you/preferences', ...guard(async (req, res, subject) => {-- const b = req.body || {};-- const cur = await preferences.get(subject).catch(() => ({}));-- const ceiling = String(b.price_ceiling_usd_per_mtok || '').trim();-- await preferences.put(subject, {-- only_my_keys: b.only_my_keys === '1', free_first: b.free_first === '1',-- price_ceiling_usd_per_mtok: ceiling === '' ? null : Number(ceiling),-- defaults: cur.defaults || { text: 'auto', code: 'auto', vision: 'auto', image: 'auto' },-- });-- back(res, { ok: 'Saved how your AI is paid.' });-- }));-- return r;--}----module.exports = { youPageRouter, page, signedOut };nnew file mode 100644@@@ -0,0 +1,53 @@++'use strict';++++const assert = require('assert');++const express = require('express');++const { createServer } = require('http');++const { consoleDataRouter } = require('../server/api/console-data');++++const secret = 'sk-console-secret-not-in-json';++const saved = [];++const audit = [];++const app = express();++const auth = { requireCap: () => (req, _res, next) => { req.principal = { sub: 'svc:exchange' }; req.ov = { traceId: 'trace-test' }; next(); } };++app.use(consoleDataRouter({++ db: {}, registry: {++ async listProviders() { return [{ key: 'openai', secret_ref: 'vault:openai' }]; },++ async listModels() { return []; },++ async getProvider(key) { return key === 'openai' ? { key, base_url: 'https://api.openai.com/v1' } : null; },++ async upsertProvider(provider) { return provider; }, async audit(...args) { audit.push(args); },++ }, pool: {}, quotas: {}, cache: {}, runs: {}, auth,++ ops: { async providerView(p) { return { key: p.key, has_key: true, secret_ref: p.secret_ref }; } },++ vault: { async put(key, value) { saved.push([key, value]); } },++ settings: { providerKeys: { async setOrigin() {} } },++ fetchImpl: async () => new Response(JSON.stringify({ data: [] }), { status: 200, headers: { 'content-type': 'application/json' } }),++}));++++(async () => {++ const server = createServer(app);++ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));++ const base = `http://127.0.0.1:${server.address().port}`;++ try {++ const read = await fetch(`${base}/api/v1/admin/console/providers`);++ const body = await read.text();++ assert.strictEqual(read.status, 200);++ assert.ok(!body.includes(secret));++ const write = await fetch(`${base}/api/v1/admin/console/providers/openai/key`, {++ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ api_key: secret }),++ });++ const answer = await write.text();++ assert.strictEqual(write.status, 200, answer);++ assert.ok(!answer.includes(secret));++ assert.deepStrictEqual(saved, [['openai', secret]]);++ assert.strictEqual(audit[0][1], 'provider.key_replaced');++ const created = await fetch(`${base}/api/v1/admin/console/providers`, {++ method: 'POST', headers: { 'content-type': 'application/json' },++ body: JSON.stringify({ preset: 'custom', key: 'custom-ai', base_url: 'https://api.example.test/v1', api_key: secret }),++ });++ const createdText = await created.text();++ assert.strictEqual(created.status, 200, createdText);++ assert.ok(!createdText.includes(secret));++ assert.deepStrictEqual(saved[1], ['custom-ai', secret]);++ } finally { await new Promise((resolve) => server.close(resolve)); }++ process.stdout.write('console-data: metadata reads and secret-safe key writes passed\n');++})().catch((err) => { process.stderr.write(`${err.stack || err}\n`); process.exitCode = 1; });ddeleted file mode 100644@@@ -1,253 +0,0 @@--'use strict';--// AI Exchange on the operator console (AI_CREDITS / AI_ROUTING_EVIDENCE): staff only, the Credits settle--// queue counts and review rows with one audited "mark resolved" action that never retries, the measured--// routing evidence and incumbents, nothing from a settle body beyond the four usage counts, and no--// Exchange page or navigation when both flags are off.----const assert = require('assert');--const crypto = require('crypto');--const { serviceAuth, ids } = require('openvibe-contracts');--const { boot, suite, privateKey, ISSUER } = require('./helpers');----const t = suite('console-exchange');--const NETWORK = 'http://network.test';--const CLIENT_SECRET = `sentinel-client-${crypto.randomBytes(12).toString('hex')}`;--const SESSION_SECRET = crypto.randomBytes(32).toString('hex');--const people = { user: ids.newId('user'), admin: ids.newId('user') };----const codes = new Map();--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`) {-- // openvibe-sdk/sso posts the grant as JSON; the real Network takes that (and this stub keeps the form shape too).-- const text = String(opts.body || '');-- const body = text.trim().startsWith('{') ? JSON.parse(text) : Object.fromEntries(new URLSearchParams(text));-- 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' });-- if (crypto.createHash('sha256').update(String(body.code_verifier || '')).digest('base64url') !== c.challenge) return json(400, { error: 'invalid_grant' });-- 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);--}----const baseEnv = {-- 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_STUB_FALLBACK: 'false',--};--let h;--let adminCookie = null;--const setCookies = (res) => (res.headers.getSetCookie ? res.headers.getSetCookie() : []);--async function get(p, cookie) {-- const res = await fetch(h.base + p, { redirect: 'manual', headers: cookie ? { Cookie: cookie } : {} });-- return { status: res.status, headers: res.headers, text: await res.text(), cookies: setCookies(res) };--}--async function post(p, cookie, form) {-- const res = await fetch(h.base + p, {-- method: 'POST', redirect: 'manual',-- headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...(cookie ? { Cookie: cookie } : {}) },-- 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/exchange' } = {}) {-- 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, cookie: sess ? sess.split(';')[0] : null };--}--const csrfOf = (page) => (page.text.match(/name="_csrf" value="([^"]+)"/) || [])[1];--const sessionCount = async () => (await h.db.prepare('SELECT COUNT(*) AS n FROM console_sessions').get()).n;--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`);-- const csp = r.headers.get('content-security-policy') || '';-- assert.ok(csp.includes("default-src 'none'") && csp.includes("form-action 'self'") && !csp.includes('unsafe-inline'), `${p}: CSP`);-- assert.strictEqual(r.headers.get('x-robots-tag'), 'noindex, nofollow', p);-- 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.ok(!r.text.includes(CLIENT_SECRET) && !r.text.includes(SESSION_SECRET), `${p}: no secret`);--}----const REVIEW_A = 'ai-settle:req_REVIEWA';--const REVIEW_B = 'ai-settle:req_REVIEWB';--const REVIEW_BODY_A = JSON.stringify({-- idempotency_key: REVIEW_A,-- subject: 'SUBJECT-MARKER-usr_01JABCDEFGHJKMNPQRSTVWXYZ00',-- usage: { input_tokens: 111, cached_read_tokens: 222, cache_write_tokens: 333, output_tokens: 444 },-- retail: { version: 'RETAIL-MARKER-9f3a', nano_usd_per_m: { input: 'SUPPLY-MARKER-111', cached_read: 'SUPPLY-MARKER-222', cache_write: 'SUPPLY-MARKER-333', output: 'SUPPLY-MARKER-444' }, per_request_nano_usd: 'SUPPLY-MARKER-555' },-- supply_cost_nano_usd: 'SUPPLY-MARKER-666',-- byok: false,-- attempts: [{ deployment_id: 'DEPLOY-MARKER-xyz', started_at: '2026-11-05T10:00:00.000Z', ended_at: '2026-11-05T10:00:01.000Z', error_code: null }],--});--const REVIEW_BODY_B = JSON.stringify({-- idempotency_key: REVIEW_B,-- usage: { input_tokens: 5, cached_read_tokens: 6, cache_write_tokens: 7, output_tokens: 8 },-- retail: { version: 'SECOND-MARKER', nano_usd_per_m: { input: '0', cached_read: '0', cache_write: '0', output: '0' }, per_request_nano_usd: '0' },-- supply_cost_nano_usd: '0', byok: false, attempts: [],--});----async function seedQueue({ id, hold, request, body, status, tries, createdAt, lastError = null }) {-- const at = new Date(createdAt).toISOString();-- await h.db.prepare(`INSERT INTO credit_settle_queue-- (id, hold_id, request_id, settle_body, attempts, status, tries, next_at, last_error, created_at, updated_at)-- VALUES (?, ?, ?, ?::jsonb, '[]'::jsonb, ?, ?, ?::timestamptz, ?, ?::timestamptz, ?::timestamptz)`).run(-- id, hold, request, body, status, tries, at, lastError, at, at);--}----t.test('boot with both flags on and seed queue, metrics and incumbent rows', async () => {-- h = await boot({ env: { ...baseEnv, AI_CREDITS: 'true', AI_ROUTING_EVIDENCE: 'true' }, fetchImpl: fakeFetch });-- const now = Date.now();-- await seedQueue({ id: 'ai-settle:req_QUEUED', hold: 'hold_queued', request: 'req_QUEUED', body: REVIEW_BODY_B, status: 'queued', tries: 4, createdAt: now - 300000, lastError: 'billing unavailable' });-- await seedQueue({ id: 'ai-settle:req_SENT', hold: 'hold_sent', request: 'req_SENT', body: REVIEW_BODY_B, status: 'sent', tries: 0, createdAt: now - 200000 });-- await seedQueue({ id: REVIEW_A, hold: 'hold_review_a', request: 'req_REVIEWA', body: REVIEW_BODY_A, status: 'review', tries: 2, createdAt: now - 150000, lastError: 'billing.invalid_request: output_tokens' });-- await seedQueue({ id: REVIEW_B, hold: 'hold_review_b', request: 'req_REVIEWB', body: REVIEW_BODY_B, status: 'review', tries: 1, createdAt: now - 100000 });-- await h.db.prepare(`INSERT INTO provider_metrics-- (provider_key, model_key, ttft_ms, ttft_samples, decode_tps, decode_samples, success_rate, timeout_rate, cache_hit_ratio, cache_samples, sample_count, updated_at)-- VALUES ('alpha', 'model-a', 420, 30, 55.5, 25, 0.97, 0.01, 0.42, 22, 40, ?::timestamptz)`).run(new Date(now).toISOString());-- await h.db.prepare(`INSERT INTO provider_metrics-- (provider_key, model_key, ttft_ms, ttft_samples, decode_tps, decode_samples, success_rate, timeout_rate, cache_hit_ratio, cache_samples, sample_count, updated_at)-- VALUES ('beta', 'model-b', NULL, 0, NULL, 0, 0.5, 0.25, 0, 0, 2, ?::timestamptz)`).run(new Date(now).toISOString());-- await h.db.prepare(`INSERT INTO routing_incumbents-- (decision_key, provider_key, model_key, challenger_provider, challenger_model, challenger_streak, updated_at)-- VALUES ('openvibe/auto|chat|balanced', 'alpha', 'model-a', 'beta', 'model-b', 2, ?::timestamptz)`).run(new Date(now).toISOString());--});----t.test('anonymous get the sign-in page (401), never data', async () => {-- const r = await get('/console/exchange');-- assert.strictEqual(r.status, 401);-- assert.ok(r.text.includes('Sign in with OpenVibe.Network'));-- assert.ok(!r.text.includes('<table'));--});----t.test('non-staff are refused at sign-in (403) and get no session; the refusal is audited', async () => {-- const r = await signIn(people.user, { role: 'user' });-- assert.strictEqual(r.cb.status, 403);-- assert.strictEqual(r.cookie, null);-- assert.ok(r.cb.text.includes('only for OpenVibe.Network staff'));-- assert.strictEqual(await sessionCount(), 0);-- assert.strictEqual((await h.db.prepare("SELECT COUNT(*) AS n FROM audit_log WHERE action = 'console.sign_in.refused' AND actor = ?").get(people.user)).n, 1);-- assert.ok(revoked.length >= 1, 'the Network refresh token is revoked');--});----t.test('an admin sees both sections from the seeded rows; no settle body text beyond the four counts', async () => {-- const s = await signIn(people.admin, { username: 'ops' });-- assert.strictEqual(s.cb.status, 303);-- assert.strictEqual(s.cb.headers.get('location'), '/console/exchange');-- assert.ok(s.cookie);-- adminCookie = s.cookie;-- const page = await get('/console/exchange', s.cookie);-- assertPage(page, '/console/exchange');-- const text = page.text;-- assert.ok(text.includes('<a href="/console/exchange" aria-current="page">Exchange</a>'), 'the nav entry is current');-- assert.ok(text.includes('Credits settle queue') && text.includes('Routing evidence'), 'both sections');-- assert.match(text, /<div class="k">Queued<\/div><div class="v">1<\/div>/);-- assert.match(text, /<div class="k">Sent<\/div><div class="v">1<\/div>/);-- assert.match(text, /<div class="k">Review<\/div><div class="v">2<\/div>/);-- assert.match(text, /<div class="k">Oldest queued<\/div><div class="v">[4-6] min<\/div>/);-- assert.ok(text.includes('hold_review_a') && text.includes('hold_review_b'));-- assert.ok(text.includes('req_REVIEWA') && text.includes('billing.invalid_request: output_tokens'));-- const wraps = (text.match(/class="tablewrap"/g) || []).length;-- assert.ok(wraps >= 3, `every table is inside a scroll wrapper (${wraps})`);-- // The four settle usage counts (summed over the review rows) are the only settle-body data shown.-- assert.match(text, /tokens in 116 · cached read 228 · cache write 340 · out 452/);-- for (const marker of ['SUBJECT-MARKER-usr_01JABCDEFGHJKMNPQRSTVWXYZ00', 'RETAIL-MARKER-9f3a', 'SUPPLY-MARKER-111', 'SUPPLY-MARKER-666', 'DEPLOY-MARKER-xyz', 'SECOND-MARKER', '"byok"', 'supply_cost_nano_usd', 'nano_usd_per_m']) {-- assert.ok(!text.includes(marker), `settle body field leaked: ${marker}`);-- }-- assert.ok(text.includes('alpha:model-a') && text.includes('beta:model-b'));-- assert.ok(text.includes('97.0 %') && text.includes('1.0 %') && text.includes('420 ms') && text.includes('55.5') && text.includes('42.0 %'));-- assert.ok(text.includes('openvibe/auto|chat|balanced') && text.includes('n=30') && text.includes('n=25') && text.includes('n=22'));--});----t.test('"mark resolved" changes only that row, never retries it, and writes the console audit row', async () => {-- const page = await get('/console/exchange', adminCookie);-- const csrf = csrfOf(page);-- assert.ok(csrf, 'the page carries the CSRF token');-- const before = await h.db.prepare('SELECT * FROM credit_settle_queue WHERE id = ?').get(REVIEW_A);-- const other = await h.db.prepare('SELECT * FROM credit_settle_queue WHERE id = ?').get(REVIEW_B);-- const url = `/console/exchange/credits/${encodeURIComponent(REVIEW_A)}/resolve`;-- const r = await post(url, adminCookie, { _csrf: csrf });-- assert.strictEqual(r.status, 303, r.text.slice(0, 300));-- assert.strictEqual(r.headers.get('location'), '/console/exchange?done=settle_resolved');-- const after = await h.db.prepare('SELECT * FROM credit_settle_queue WHERE id = ?').get(REVIEW_A);-- assert.strictEqual(after.status, 'resolved');-- assert.strictEqual(after.tries, before.tries, 'no retry');-- assert.strictEqual(after.next_at, before.next_at, 'the retry schedule is untouched');-- assert.strictEqual(after.resolved_by, people.admin);-- assert.ok(after.resolved_at);-- const otherAfter = await h.db.prepare('SELECT * FROM credit_settle_queue WHERE id = ?').get(REVIEW_B);-- assert.strictEqual(otherAfter.status, other.status, 'the other review row is unchanged');-- assert.strictEqual(otherAfter.tries, other.tries);-- const auditPage = await get('/console/audit?kind=exchange', adminCookie);-- assert.ok(auditPage.text.includes('credit_settle.resolve') && auditPage.text.includes('AI Exchange'), 'the audit page lists it under AI Exchange');-- const audited = await h.db.prepare("SELECT * FROM audit_log WHERE action = 'credit_settle.resolve' ORDER BY id").all();-- assert.strictEqual(audited.length, 1);-- assert.strictEqual(audited[0].actor, people.admin);-- assert.strictEqual(audited[0].target_type, 'credit_settle');-- assert.strictEqual(audited[0].target_id, REVIEW_A);-- assert.strictEqual(JSON.parse(audited[0].metadata).hold_id, 'hold_review_a');-- // A resolved row is no longer offered, and a second attempt is a 409 that changes and audits nothing.-- const shown = await get('/console/exchange', adminCookie);-- assert.match(shown.text, /<div class="k">Review<\/div><div class="v">1<\/div>/);-- assert.ok(!shown.text.includes('hold_review_a'));-- const again = await post(url, adminCookie, { _csrf: csrf });-- assert.strictEqual(again.status, 409, again.text.slice(0, 300));-- assert.strictEqual((await h.db.prepare("SELECT COUNT(*) AS n FROM audit_log WHERE action = 'credit_settle.resolve'").get()).n, 1);-- // A missing CSRF token is refused and does not resolve the other row.-- const denied = await post(`/console/exchange/credits/${encodeURIComponent(REVIEW_B)}/resolve`, adminCookie, {});-- assert.strictEqual(denied.status, 403);-- assert.strictEqual((await h.db.prepare('SELECT status FROM credit_settle_queue WHERE id = ?').get(REVIEW_B)).status, 'review');--});----t.test('a staff session without ai.workflow.manage reads the page but cannot mark a row resolved', async () => {-- const s = await signIn(people.admin, { username: 'reader', staff_caps: ['staff.site.view'] });-- assert.strictEqual(s.cb.status, 303);-- const page = await get('/console/exchange', s.cookie);-- assertPage(page, '/console/exchange (reader)');-- assert.ok(page.text.includes('Credits settle queue'));-- assert.ok(!page.text.includes('Mark resolved'), 'no action without ai.workflow.manage');-- assert.ok(page.text.toLowerCase().includes('never retries the settle'));-- const denied = await post(`/console/exchange/credits/${encodeURIComponent(REVIEW_B)}/resolve`, s.cookie, { _csrf: csrfOf(page) });-- assert.strictEqual(denied.status, 403);-- assert.ok(denied.text.includes('This needs ai.workflow.manage'));-- assert.strictEqual((await h.db.prepare('SELECT status FROM credit_settle_queue WHERE id = ?').get(REVIEW_B)).status, 'review');--});----t.test('with both flags off the Exchange page and its sections are absent', async () => {-- await h.stop();-- h = await boot({ env: { ...baseEnv, AI_CREDITS: 'false', AI_ROUTING_EVIDENCE: 'false' }, fetchImpl: fakeFetch });-- const s = await signIn(people.admin, { username: 'ops' });-- assert.strictEqual(s.cb.status, 303);-- const overview = await get('/console', s.cookie);-- assertPage(overview, '/console');-- assert.ok(!overview.text.includes('href="/console/exchange"'), 'no Exchange navigation');-- const page = await get('/console/exchange', s.cookie);-- assert.strictEqual(page.status, 404);-- assert.ok(!page.text.includes('Credits settle queue') && !page.text.includes('Routing evidence'));--});----t.run();ddeleted file mode 100644@@@ -1,627 +0,0 @@--'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 { personFromClaims } = require('../server/console/sso');--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 VAULT_KEY = crypto.randomBytes(32).toString('hex');--const KEK = crypto.randomBytes(32).toString('base64');--const ADDED_KEY = `sentinel-added-${crypto.randomBytes(12).toString('hex')}`;--const REPLACED_KEY = `sentinel-replaced-${crypto.randomBytes(12).toString('hex')}`;--const SECRETS = [PROVIDER_SECRET, CLIENT_SECRET, SESSION_SECRET, VAULT_KEY, KEK, ADDED_KEY, REPLACED_KEY];--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, expired = false }) {-- const code = crypto.randomBytes(16).toString('hex');-- codes.set(code, { subject_id, role, is_owner, username, staff_caps, challenge, redirect_uri, expired });-- 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: u.expired ? now - 7200 : now, exp: u.expired ? now - 3600 : 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`) {-- // openvibe-sdk/sso posts the grant as JSON; the real Network takes that (and this stub keeps the form shape too).-- const text = String(opts.body || '');-- const body = text.trim().startsWith('{') ? JSON.parse(text) : Object.fromEntries(new URLSearchParams(text));-- 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 }); }-- // The added provider's own model list (L33: read when it is added, and again from its row).-- if (u === 'https://api.cheap.test/v1/models') return json(200, { data: [{ id: 'cheap-1' }, { id: 'cheap-pro-reasoner' }] });-- 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 = async (where, ...args) => await h.db.prepare(`SELECT * FROM audit_log WHERE ${where} ORDER BY id`).all(...args);--const sessionCount = async () => (await 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', AI_CREDENTIALS_KEY: VAULT_KEY,-- OV_CONFIG_KEKS: `k1:${KEK}`, OV_CONFIG_KEK_CURRENT: 'k1',-- },-- 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((await 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/usage', '/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 = (await 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((await h.quotas.list()).length, before);-- assert.strictEqual((await h.registry.getProvider('okseam')).status, 'active');--});----t.test('the sign-in module takes a Network person only: no service/app principal, no FedCM assertion', () => {-- assert.strictEqual(personFromClaims({ sub: 'svc:live', subject_id: people.admin }).error, 'not a person\'s token');-- assert.strictEqual(personFromClaims({ sub: 42, actor_type: 'app', subject_id: people.admin }).error, 'not a person\'s token');-- assert.strictEqual(personFromClaims({ sub: 42, typ: 'fedcm', subject_id: people.admin }).error, 'a FedCM assertion is not a sign-in');-- assert.strictEqual(personFromClaims({ sub: 42, username: people.admin }).error, 'the token carries no network subject');-- const person = personFromClaims({ sub: 42, subject_id: people.admin, username: 'ada', role: 'admin', is_owner: true, staff_caps: ['staff.site.view'], staff_map: '1.1.0' });-- assert.strictEqual(person.subject, people.admin);-- assert.strictEqual(person.username, 'ada');-- assert.deepStrictEqual(person.staff, { role: 'admin', is_owner: true, staff_caps: ['staff.site.view'], staff_map: '1.1.0' });--});----t.test('GET /auth/health answers whether the Network accepts the ai client', async () => {-- const r = await get('/auth/health');-- assert.strictEqual(r.status, 200);-- assert.deepStrictEqual(JSON.parse(r.text), { ok: true, client: 'ok' });-- assert.strictEqual(r.headers.get('cache-control'), 'no-store');--});----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(await sessionCount(), 0);--});----// The SDK takes the clock as a number: a conversion that passed a function made every expiry check pass.--t.test('an expired access token from the exchange starts no session', 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 code = authorize({ subject_id: people.admin, role: 'admin', challenge: loc.searchParams.get('code_challenge'), redirect_uri: loc.searchParams.get('redirect_uri'), expired: true });-- const cb = await get(`/auth/callback?code=${code}&state=${encodeURIComponent(loc.searchParams.get('state'))}`, flow);-- assert.ok(cb.status >= 400, `expired token: ${cb.status}`);-- assert.strictEqual(await 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(await sessionCount(), 0);-- const refused = await 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 = await 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((await 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>AI_API_KEY</code>') && /From the server's settings file|the settings file has no/.test(prov.text), 'the settings-file name is shown, in words');-- 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((await h.registry.getProvider('okseam')).status, 'active');-- assert.ok((await h.cache.stats()).length >= 1);-- assert.strictEqual((await 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 = (await 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((await h.registry.getWorkflow('ai.generate', v)).status, 'draft', why);-- }-- assert.strictEqual((await 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((await h.registry.getWorkflow('ai.generate', v)).status, 'archived');-- const row = (await 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((await h.registry.getProvider('badstub')).status, 'disabled');-- assert.strictEqual((await 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((await 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 = (await 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((await 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((await 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((await h.cache.stats()).length, 0);-- const purge = (await 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('owner: add a provider with its key (kept encrypted), replace the key, register a model, point a route at it and back', async () => {-- let x = await get('/console/providers/new?preset=deepseek', cookies.owner);-- assert.strictEqual(x.status, 200);-- assert.match(x.text, /value="https:\/\/api\.deepseek\.com\/v1"/);-- assert.match(x.text, /name="api_key" required/);-- assert.strictEqual((await get('/console/providers/new', cookies.admin)).status, 403, 'an admin does not add providers');-- x = await post('/console/providers', cookies.owner, { preset: 'custom', key: 'cheapapi', display_name: 'Cheap API', base_url: 'http://insecure.test/v1', api_key: ADDED_KEY, default_model: 'cheap-1', _csrf: csrf.owner });-- assert.ok(x.status >= 400, 'https only');-- x = await post('/console/providers', cookies.owner, { preset: 'custom', key: 'cheapapi', display_name: 'Cheap API', base_url: 'https://api.cheap.test/v1/', api_key: ADDED_KEY, default_model: 'cheap-1', _csrf: csrf.owner });-- assert.strictEqual(x.status, 303, x.text);-- const p = await h.registry.getProvider('cheapapi');-- assert.deepStrictEqual([p.kind, p.secret_ref, p.base_url, p.default_model, p.auth_mode], ['openai', 'vault:cheapapi', 'https://api.cheap.test/v1', 'cheap-1', 'bearer']);-- const row = await h.db.prepare("SELECT values_json FROM config_snapshots WHERE namespace = 'ai.provider_keys' AND state = 'active'").get();-- assert.match(JSON.parse(row.values_json)['cheapapi.key'], /^v1:k1:/, 'sealed at rest in the D11 store');-- assert.ok(!row.values_json.includes(ADDED_KEY), 'encrypted at rest');-- assert.strictEqual(h.settings.providerKeys.store.get('cheapapi.key'), ADDED_KEY);-- assert.strictEqual(require('../server/util').resolveSecret('vault:cheapapi'), ADDED_KEY, 'the pool resolves it');-- assert.strictEqual((await h.settings.providerKeys.store.summary()).secrets['cheapapi.key'].bound_to, 'https://api.cheap.test');-- assert.strictEqual((await h.settings.providerKeys.store.summary()).secrets['cheapapi.key'].hint, ADDED_KEY.slice(-4));-- x = await get('/console/providers', cookies.owner);-- assert.ok(!x.text.includes(ADDED_KEY), 'the page never shows the key');-- assert.match(x.text, new RegExp(`Entered here, locked</span><br><span class="muted">ends …${ADDED_KEY.slice(-4)}`));-- x = await post('/console/providers', cookies.owner, { preset: 'custom', key: 'cheapapi', base_url: 'https://api.cheap.test/v1', api_key: ADDED_KEY, _csrf: csrf.owner });-- assert.ok(x.status >= 400, 'no second provider of the same name');-- x = await post('/console/providers/cheapapi/key', cookies.owner, { api_key: REPLACED_KEY, _csrf: csrf.owner });-- assert.strictEqual(x.status, 303);-- assert.strictEqual(require('../server/util').resolveSecret('vault:cheapapi'), REPLACED_KEY);-- assert.strictEqual(h.settings.providerKeys.store.get('cheapapi.key'), REPLACED_KEY, 'the store holds the replaced key');-- x = await post('/console/providers/cheapapi/models', cookies.owner, { model_key: 'cheap-2', cost_in: '0.1', cost_out: '0.4', _csrf: csrf.owner });-- assert.ok(x.status >= 400, 'prices name the page they come from');-- x = await post('/console/providers/cheapapi/models', cookies.owner, { model_key: 'cheap-2', cost_in: '0.1', cost_out: '0.4', price_source: 'https://cheap.test/pricing', tools: '1', _csrf: csrf.owner });-- assert.strictEqual(x.status, 303, x.text);-- const m = await h.registry.getModel('cheapapi', 'cheap-2');-- assert.deepStrictEqual([m.cost.in_per_mtok, m.cost.out_per_mtok, m.supports.tools], [0.1, 0.4, true]);-- // Its model list was read when it was added: listed, described, switched off until a person turns one on (L33).-- const listed = await h.registry.getModel('cheapapi', 'cheap-pro-reasoner');-- assert.ok(listed && listed.status === 'disabled' && listed.metadata.tier === 'strong' && listed.metadata.reasoning, 'read on add, described, off');-- x = await get('/console/providers', cookies.owner);-- assert.ok(x.text.includes('action="/console/providers/cheapapi/sync"') && x.text.includes('action="/console/models/cheapapi/cheap-pro-reasoner/status"'), 'the page offers the list and the switch');-- x = await post('/console/providers/cheapapi/sync', cookies.owner, { _csrf: csrf.owner });-- assert.strictEqual(x.status, 303, x.text);-- assert.match(x.headers.get('location'), /done=models_synced$/);-- assert.strictEqual((await auditRows("action = 'provider.models_synced' AND target_id = 'cheapapi' AND actor = ?", people.owner)).length, 1);-- x = await post('/console/models/cheapapi/cheap-pro-reasoner/status', cookies.owner, { status: 'active', _csrf: csrf.owner });-- assert.strictEqual(x.status, 303);-- assert.strictEqual((await h.registry.getModel('cheapapi', 'cheap-pro-reasoner')).status, 'active', 'turned on');-- assert.strictEqual((await post('/console/models/cheapapi/cheap-pro-reasoner/status', cookies.admin, { status: 'disabled', _csrf: csrf.admin })).status, 403, 'only the owner switches models');-- // Point default.json at it: a new version, the previous primary kept as the first fallback; then back to the default.-- const before = await h.registry.getRoute('default.json');-- x = await get('/console/routes/default.json', cookies.owner);-- assert.match(x.text, /<option value="cheapapi\|cheap-2">Cheap API · cheap-2<\/option>/);-- x = await post('/console/routes/default.json/use', cookies.owner, { target: 'cheapapi|cheap-2', _csrf: csrf.owner });-- assert.strictEqual(x.status, 303, x.text);-- const used = await h.registry.getRoute('default.json');-- assert.strictEqual(used.version, before.version + 1);-- assert.deepStrictEqual(used.primary, { provider: 'cheapapi', model: 'cheap-2' });-- assert.strictEqual(used.created_by, people.owner);-- x = await post('/console/routes/default.json/use', cookies.owner, { default: '1', _csrf: csrf.owner });-- assert.strictEqual(x.status, 303, x.text);-- const back = await h.registry.getRoute('default.json');-- assert.strictEqual(back.version, used.version + 1);-- assert.notStrictEqual((back.primary || {}).provider, 'cheapapi');-- assert.strictEqual((await post('/console/routes/default.json/use', cookies.admin, { target: 'cheapapi|', _csrf: csrf.admin })).status, 403, 'only the owner changes what a route spends');-- const audits = await auditRows("target_id = 'cheapapi'");-- assert.ok(audits.some((a) => a.action === 'provider.create') && audits.some((a) => a.action === 'provider.key_replaced'));-- assert.ok(audits.every((a) => !String(a.metadata).includes(ADDED_KEY) && !String(a.metadata).includes(REPLACED_KEY)), 'no key in the audit');--});----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((await 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('usage: free allowance per subject this period and Billing readings by state, with the last send errors', async () => {-- await h.registry.upsertModel({ provider_key: 'okseam', model_key: 'seam-model', type: 'chat', cost: { in_per_mtok: 1, out_per_mtok: 1, free_allowance: 500, reset_period: 'month' } });-- assert.strictEqual((await h.quotas.freeAllowance.claim('live:user:4242', 'okseam', 'seam-model', { in: 120 })).tokens, 120);-- const key = `ai:${runIds.ok}:0:in`;-- assert.ok(await h.db.prepare('SELECT 1 AS x FROM usage_sample_outbox WHERE event_id = ?').get(key), 'the succeeded run queued a reading');-- await h.db.prepare("UPDATE usage_sample_outbox SET attempts = 2, last_error = 'billing.usage.record answered 503' WHERE event_id = ?").run(key);-- const r = await get('/console/usage', cookies.admin);-- assertPage(r, '/console/usage');-- const row = r.text.split('</tr>').find((x) => x.includes('<code>live:user:4242</code>'));-- assert.ok(row, 'the subject has a row');-- const cells = [...row.matchAll(/<td[^>]*>([\s\S]*?)<\/td>/g)].map((m) => m[1].replace(/<[^>]+>/g, '').trim());-- assert.deepStrictEqual(cells.slice(0, 6), ['live:user:4242', 'okseam', 'input-tokens:seam-model', '120', '500', '380']);-- assert.ok(r.text.includes('Readings by delivery state') && r.text.includes(`<code>${key}</code>`) && r.text.includes('billing.usage.record answered 503'));-- const run = await get(`/console/runs/${runIds.ok}`, cookies.admin);-- assert.ok(run.text.includes(`<code>${key}</code>`) && run.text.includes('billing.usage.record answered 503'), 'the run page shows its reading\'s state and error');-- assert.strictEqual((await get('/console/usage')).status, 401, 'signed out: refused');-- assert.ok(!(await fetch(`${h.base}/stats`).then((x) => x.text())).includes('live:user:4242'), 'the public page has no subject');--});----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((await h.runs.get(id)).status, 'cancelled');-- assert.strictEqual((await 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);-- await 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((await auditRows("action = 'console.sign_out' AND actor = ?", people.admin)).length, 1);-- for (const s of SECRETS) assert.ok(!JSON.stringify(await 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; the sitemap lists only public pages', 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/'));-- const map = await request(h.base, 'GET', '/sitemap.xml');-- assert.strictEqual(map.status, 200);-- assert.ok(!/\/console|\/auth\//.test(map.text), 'the console and sign-in are never in the sitemap');-- 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.services.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();@@@ -62,7 +62,9 @@ t.test('public GET preflight succeeds; rejected origin or method gets no CORS het.test('token and console routes never receive CORS headers', async () => {for (const [method, path] of [['GET', '/api/v1/runs'], ['OPTIONS', '/api/v1/runs'], ['GET', '/console'], ['OPTIONS', '/console'], ['GET', '/auth/login']]) {-- const r = await request(h.base, method, path, { headers: { Origin: allowed, 'Access-Control-Request-Method': 'GET' } });++ // Inspect the legacy routes' own answers. Following their redirects lands on a human page that++ // may establish its own browser session and has a different CORS policy.++ const r = await request(h.base, method, path, { redirect: 'manual', headers: { Origin: allowed, 'Access-Control-Request-Method': 'GET' } });assertNoCors(r.headers);}for (const path of ['/stats/extra', '/release.json/extra']) {@@@ -34,7 +34,7 @@ t.test('the home: a browser is sent to Exchange, curl still gets the API index',assert.strictEqual(text.status, 200, String(accept));assert.match(text.headers.get('content-type'), /^text\/plain/, String(accept));assert.ok(text.text.startsWith('OpenVibe.AI: providers, models, routing'), String(accept));-- assert.ok(text.text.includes('POST /api/v1/runs') && text.text.includes('GET /runs/:id') && text.text.includes('GET /console'), String(accept));++ assert.ok(text.text.includes('POST /api/v1/runs') && text.text.includes('GET /runs/:id') && text.text.includes(`${EXCHANGE}/console`), String(accept));assert.ok(text.text.includes(`${EXCHANGE}/`), 'the text index names the new home');}await moved('/?format=markdown', '/index.md');@@@ -70,13 +70,19 @@ t.test('an unknown human page goes to the Exchange home; an unknown API path sta}});--t.test('the machine and session routes are not redirected', async () => {++t.test('console paths redirect to Exchange while machine and receipt routes stay on AI', async () => {const health = await request(h.base, 'GET', '/api/health');assert.strictEqual(health.status, 200);assert.strictEqual(health.body.status, 'ok');const release = await request(h.base, 'GET', '/release.json');assert.strictEqual(release.status, 200);-- for (const p of ['/console', '/runs/run_01J0000000000000000000000', '/auth/login']) {++ await moved('/console', '/console');++ await moved('/console/providers?tab=all', '/console/providers?tab=all');++ await moved('/llms.txt', '/llms.txt');++ await moved('/auth/login', '/auth/login');++ await moved('/you', '/privacy/keys');++ await moved('/you?allow=actor&provider=openai', '/privacy/keys?allow=actor&provider=openai');++ for (const p of ['/runs/run_01J0000000000000000000000', '/auth/person/login']) {const r = await request(h.base, 'GET', p, { headers: { Accept: BROWSER }, redirect: 'manual' });assert.ok(!location(r).startsWith(EXCHANGE), `${p} stays on this origin (${r.status} ${location(r)})`);}@@@ -55,7 +55,7 @@ t.test('a service that may not use the key is refused, and the answer says whereassert.strictEqual(res.status, 403, res.text);assert.strictEqual(res.body.error.code, 'ai.credential_not_allowed');assert.ok(res.body.error.message.includes('allow actor to use your openai key'), res.text);-- assert.ok(res.body.error.message.includes('/you?allow=actor&provider=openai'), 'the detail names the page');++ assert.ok(res.body.error.message.includes('https://openvibe.exchange/privacy/keys?allow=actor&provider=openai'), 'the detail names the Exchange page');assert.ok(!res.text.includes(DANA_KEY), 'the key is not in the error');});@@@ -2,8 +2,8 @@// L36 Y1/Y3/Y7/Y8: the person's own provider keys and how their runs route.// - GET/PUT/DELETE/POST /api/v1/me/credentials and GET/PUT /api/v1/me/ai/preferences are person-only: a service// token (with or without on_behalf_of) is refused, so is another site's cookie session;--// - a token minted for openvibe.network (every site's session) may read but never write; a write needs aud--// openvibe.ai, and a cookie write needs Origin or Sec-Fetch-Site;++// - a token minted for openvibe.network may read; Exchange may write from its configured origin, while other++// writes need aud openvibe.ai;// - a key is sealed with the AAD self|subject|provider and never returned, logged or put in an error;// - test on save lists models, falls back to a one-token request, records 401 and a timeout;// - a run on_behalf_of a person tries their self key first, only_my_keys never falls back, and the explain names@@@ -74,7 +74,7 @@ t.test('the person routes refuse a service token, an on_behalf_of token, and anoassert.deepStrictEqual([r.status, r.body.code], [403, 'capability.denied'], 'another site cannot spend the session');});--t.test('a site session token may read but never write; a token for openvibe.ai may write', async () => {++t.test('a site session token may read; Exchange may write for that signed-in person; a service token remains refused', async () => {let r = await request(h.base, 'GET', '/api/v1/me/credentials', { tok: siteSession });assert.strictEqual(r.status, 200, 'a site token reads the hints');r = await request(h.base, 'GET', '/api/v1/me/ai/preferences', { tok: siteSession });@@@ -90,7 +90,19 @@ t.test('a site session token may read but never write; a token for openvibe.ai mr = await request(h.base, method, path, { tok: siteSession, body });assert.deepStrictEqual([r.status, r.body.code], [403, 'capability.denied'], `${method} ${path} refused a site session token: ${r.text}`);}-- // A token whose aud explicitly names openvibe.ai reaches the writes.++ r = await request(h.base, 'POST', '/api/v1/me/credentials', {++ tok: siteSession, headers: { Origin: 'https://untrusted.example' },++ body: { provider: 'gemini', key: API_KEY },++ });++ assert.deepStrictEqual([r.status, r.body.code], [403, 'capability.denied'], 'another site cannot forward a network session into a credential write');++ // Exchange forwards the signed user's site token server-to-server from its allowlisted origin.++ r = await request(h.base, 'POST', '/api/v1/me/credentials', {++ tok: siteSession, headers: { Origin: 'https://openvibe.exchange' },++ body: { provider: 'gemini', key: API_KEY },++ });++ assert.strictEqual(r.status, 200, r.text);++ assert.ok(!r.text.includes(API_KEY), 'Exchange receives metadata only');++ // A token whose aud explicitly names openvibe.ai reaches the writes without an Exchange origin.r = await request(h.base, 'PUT', '/api/v1/me/ai/preferences', { tok: person, body: prefsBody });assert.strictEqual(r.status, 200, r.text);});@@@ -49,6 +49,7 @@ function mounted(app) {*/const LEFT_OUT = ['/api/health', '/api/ready', '/api/v1/openapi.json',++ '/api/v1/admin','/api/v1/status', '/api/v1/providers', '/api/v1/models', '/api/v1/routes', '/api/v1/templates', '/api/v1/workflows','/api/v1/quotas', '/api/v1/usage', '/api/v1/requests', '/api/v1/audit', '/api/v1/cache','/api/v1/credentials', '/api/v1/attribution-quotas', '/api/v1/me',@@@ -198,8 +198,10 @@ t.test('a service token or an expired token starts no person session; a bad statassert.strictEqual((await get(`/auth/callback?code=${code}&state=wrong`, flow)).status, 400);});--t.test('a run receipt is personal: no console page and no API is opened by the person cookie', async () => {-- assert.strictEqual((await get('/console', runIds.adaCookie)).status, 401);++t.test('a run receipt is personal: operator pages move to Exchange and no API is opened by the person cookie', async () => {++ const consolePage = await get('/console', runIds.adaCookie);++ assert.strictEqual(consolePage.status, 301);++ assert.strictEqual(consolePage.headers.get('location'), 'https://openvibe.exchange/console');assert.strictEqual((await get(`/api/v1/runs/${runIds.ok}`, runIds.adaCookie)).status, 401);});ddeleted file mode 100644@@@ -1,143 +0,0 @@--'use strict';--// Your AI (plan L36 Y1/Y3/Y8): GET /you and its forms on ai.openvibe.services. Signed out it says what it is; signed in--// (AI's own person session) it lists every provider with the person's key state, saves, tests and removes keys, and--// sets how their AI is paid. Every POST needs this site's own origin signal; a key never comes back in the page.--const assert = require('assert');--const crypto = require('crypto');--const { boot, suite, request, token, ALL } = require('./helpers');--const { SELF_OWNER } = require('../server/credentials');----const t = suite('you-page');--const KEY = 'ef'.repeat(32);--const DANA = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPY';--const ANTHROPIC_KEY = 'sk-ant-test-you-0123456789WXYZ';--const json = (body, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });--let refuse = false;--const credentialFetch = async (url) => {-- if (refuse) return json({ error: { message: 'invalid key' } }, 401);-- if (String(url).endsWith('/models')) return json({ data: [{ id: 'm-one' }, { id: 'm-two' }] });-- return json({});--};--// The person's computers as Bot lists them to AI (only those that let their AI use their models), and the answers.--const MACHINE = 'dev_01M5YGM39VC2RRGDQPRD6D4SHD';--const asked = [];--const machineModels = {-- enabled: true,-- async mine() { return [{ id: MACHINE, name: 'Desk PC', online: true, models: ['qwen3:8b', 'llama3.1:8b'], tools: true }, { id: 'dev_01M5YGM39VC2RRGDQPRD6D4SHE', name: 'Old laptop', online: false, models: ['phi3'], tools: false }]; },-- async chat(args) { asked.push(args); return { text: 'From your desk.', message: { role: 'assistant', content: 'From your desk.' }, finish: 'stop', model: args.model, usage: { input: 5, output: 3, cached: 0 }, machine: MACHINE, tried: [] }; },--};--let h, cookie;--const page = (path, o = {}) => fetch(h.base + path, { redirect: 'manual', ...o, headers: { ...(o.headers || {}) } }).then(async (r) => ({ status: r.status, headers: r.headers, text: await r.text() }));--const post = (path, fields, headers = { 'Sec-Fetch-Site': 'same-origin' }) => page(path, { method: 'POST', body: new URLSearchParams(fields).toString(), headers: { 'content-type': 'application/x-www-form-urlencoded', Cookie: cookie, ...headers } });--const flash = (r) => Object.fromEntries(new URLSearchParams(String(r.headers.get('location') || '').split('?')[1] || ''));----t.test('boot', async () => {-- h = await boot({ env: { AI_CREDENTIALS_KEY: KEY, AI_CREDENTIAL_TEST_TIMEOUT_MS: '80' }, credentialFetch, machineModels, log: { log() {}, warn() {}, error() {} } });-- const raw = crypto.randomBytes(32).toString('base64url');-- await h.db.exec("INSERT INTO person_sessions (id_hash, subject, username, created_at, expires_at) VALUES ($1, $2, 'dana', '2026-10-01', '2030-10-01')", [crypto.createHash('sha256').update(raw).digest('hex'), DANA]);-- cookie = `ovai_person=${raw}`;--});----t.test('signed out: what it is and a sign-in, private and not indexed', async () => {-- const r = await page('/you');-- assert.strictEqual(r.status, 200);-- assert.strictEqual(r.headers.get('cache-control'), 'no-store');-- assert.match(r.headers.get('x-robots-tag'), /noindex/);-- assert.ok(r.text.includes('Your keys, on every OpenVibe site.') && r.text.includes('href="/auth/person/login?next=%2Fyou"'));--});----t.test('signed in: every provider, none added yet, and how the AI is paid', async () => {-- const r = await page('/you', { headers: { Cookie: cookie } });-- assert.strictEqual(r.status, 200);-- for (const name of ['Anthropic', 'OpenAI', 'Google Gemini', 'DeepSeek', 'Mistral', 'xAI', 'Groq', 'OpenRouter', 'Another OpenAI-compatible API']) assert.ok(r.text.includes(`<summary>${name}<span>Add</span></summary>`), name);-- assert.ok(r.text.includes('No keys yet.'), 'an empty list says so');-- assert.ok(r.text.includes('action="/you/keys/anthropic"') && r.text.includes('href="https://console.anthropic.com/settings/keys"'), 'a form and where to get a key');-- assert.ok(r.text.includes('name="only_my_keys" value="0" checked'), 'my keys first by default');-- assert.ok(r.text.includes('href="https://openvibe.report/you"'), 'what it cost is on Report');--});----t.test('save a key: sealed, tested, never shown again; the page says it works', async () => {-- const r = await post('/you/keys/anthropic', { key: ANTHROPIC_KEY });-- assert.strictEqual(r.status, 303);-- assert.match(flash(r).ok, /Saved your Anthropic key, and it works/);-- const row = await h.db.prepare('SELECT key_enc, status FROM subject_credentials WHERE owner = ? AND subject = ? AND provider = ?').get(SELF_OWNER, DANA, 'anthropic');-- assert.ok(row && row.key_enc.startsWith('v2:') && !row.key_enc.includes(ANTHROPIC_KEY));-- const view = await page('/you', { headers: { Cookie: cookie } });-- assert.ok(view.text.includes('Working') && view.text.includes('…WXYZ') && view.text.includes('2 models'));-- assert.ok(!view.text.includes(ANTHROPIC_KEY), 'the key never comes back');-- assert.ok(view.text.includes('action="/you/keys/anthropic/test"') && view.text.includes('Replace the key'));-- assert.ok(view.text.includes('<b>Anthropic</b>') && !view.text.includes('<summary>Anthropic<span>Add</span></summary>'), 'a saved key moves out of the add list');--});----t.test('a key the provider refuses is saved and says so; test and remove work', async () => {-- refuse = true;-- let r = await post('/you/keys/openai', { key: 'sk-test-you-refused-012345' });-- assert.match(flash(r).error, /Saved your OpenAI key, but it did not work/);-- r = await post('/you/keys/openai/test', {});-- assert.match(flash(r).error, /did not work/);-- refuse = false;-- r = await post('/you/keys/openai/test', {});-- assert.match(flash(r).ok, /Your OpenAI key works/);-- r = await post('/you/keys/openai/remove', {});-- assert.match(flash(r).ok, /Removed your OpenAI key/);-- r = await post('/you/keys/openai/remove', {});-- assert.match(flash(r).error, /no key to remove/);--});----t.test('how the AI is paid: only my keys, free tiers, a ceiling', async () => {-- const r = await post('/you/preferences', { only_my_keys: '1', price_ceiling_usd_per_mtok: '2.5' });-- assert.match(flash(r).ok, /Saved how your AI is paid/);-- const view = await page('/you', { headers: { Cookie: cookie } });-- assert.ok(view.text.includes('name="only_my_keys" value="1" checked') && view.text.includes('<b>Only your keys</b>'));-- assert.ok(view.text.includes('Free tiers first: <b>off</b>'), 'an unticked box is off');-- assert.ok(view.text.includes('value="2.5"'));--});----t.test('another site, a missing origin signal or no session cannot change anything', async () => {-- let r = await post('/you/keys/anthropic/remove', {}, { Origin: 'https://evil.example', 'Sec-Fetch-Site': 'cross-site' });-- assert.strictEqual(r.status, 403);-- r = await post('/you/keys/anthropic/remove', {}, {});-- assert.strictEqual(r.status, 403, 'no origin signal at all is refused');-- r = await page('/you/preferences', { method: 'POST', body: 'only_my_keys=0', headers: { 'content-type': 'application/x-www-form-urlencoded', 'Sec-Fetch-Site': 'same-origin' } });-- assert.strictEqual(r.status, 303);-- assert.match(r.headers.get('location'), /^\/auth\/person\/login/);-- const still = await h.db.prepare('SELECT 1 AS x FROM subject_credentials WHERE owner = ? AND subject = ? AND provider = ?').get(SELF_OWNER, DANA, 'anthropic');-- assert.ok(still, 'the key is still there');--});----t.test('your computer (L55 C3): a model at home, no key; an app reaches it only once the person allows it', async () => {-- let r = await page('/you', { headers: { Cookie: cookie } });-- assert.ok(r.text.includes('<h2 id="you-pc">Your computer</h2>'));-- assert.ok(r.text.includes('<b>Desk PC</b> · qwen3:8b, llama3.1:8b') && r.text.includes('<b>Old laptop</b> <span class="you-pill">away</span>'), 'its computers and their models');-- assert.ok(r.text.includes('update OpenVibe Link to v0.36.0'), 'a Link that cannot pass tools says what to update');-- assert.ok(r.text.includes('<option value="qwen3:8b">'), 'the models to pick from');-- assert.ok(flash(await post('/you/computer', { model: 'two words' })).error, 'a model name has no spaces');-- r = await post('/you/computer', { model: 'qwen3:8b' });-- assert.match(flash(r).ok, /qwen3:8b/);-- const row = await h.db.prepare('SELECT machine, models, key_enc FROM subject_credentials WHERE owner = ? AND subject = ? AND provider = ?').get(SELF_OWNER, DANA, 'node');-- assert.deepStrictEqual([row.machine, JSON.parse(row.models).chat, row.key_enc], ['any', 'qwen3:8b', ''], 'any of their computers, the model, nothing sealed');-- r = await page('/you', { headers: { Cookie: cookie } });-- assert.ok(r.text.includes('<b>Any of your computers</b><span class="you-pill is-ok">qwen3:8b</span>') && r.text.includes('On now, with this model'));-- // An app asks for it by name; before the person allows it, AI refuses and says where.-- const actor = token('actor', ALL);-- const ask = () => request(h.base, 'POST', '/v1/chat/completions', { tok: actor, headers: { 'x-openvibe-credential': `node:${DANA}` }, body: { model: 'openvibe', messages: [{ role: 'user', content: 'hello' }] } });-- r = await ask();-- assert.strictEqual(r.status, 403, r.text);-- assert.strictEqual(r.body.error.code, 'ai.credential_not_allowed');-- assert.strictEqual(asked.length, 0, 'nothing reached the computer');-- r = await page('/you?allow=actor&provider=node', { headers: { Cookie: cookie } });-- assert.ok(r.text.includes('use your computer's models?'), 'the ask in words');-- assert.match(flash(await post('/you/apps/allow', { service: 'actor', provider: 'node' })).ok, /computer's models/);-- r = await ask();-- assert.strictEqual(r.status, 200, r.text);-- assert.strictEqual(r.body.choices[0].message.content, 'From your desk.');-- assert.deepStrictEqual([asked[0].machine, asked[0].subject, asked[0].model], ['any', DANA, 'qwen3:8b']);-- assert.strictEqual(r.body.x_openvibe.cost_usd, 0);-- assert.match(flash(await post('/you/computer/remove', {})).ok, /No app can use it/);-- r = await ask();-- assert.ok(r.status === 404 || r.status === 403, r.text);-- assert.strictEqual(asked.length, 1, 'removed: nothing reaches the computer');--});----t.test('close', async () => { await h.close(); });--t.run();
@@ -5,8 +5,8 @@
@@ -5,8 +5,8 @@
**Status:** alpha (roadmap Wave 13). Deployed on the host since 2026-09-23 on `127.0.0.1:4700` and in
**Status:** alpha (roadmap Wave 13). Deployed on the host since 2026-09-23 on `127.0.0.1:4700` and in
production use: OpenVibe.Live runs with `AI_SERVICE=remote` and Network's footer copy runs
production use: OpenVibe.Live runs with `AI_SERVICE=remote` and Network's footer copy runs
`network.site_copy` here. Public since 2026-10-07 at [ai.openvibe.services](https://ai.openvibe.services):
`network.site_copy` here. Public since 2026-10-07 at [ai.openvibe.services](https://ai.openvibe.services):
-the API (`ai.app.run`, the OpenAI-compatible `/v1`), a run's receipt (`/runs/:id`), a person's own provider
-keys (`/you`) and the operator console.
+the API (`ai.app.run`, the OpenAI-compatible `/v1`) and a run's receipt (`/runs/:id`). The operator console and
+personal credential settings live on OpenVibe.Exchange.
**The public site moved to [openvibe.exchange](https://openvibe.exchange) on 2026-11-07:** the home, prices
**The public site moved to [openvibe.exchange](https://openvibe.exchange) on 2026-11-07:** the home, prices
and latency, the changelog, the legal pages and the discovery files answer 301 there on an exact page map
and latency, the changelog, the legal pages and the discovery files answer 301 there on an exact page map
(`server/app.js`), while every API route on this origin is unchanged. The content and the map are in
(`server/app.js`), while every API route on this origin is unchanged. The content and the map are in
@@ -31,13 +31,14 @@ owns publication truth: Wiki, Blog, News, Live, … decide what to publish.
@@ -31,13 +31,14 @@ owns publication truth: Wiki, Blog, News, Live, … decide what to publish.
- people's own provider keys (`ai.credential.manage`), encrypted at rest, one per provider (Codes C3: `anthropic`,
- people's own provider keys (`ai.credential.manage`), encrypted at rest, one per provider (Codes C3: `anthropic`,
`openai`, `deepseek`, `minimax`, `openrouter` or a `custom` endpoint), and per-streamer
`openai`, `deepseek`, `minimax`, `openrouter` or a `custom` endpoint), and per-streamer
attribution quotas (`ai.quota.attribution.manage`)
attribution quotas (`ai.quota.attribution.manage`)
-- **"Your AI" (L36 Y1/Y3/Y8):** the person's own keys under the reserved owner `self` for every console provider
+- **"Your AI" (L36 Y1/Y3/Y8):** the person's own keys under the reserved owner `self` for every supported provider
(Anthropic, OpenAI, Gemini, DeepSeek, Mistral, xAI, Groq, Cerebras, Together, Fireworks, OpenRouter, MiniMax and a
(Anthropic, OpenAI, Gemini, DeepSeek, Mistral, xAI, Groq, Cerebras, Together, Fireworks, OpenRouter, MiniMax and a
custom endpoint), tested on save (its models, or one token) with status, `models_count` and a redacted `last_error`;
custom endpoint), tested on save (its models, or one token) with status, `models_count` and a redacted `last_error`;
their `only_my_keys`, `free_first`, price ceiling and default model per kind of work; a run `on_behalf_of` a person
their `only_my_keys`, `free_first`, price ceiling and default model per kind of work; a run `on_behalf_of` a person
tries their keys first, `explain.paid_by` and the attempts name who paid and why each failed, and a 401/403 marks the
tries their keys first, `explain.paid_by` and the attempts name who paid and why each failed, and a 401/403 marks the
key failing and publishes `ai.credential.failed` (`/api/v1/me/*`, `server/me.js`; only the person's own token or
key failing and publishes `ai.credential.failed` (`/api/v1/me/*`, `server/me.js`; only the person's own token or
- AI-site session, never a service token or `on_behalf_of`)
+ AI-site session, never a service token or `on_behalf_of`). The person's settings UI is on
+ [OpenVibe.Exchange](https://openvibe.exchange/privacy/keys).
- a person's own computer as their provider (credential provider `node`, contracts 0.220.0): a model on their own
- a person's own computer as their provider (credential provider `node`, contracts 0.220.0): a model on their own
machine (Ollama, LM Studio, llama.cpp through the OpenVibe Link), reached through OpenVibe.Bot with `bot.machine.ai`
machine (Ollama, LM Studio, llama.cpp through the OpenVibe Link), reached through OpenVibe.Bot with `bot.machine.ai`
for that person only, free and with no key (`server/machines.js`; Bot on this host by default, `OV_BOT_INTERNAL_URL`);
for that person only, free and with no key (`server/machines.js`; Bot on this host by default, `OV_BOT_INTERNAL_URL`);
@@ -141,7 +142,7 @@ idempotent replay):
@@ -141,7 +142,7 @@ idempotent replay):
| `GET /api/v1/usage/series?days=N` (N 1–90) — aggregate demand per UTC day and model: requests, successes and the four token totals, never a caller id or text (`AI_USAGE_SERIES=on`) | `ai.usage.read` |
| `GET /api/v1/usage/series?days=N` (N 1–90) — aggregate demand per UTC day and model: requests, successes and the four token totals, never a caller id or text (`AI_USAGE_SERIES=on`) | `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 /console…` | 301 to the same path on [OpenVibe.Exchange](https://openvibe.exchange/console) |
| `GET /runs/:id` — one run's receipt (status, workflow, model, cost, times, output or error; never the input) | the run's person, signed in (see [Run receipts](#run-receipts)) |
| `GET /runs/:id` — one run's receipt (status, workflow, model, cost, times, output or error; never the input) | the run's person, signed in (see [Run receipts](#run-receipts)) |
| `GET /auth/person/login\|callback` | any Network person, for a run receipt |
| `GET /auth/person/login\|callback` | any Network person, for a run receipt |
| `GET /robots.txt` | public: disallows `/console`, `/runs`, `/auth/`, `/api/` |
| `GET /robots.txt` | public: disallows `/console`, `/runs`, `/auth/`, `/api/` |
@@ -478,47 +479,9 @@ AI takes one on these routes alone, and every other route refuses `env: sandbox`
@@ -478,47 +479,9 @@ AI takes one on these routes alone, and every other route refuses `env: sandbox`
## Operator console
## Operator console
-`/console` on ai.openvibe.services (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/`.
+The owner-only AI operator console is on [OpenVibe.Exchange](https://openvibe.exchange/console). It uses Exchange sign-in and Exchange's page shell; Exchange reads and writes AI's JSON admin API with the `exchange` service grant. The old `ai.openvibe.services/console` paths return 301 to the matching Exchange path. AI keeps its JSON admin APIs and run receipts, not operator-console HTML.
-| 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; for the owner, per provider: replace its key, register a model (with prices typed from the provider's own price page, which the form requires) |
-| `/console/providers/new` | the owner adds a provider in a few clicks: DeepSeek, OpenRouter, Groq, Gemini, Mistral, Together, Fireworks, Cerebras, xAI, OpenAI, Anthropic or any OpenAI-compatible API (`server/console/presets.js`), with its API key, which is kept sealed in the config store, bound to the provider's origin (`vault:<provider>`, below) |
-| `/console/routes/:key` (owner form) | **use another provider**: a new route version whose primary is the provider and model chosen, with the previous primary kept as the first fallback; **back to the default** writes the newest seeded version again |
-| `/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, the Billing reading with its delivery state, free share and last send error, 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`); the **free allowance this period** per subject × provider × metric (free used, allowance, remaining, period end; `quotas.freeAllowance.current()`); the `platform.usage-sample@1` readings by delivery state (queued, retrying, sent, failed) and the last send errors with their run. Staff only: the public `/stats` page never shows a subject |
-| `/console/exchange` | AI Exchange operational state, only with `AI_CREDITS` and/or `AI_ROUTING_EVIDENCE` on: the Credits settle queue's counts by status (queued, sent, review), the oldest queued row's age, the review rows (hold id, request id, tries, last error, created) with one **mark resolved** action that never retries, and the four settle-body usage counts only; per deployment samples, success and timeout rates, TTFT and decode EWMA and cache-hit ratio; the incumbents per decision key with the challenger and its streak |
-| `/console/cache` | live entries and hits by workflow |
-| `/console/audit` | the `audit_log`, filtered by kind (configuration changes by default, runs, AI Exchange, 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; mark a Credits settle review row resolved (never retried) |
-| `staff.secrets.manage` | owner | `ai.provider.manage` | add a provider with its key, replace a key, register a model, choose a route's provider and model, 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.
+A person's provider keys and supported browser session cookies are managed at [Your keys and accounts](https://openvibe.exchange/privacy/keys). AI's `/api/v1/me/credentials` endpoints remain person-authenticated JSON APIs; list and balance responses contain metadata only, never secret values.
## Run receipts
## Run receipts
@@ -737,7 +700,7 @@ OpenRouter's public model list is read as well, but only as a second opinion; no
@@ -737,7 +700,7 @@ OpenRouter's public model list is read as well, but only as a second opinion; no
A price never changes silently. Every change lands in the `price_changes` log with the old price, the new
A price never changes silently. Every change lands in the `price_changes` log with the old price, the new
price, the listing it was read from and the day it was read. A change of **more than 20%** waits there for the
price, the listing it was read from and the day it was read. A change of **more than 20%** waits there for the
-owner, who approves or rejects it on the operator console (`/console/pricebook`, `ai.provider.manage`); a
+owner, who approves or rejects it on the operator console ([price book](https://openvibe.exchange/console/pricebook), `ai.provider.manage`); a
smaller one the job applies at once, and the model row carries the new price with the day it was checked. A
smaller one the job applies at once, and the model row carries the new price with the day it was checked. A
listing that cannot be read leaves every price alone: an unreadable page is not evidence of a change. A price a
listing that cannot be read leaves every price alone: an unreadable page is not evidence of a change. A price a
person typed by hand is never touched.
person typed by hand is never touched.
@@ -1010,7 +973,7 @@ mix vectors from different providers (re-embed when the provider changes).
@@ -1010,7 +973,7 @@ mix vectors from different providers (re-embed when the provider changes).
| 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` |
| Developer apps: `ai.app.run` on the six direct operations and `GET /runs/:id`, 403 on every other route, project-scoped runs, readings and free allowance per project, govern tiers keyed by the project, sandbox and ungoverned runs free/local capacity only, a byok profile and `credential` never selected | `test/app-access.test.js` |
| Developer apps: `ai.app.run` on the six direct operations and `GET /runs/:id`, 403 on every other route, project-scoped runs, readings and free allowance per project, govern tiers keyed by the project, sandbox and ungoverned runs free/local capacity only, a byok profile and `credential` never selected | `test/app-access.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` |
+| Operator console: owner-only pages and controls on Exchange; AI keeps JSON admin APIs and redirects the old paths | Exchange `test/console-merge.test.js`, AI `test/home.test.js` |
| Public price/latency page: 200 without auth, rows from seeded stats and rate cards, the empty state, no caller ids, own-key providers, URLs or secrets | `test/stats-page.test.js` |
| Public price/latency page: 200 without auth, rows from seeded stats and rate cards, the empty state, no caller ids, own-key providers, URLs or secrets | `test/stats-page.test.js` |
| The OpenAI Responses adapter (`kind: responses`) with a stubbed fetch: request shape, output and usage parsing, `text.format`, error mapping, the key never in an error | `test/provider-responses.test.js` |
| The OpenAI Responses adapter (`kind: responses`) with a stubbed fetch: request shape, output and usage parsing, `text.format`, error mapping, the key never in an error | `test/provider-responses.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` |
@@ -1030,8 +993,8 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md). The rules the code keeps:
@@ -1030,8 +993,8 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md). The rules the code keeps:
- **Auth.** Every API route needs a Network service token for audience `openvibe.ai`, with the route's
- **Auth.** Every API route needs a Network service token for audience `openvibe.ai`, with the route's
capability and, for runs, a namespace its `ns` claim allows. Runs are private to their requester.
capability and, for runs, a namespace its `ns` claim allows. Runs are private to their requester.
A developer-app token is accepted only on `ai.app.run`'s routes ([Developer apps](#developer-apps)),
A developer-app token is accepted only on `ai.app.run`'s routes ([Developer apps](#developer-apps)),
- where it acts as its project; every other route refuses it. The operator console is Network staff
- only, signed in with SSO and PKCE, with CSRF on every write.
+where it acts as its project; every other route refuses it. The owner-only operator console is on OpenVibe.Exchange;
+AI's admin JSON endpoints still require their named capabilities.
- **Secrets.** Shared provider keys are `env:NAME` references, never values; a person's own key is
- **Secrets.** Shared provider keys are `env:NAME` references, never values; a person's own key is
AES-256-GCM encrypted with `AI_CREDENTIALS_KEY`, bound to its owner and subject, and never returned
AES-256-GCM encrypted with `AI_CREDENTIALS_KEY`, bound to its owner and subject, and never returned
(a four-character hint only). No response, log or console page carries a secret value.
(a four-character hint only). No response, log or console page carries a secret value.
@@ -69,7 +69,7 @@ last week's per-minute baseline for that bucket. Above `AI_ABUSE_VELOCITY_MIN_TO
@@ -69,7 +69,7 @@ last week's per-minute baseline for that bucket. Above `AI_ABUSE_VELOCITY_MIN_TO
`abuse.throttled` row goes to the operator audit log. The rate limit above records `abuse.rate_limited` the same
`abuse.throttled` row goes to the operator audit log. The rate limit above records `abuse.rate_limited` the same
way.
way.
-Operators see both on `/console/abuse`, and the gauge `ai_abuse_throttles{reason}` (events in the last 15 minutes)
+Operators see both on [Exchange's abuse page](https://openvibe.exchange/console/abuse), and the gauge `ai_abuse_throttles{reason}` (events in the last 15 minutes)
is on `/metrics`. OpenVibe.Host's `deploy/prometheus/openvibe-rules.yml` raises `OpenVibeAiAbuseThrottling`
is on `/metrics`. OpenVibe.Host's `deploy/prometheus/openvibe-rules.yml` raises `OpenVibeAiAbuseThrottling`
(severity ticket) above 20 in 15 minutes.
(severity ticket) above 20 in 15 minutes.
@@ -82,7 +82,7 @@ money, their destroy failures and their measured cold start are in `gpu_instance
@@ -82,7 +82,7 @@ money, their destroy failures and their measured cold start are in `gpu_instance
## The console page
## The console page
-`GET /console/gpus` (the "GPUs" entry in the console) shows whether the provisioner is on and why not,
+`GET https://openvibe.exchange/console/gpus` (the "GPUs" entry in the console) shows whether the provisioner is on and why not,
the spend this month, the committed worst case and the cap, the models with their weights and their
the spend this month, the committed worst case and the cap, the models with their weights and their
state, the measured cold start (from the launch to the first answered request), and every instance
state, the measured cold start (from the launch to the first answered request), and every instance
with its price (the hour and the second), endpoint, cold start and money. "Start" and "Stop" write a request row;
with its price (the hour and the second), endpoint, cold start and money. "Start" and "Stop" write a request row;
@@ -169,7 +169,7 @@ instances continue cleanup when these switches are unset. Quote previews show th
@@ -169,7 +169,7 @@ instances continue cleanup when these switches are unset. Quote previews show th
price; the live placement audit records base, allowance and total separately.
price; the live placement audit records base, allowance and total separately.
No new accounts, payment methods or provider keys are created.
No new accounts, payment methods or provider keys are created.
-`GET /console/gpus/:model/quote?strategy=cheapest` previews without a Billing hold or provider
+`GET https://openvibe.exchange/console/gpus/:model/quote?strategy=cheapest` previews without a Billing hold or provider
purchase. Exchange uses the public, minute-cached `/api/v1/exchange/gpu-quotes/:model` and can
purchase. Exchange uses the public, minute-cached `/api/v1/exchange/gpu-quotes/:model` and can
preview even when rentals are disabled. A private-endpoint create may include `strategy`;
preview even when rentals are disabled. A private-endpoint create may include `strategy`;
the choice persists on the endpoint. Every live GPU choice is recorded as `gpu.placement`
the choice persists on the endpoint. Every live GPU choice is recorded as `gpu.placement`
@@ -26,8 +26,8 @@ The free tier's controls are described in full in [`abuse-prevention.md`](abuse-
@@ -26,8 +26,8 @@ The free tier's controls are described in full in [`abuse-prevention.md`](abuse-
- **Network's trust signals** (account age, a passkey, a link count, 30-day activity) are read through the
- **Network's trust signals** (account age, a passkey, a link count, 30-day activity) are read through the
existing identity route and cached in memory for five minutes; AI never sees an email or which sites are linked.
existing identity route and cached in memory for five minutes; AI never sees an email or which sites are linked.
- **Operator events** (`audit_log`, `target_type='abuse'`) carry the reason, ASN, tier and counts, never an
- **Operator events** (`audit_log`, `target_type='abuse'`) carry the reason, ASN, tier and counts, never an
- address, a device id or a prompt, and are visible only in the operator console (`/console/abuse`,
- `/console/audit`).
+ address, a device id or a prompt, and are visible only in the owner-only operator console
+ ([abuse](https://openvibe.exchange/console/abuse), [audit](https://openvibe.exchange/console/audit)) on [OpenVibe.Exchange](https://openvibe.exchange/console).
`AI_ABUSE_SALT` keys every one of those hashes and signs the device cookie. Without it, a per-boot key is used:
`AI_ABUSE_SALT` keys every one of those hashes and signs the device cookie. Without it, a per-boot key is used:
the rows cannot be linked across boots and device cookies are re-issued, which is the safe default. Set it in
the rows cannot be linked across boots and device cookies are re-issued, which is the safe default. Set it in
@@ -35,7 +35,11 @@ function adminRouter({ config = {}, db, registry, pool, quotas, cache, runs, aut
@@ -35,7 +35,11 @@ function adminRouter({ config = {}, db, registry, pool, quotas, cache, runs, aut
const defWrite = auth.requireCap(CAPS.workflowManage);
const defWrite = auth.requireCap(CAPS.workflowManage);
const usageRead = auth.requireCap(CAPS.usageRead);
const usageRead = auth.requireCap(CAPS.usageRead);
const wrap = (fn) => async (req, res) => { try { const out = await fn(req, res); if (out !== undefined) res.json(out); } catch (err) { sendError(res, err, req.ov, log); } };
const wrap = (fn) => async (req, res) => { try { const out = await 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) => {
+ const delegated = String(req.get('x-ov-subject') || '');
+ const isExchange = String(req.principal && req.principal.sub || '').replace(/^svc:/, '') === 'exchange';
+ return { actor: isExchange && /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(delegated) ? delegated : req.principal.sub, trace: req.ov.traceId };
+ };
// Operators see the billing profile and shared pool name (migrations/0008) alongside the secret reference:
// Operators see the billing profile and shared pool name (migrations/0008) alongside the secret reference:
// names only, never a value.
// names only, never a value.
const view = async (p) => { const v = await ops.providerView(p); return v && { ...v, billing_profile: p.billing_profile, pool_key: p.pool_key }; };
const view = async (p) => { const v = await ops.providerView(p); return v && { ...v, billing_profile: p.billing_profile, pool_key: p.pool_key }; };
new file mode 100644
new file mode 100644
@@ -0,0 +1,181 @@
@@ -0,0 +1,181 @@
+'use strict';
+
+const express = require('express');
+const q = require('../console/queries');
+const { CAPS } = require('../auth');
+const { AiError } = require('../util');
+const providerPresets = require('../console/presets');
+const providerCatalog = require('../providers/catalog');
+const poolSeed = require('../providers/pool-seed');
+
+/** Read-only JSON read models for Exchange's owner console. Every query is bounded and the query module redacts runs. */
+function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, gpus = null, abuse = null, priceBook = null, creditGrants = null, sessionCookies = null, vault = null, settings = null, config = {}, log = console }) {
+ const r = express.Router();
+ const json = express.json({ limit: '64kb' });
+ const read = auth.requireCap(CAPS.usageRead);
+ const write = auth.requireCap(CAPS.providerManage);
+ const workflowWrite = auth.requireCap(CAPS.workflowManage);
+ const actor = (req) => {
+ const subject = String(req.get('x-ov-subject') || '');
+ return /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(subject) ? subject : req.principal.sub;
+ };
+ const wrap = (fn) => async (req, res) => {
+ try { res.set('Cache-Control', 'no-store').json(await fn(req)); }
+ catch (err) { log.error(`[console-json] ${req.method} ${req.path} failed (${err && err.code || 'ai.internal'})`); res.status(err.status || 500).json({ error: 'AI admin data is unavailable.', code: err.code || 'ai.internal' }); }
+ };
+ r.get('/api/v1/admin/console/:section', read, wrap(async (req) => {
+ const section = req.params.section;
+ if (section === 'overview') {
+ const since = new Date(clock.now() - 86400000).toISOString();
+ return { status: await ops.status(), counts24: await q.statusCounts(db, since), failures24: await q.failures(db, since), queue: runs.stats(), cache: await cache.stats() };
+ }
+ if (section === 'providers') {
+ const rows = await Promise.all((await registry.listProviders()).map(ops.providerView));
+ const estimates = creditGrants ? await Promise.all(['anthropic', 'openai'].map(async (provider) => ({
+ provider, grants: await creditGrants.list(provider), estimate: creditGrants.values().find((x) => x.provider === provider) || null,
+ preference: await creditGrants.preference(provider),
+ }))) : [];
+ const cookies = sessionCookies ? await sessionCookies.list('operator', 'owner') : [];
+ const balances = sessionCookies ? await Promise.all(cookies.map((c) => sessionCookies.balance('operator', 'owner', c.id))) : [];
+ return { providers: rows, models: await registry.listModels(), credit_estimates: estimates, session_credentials: cookies, session_balances: balances };
+ }
+ if (section === 'runs') {
+ const { filters, form, bad } = q.parseRunFilters(req.query);
+ const list = await q.listRuns(db, filters);
+ return { rows: list.rows, next: list.next, filters: form, invalid_filters: bad, facets: await q.runFacets(db) };
+ }
+ if (section === 'run') {
+ const run = await runs.explained(await runs.get(req.query.id));
+ if (!run) return { run: null };
+ return { run, citations: await runs.citations(run.id), requests: await runs.requestsFor(run.id), input: q.preview(run.input), output: q.preview(run.output) };
+ }
+ if (section === 'quotas') return { quotas: await quotas.list(), current: await quotas.counters() };
+ if (section === 'usage') {
+ const { filters, form, bad } = q.parseUsageFilters(req.query, clock.now());
+ return { ...await q.usageReport(quotas, filters), filters: form, invalid_filters: bad };
+ }
+ if (section === 'cache') return { cache: await cache.stats() };
+ if (section === 'audit') {
+ const { filters, form, bad } = q.parseAuditFilters(req.query);
+ const result = await q.listAudit(db, filters);
+ return { rows: result.rows, next: result.next, filters: form, invalid_filters: bad, kinds: q.AUDIT_KINDS };
+ }
+ if (section === 'gpus') return { state: gpus ? await gpus.state() : null };
+ if (section === 'pricebook') return { report: priceBook ? priceBook.last : null, state: priceBook ? await priceBook.state.read() : null,
+ pending: priceBook ? await priceBook.pending(50) : [], recent: priceBook ? await priceBook.changes({ status: 'applied', limit: 50 }) : [] };
+ if (section === 'exchange') return { credits: await q.settleQueue(db, clock.now()), routing: await q.routingEvidence(db) };
+ if (section === 'abuse') return { report: abuse ? await abuse.overview() : null };
+ if (section === 'glossary') return { providers: await registry.listProviders(), models: await registry.listModels() };
+ throw Object.assign(new Error('no such console section'), { status: 404 });
+ }));
+ r.get('/api/v1/admin/console/gpus/:model/quote', read, wrap(async (req) => {
+ if (!gpus) throw new AiError(409, 'ai.conflict', 'GPU quotes are off on this host');
+ return await gpus.preview(String(req.params.model), { strategy: req.query.strategy || undefined });
+ }));
+ r.post('/api/v1/admin/console/providers/:key/key', json, write, wrap(async (req) => {
+ if (!vault || !settings) throw new AiError(503, 'vault.unavailable', 'provider keys cannot be stored on this host');
+ const provider = await registry.getProvider(req.params.key);
+ if (!provider) throw new AiError(404, 'ai.not_found', 'no such provider');
+ const value = String((req.body || {}).api_key || '');
+ if (value.length < 8 || value.length > 400) throw new AiError(422, 'ai.invalid', 'the provider key must be 8-400 characters');
+ const who = { actor: actor(req), trace: req.ov && req.ov.traceId };
+ await vault.put(provider.key, value, who);
+ await registry.upsertProvider({ key: provider.key, secret_ref: `vault:${provider.key}` }, { ...who, origin: 'admin' });
+ await registry.audit(who.actor, 'provider.key_replaced', 'provider', provider.key, { trace: who.trace });
+ return { ok: true, provider: provider.key };
+ }));
+ r.post('/api/v1/admin/console/providers', json, write, wrap(async (req) => {
+ if (!vault || !settings) throw new AiError(503, 'vault.unavailable', 'provider keys cannot be stored on this host');
+ const b = req.body || {};
+ const preset = providerPresets.byId(String(b.preset || 'custom')) || providerPresets.byId('custom');
+ const key = String(b.key || preset.id).trim().toLowerCase();
+ if (!/^[a-z0-9][a-z0-9._-]{1,40}$/.test(key)) throw new AiError(422, 'ai.invalid', 'the provider name must be 2-41 lowercase letters, digits, dots and dashes');
+ if (await registry.getProvider(key)) throw new AiError(409, 'ai.conflict', 'a provider with that name already exists');
+ const apiKey = String(b.api_key || '');
+ if (apiKey.length < 8 || apiKey.length > 400) throw new AiError(422, 'ai.invalid', 'the provider key must be 8-400 characters');
+ const baseUrl = String(b.base_url || preset.base_url || '').trim();
+ if (!/^https:\/\/[^\s]+$/i.test(baseUrl)) throw new AiError(422, 'ai.invalid', 'the base URL must use https');
+ const actorInfo = { actor: actor(req), trace: req.ov && req.ov.traceId };
+ const provider = await registry.upsertProvider({ key, display_name: String(b.display_name || preset.name || key).slice(0, 80),
+ kind: preset.kind, status: 'active', base_url: baseUrl.replace(/\/+$/, ''),
+ auth_mode: preset.kind === 'anthropic' ? 'x-api-key' : 'bearer', secret_ref: `vault:${key}`,
+ default_model: String(b.default_model || preset.model || '').trim() || null,
+ capabilities: [...providerPresets.TEXT_CAPS, ...(b.vision === true ? ['vision'] : [])],
+ timeout_ms: 60000, priority: 50, billing_profile: 'metered', metadata: { added_from: 'exchange-console', preset: preset.id },
+ }, { ...actorInfo, origin: 'admin' });
+ await vault.put(key, apiKey, actorInfo);
+ if (settings.providerKeys && settings.providerKeys.setOrigin) await settings.providerKeys.setOrigin(key, baseUrl, { actor: { type: 'service', id: 'ai' }, reason: 'provider added' });
+ await providerCatalog.sync({ registry, provider, apiKey, fetchImpl, actor: actorInfo.actor }).catch(() => null);
+ await poolSeed.applyCatalogPrices({ registry, provider, actor: actorInfo.actor }).catch(() => null);
+ return { ok: true, provider: await ops.providerView(provider) };
+ }));
+ r.post('/api/v1/admin/console/provider-credits', json, write, wrap(async (req) => {
+ if (!creditGrants) throw new AiError(503, 'ai.unavailable', 'provider credit records are not configured');
+ const b = req.body || {};
+ const provider = ['anthropic', 'openai'].includes(String(b.provider)) ? String(b.provider) : 'anthropic';
+ const grant = await creditGrants.add({ provider_key: provider, kind: String(b.kind || 'promotional'), amount_usd: Number(b.amount_usd),
+ as_of: String(b.as_of || ''), received_at: String(b.received_at || b.as_of || ''),
+ remaining_as_of_usd: b.remaining_as_of_usd == null ? Number(b.amount_usd) : Number(b.remaining_as_of_usd),
+ remaining_as_of: String(b.remaining_as_of || b.as_of || ''),
+ expires_at: b.expires_at ? new Date(`${b.expires_at}T23:59:59Z`).toISOString() : null,
+ applies_to: String(b.applies_to || '').slice(0, 200) || null });
+ return { grant };
+ }));
+ r.post('/api/v1/admin/console/provider-credit-preference', json, write, wrap(async (req) => {
+ if (!creditGrants) throw new AiError(503, 'ai.unavailable', 'provider credit preferences are not configured');
+ const b = req.body || {};
+ const provider = ['anthropic', 'openai'].includes(String(b.provider)) ? String(b.provider) : 'anthropic';
+ return { preference: await creditGrants.setPreference({ provider_key: provider, enabled: b.enabled === true, floor_usd: Number(b.floor_usd) }) };
+ }));
+ r.post('/api/v1/admin/console/provider-session-credentials', json, write, wrap(async (req) => {
+ if (!sessionCookies) throw new AiError(503, 'ai.unavailable', 'provider session credentials are not configured');
+ const b = req.body || {};
+ return { credential: await sessionCookies.add('operator', 'owner', { provider: b.provider, cookie: String(b.cookie || ''), label: b.label, expires_at: b.expires_at || null, org_id: b.org_id }) };
+ }));
+ r.delete('/api/v1/admin/console/provider-session-credentials/:id', write, wrap(async (req) => {
+ if (!sessionCookies) throw new AiError(503, 'ai.unavailable', 'provider session credentials are not configured');
+ return { removed: await sessionCookies.remove('operator', 'owner', req.params.id) };
+ }));
+ r.post('/api/v1/admin/console/gpus/:model/:action', write, wrap(async (req) => {
+ if (!gpus) throw new AiError(409, 'ai.conflict', 'GPU controls are off on this host');
+ const action = String(req.params.action);
+ if (!['launch', 'stop'].includes(action)) throw new AiError(404, 'ai.not_found', 'no such GPU action');
+ await gpus.request(String(req.params.model || ''), action, actor(req));
+ return { ok: true };
+ }));
+ r.post('/api/v1/admin/console/pricebook/changes/:id', write, wrap(async (req) => {
+ if (!priceBook) throw new AiError(409, 'ai.conflict', 'the price book is off on this host');
+ const decision = String((req.body || {}).decision) === 'approve' ? 'approve' : 'reject';
+ const id = String(req.params.id).slice(0, 60);
+ const out = await priceBook.decide(id, { decision, actor: actor(req) });
+ if (!out.ok) throw new AiError(409, 'ai.conflict', out.error);
+ await registry.audit(actor(req), decision === 'approve' ? 'price_change.approved' : 'price_change.rejected', 'price_change', id, { trace: req.ov && req.ov.traceId, metadata: { decision } });
+ return { ok: true, decision, change: out.change };
+ }));
+ r.post('/api/v1/admin/console/pricebook/run', write, wrap(async (req) => {
+ if (!priceBook) throw new AiError(409, 'ai.conflict', 'the price book is off on this host');
+ const report = await priceBook.runOnce({ force: true });
+ await registry.audit(actor(req), 'pricebook.run', 'pricebook', 'run', { trace: req.ov && req.ov.traceId, metadata: { sources: (report.sources || []).length, applied: (report.applied || []).length, pending: (report.pending || []).length } });
+ return report;
+ }));
+ r.post('/api/v1/admin/console/runs/:id/cancel', workflowWrite, wrap(async (req) => {
+ const subject = actor(req);
+ const run = await runs.cancel(req.params.id, { sub: subject, subject: { type: 'user', id: subject } }, {
+ trace: req.ov && req.ov.traceId, principalHas: (cap) => cap === CAPS.workflowManage,
+ });
+ return { run };
+ }));
+ r.post('/api/v1/admin/console/exchange/credits/:id/resolve', workflowWrite, wrap(async (req) => {
+ const id = String(req.params.id || '').slice(0, 300);
+ const row = id ? await db.prepare('SELECT id, hold_id, request_id, status FROM credit_settle_queue WHERE id = ?').get(id) : null;
+ if (!row) throw new AiError(404, 'ai.not_found', 'no such settle queue row');
+ if (row.status !== 'review') throw new AiError(409, 'ai.conflict', 'only a row in review can be marked resolved');
+ const now = new Date(clock.now()).toISOString();
+ await db.prepare(`UPDATE credit_settle_queue SET status = 'resolved', resolved_at = ?::timestamptz, resolved_by = ?, updated_at = ?::timestamptz WHERE id = ? AND status = 'review'`)
+ .run(now, actor(req), now, id);
+ return { ok: true, id };
+ }));
+ return r;
+}
+
+module.exports = { consoleDataRouter };
@@ -221,21 +221,26 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
@@ -221,21 +221,26 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
// dashboard, an API for Exchange. Nothing here writes an offer; the offer belongs to Network and the Link owner.
// dashboard, an API for Exchange. Nothing here writes an offer; the offer belongs to Network and the Link owner.
if (community) app.use('/api/v1', require('./api/community').communityRouter({ community, auth, log }));
if (community) app.use('/api/v1', require('./api/community').communityRouter({ community, auth, log }));
app.use(adminRouter({ config, db, registry, pool, quotas, cache, runs, auth, settings, ops, log }));
app.use(adminRouter({ config, db, registry, pool, quotas, cache, runs, auth, settings, ops, log }));
- // The operator console (/console, /auth/*): Network staff, server-rendered, no scripts (server/console).
+ app.use(require('./api/console-data').consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth, clock, fetchImpl, gpus, abuse, priceBook, creditGrants, sessionCookies, vault, settings, config, log }));
+ // The operator console moved to OpenVibe.Exchange. Keep the legacy AI paths as permanent redirects;
+ // run receipts still use the person-session routes below and the AI JSON admin APIs stay here.
// Person sessions are separate, read-only and open one run receipt each (server/person-session.js).
// Person sessions are separate, read-only and open one run receipt each (server/person-session.js).
const personSessions = createPersonSessions({ db, config, clock });
const personSessions = createPersonSessions({ db, config, clock });
// The person's own AI settings (L36 Y1/Y3): their keys under owner `self`, and their routing preferences. Only
// The person's own AI settings (L36 Y1/Y3): their keys under owner `self`, and their routing preferences. Only
// the person's Network token or their AI site session (same origin) reaches them; a service token is refused.
// the person's Network token or their AI site session (same origin) reaches them; a service token is refused.
if (credentials && preferences && grants) {
if (credentials && preferences && grants) {
app.use(require('./me').meRouter({ credentials, preferences, grants, auth, registry, config, clock, log, personSessions, sessionCookies, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));
app.use(require('./me').meRouter({ credentials, preferences, grants, auth, registry, config, clock, log, personSessions, sessionCookies, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));
- // The same settings as a page for people (GET /you, plain forms on AI's own person session).
- app.use(require('./you-page').youPageRouter({ credentials, preferences, grants, personSessions, config, log, machines: pool && pool.machines }));
}
}
- app.use(consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, env, clock, fetchImpl, log, vault, settings, personSessions, abuse, priceBook, creditGrants, sessionCookies }));
+ // The person's human settings moved to Exchange; keep the JSON API here for first-party clients.
+ app.use('/you', (req, res) => res.redirect(301, `${config.exchangeUrl}/privacy/keys${req.originalUrl.includes('?') ? req.originalUrl.slice(req.originalUrl.indexOf('?')) : ''}`));
+ app.use('/console', (req, res) => res.redirect(301, `${config.exchangeUrl}${req.originalUrl}`));
+ app.get('/auth/login', (req, res) => res.redirect(301, `${config.exchangeUrl}/auth/login${req.originalUrl.includes('?') ? req.originalUrl.slice(req.originalUrl.indexOf('?')) : ''}`));
+ // Person sign-in for /runs/:id shares the console SSO router, but the legacy operator pages are intercepted above.
+ app.use(consoleRouter({ config, db, registry, env, clock, fetchImpl, log, personSessions }));
app.use(runPageRouter({ runs, personSessions, log }));
app.use(runPageRouter({ runs, personSessions, log }));
// ── The public pages moved to OpenVibe.Exchange (2026-11-07) ────
// ── The public pages moved to OpenVibe.Exchange (2026-11-07) ────
- // ai.openvibe.services keeps the API, a run's receipt and the operator console. Every human page it used
+ // ai.openvibe.services keeps the API and a run's receipt. Every human page it used
// to serve answers 301 to its Exchange home: the page map is exact, an unknown human path goes to the home,
// to serve answers 301 to its Exchange home: the page map is exact, an unknown human path goes to the home,
// and no API path is ever redirected. The site's page list, price page, update log, legal pages and
// and no API path is ever redirected. The site's page list, price page, update log, legal pages and
// crawl artifacts moved with it.
// crawl artifacts moved with it.
@@ -261,8 +266,8 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
@@ -261,8 +266,8 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
'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 /runs/:id your run receipt (OpenVibe.Network sign-in; your own runs only)',
'GET /runs/:id your run receipt (OpenVibe.Network sign-in; your own runs only)',
- 'GET /you your own provider keys and how your AI is paid (OpenVibe.Network sign-in)',
- 'GET /console the operator console (OpenVibe.Network staff sign-in)',
+ `GET ${EXCHANGE}/privacy/keys your own provider keys and session cookies`,
+ `GET ${EXCHANGE}/console owner-only AI operator console`,
'',
'',
'Callers authenticate with OpenVibe.Network tokens (audience openvibe.ai): first-party service tokens, or a',
'Callers authenticate with OpenVibe.Network tokens (audience openvibe.ai): first-party service tokens, or a',
"developer app's token (ai.app.run) on the six direct operations and GET /api/v1/runs/:id.",
"developer app's token (ai.app.run) on the six direct operations and GET /api/v1/runs/:id.",
@@ -1,700 +1,63 @@
@@ -1,700 +1,63 @@
'use strict';
'use strict';
-/**
- * The AI operator console (roadmap WS-O task 4): server-rendered, no JavaScript, on
- * ai.openvibe.services under /console, for OpenVibe.Network staff.
- *
- * GET /auth/login, /auth/callback Network SSO (authorization code + PKCE S256, OAuth client `ai`)
- * GET /auth/person/login the same sign-in for any person, for their run receipt page
- * 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; free allowance this period, quotas.freeAllowance.current;
- * Billing readings by delivery state, usage-samples deliveryReport)
- * GET /console/cache; POST /console/cache/purge (writes: ai.provider.manage; ops.purgeCache)
- * GET /console/audit[?kind&action&actor&target_type&target_id&before]
- * GET /console/pricebook the living price book: the prices that wait for the owner
- * POST /console/pricebook/changes/:id approve or reject a waiting price change (ai.provider.manage)
- * POST /console/pricebook/run read the providers' listings now (ai.provider.manage)
- * GET /console/exchange AI Exchange state (AI_CREDITS or AI_ROUTING_EVIDENCE)
- * POST /console/exchange/credits/:id/resolve mark a review settle row resolved, never retried
- *
- * 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.
- */
+
+// AI keeps only the sign-in flow used by private run receipts. Operator pages live on Exchange.
const crypto = require('crypto');
const crypto = require('crypto');
const express = require('express');
const express = require('express');
-const { AiError, resolveSecret, providerSecret, iso } = require('../util');
-const usageSamples = require('../usage-samples');
-const { CAPS } = require('../auth');
-const { staff: staffMap } = require('openvibe-contracts');
+const { resolveSecret } = require('../util');
const { createSessions, sameString } = require('./session');
const { createSessions, sameString } = require('./session');
-const sso = require('./sso');
-const q = require('./queries');
-const catalog = require('../providers/catalog');
-const poolSeed = require('../providers/pool-seed');
-const { CSP, esc, pages, healthText } = 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;
-}
-
-/** Where to go after a person signs in for a run receipt: exactly one /runs/run_<ULID> page; anything else goes home. */
-function sanitizeRunNext(v) {
- if (typeof v === 'string' && /[\u0000-\u001f\u007f\\]/.test(v)) return '/';
- const s = String(v || '');
- return s.length <= 300 && /^\/runs\/run_[0-9A-HJKMNP-TV-Z]{26}$/.test(s) ? s : '/';
+const { createConsoleSso } = require('./sso');
+const { pages } = require('./views');
+
+/** A person can return only to one run receipt after signing in. */
+function sanitizeRunNext(value) {
+ if (typeof value === 'string' && /[\u0000-\u001f\u007f\\]/.test(value)) return '/';
+ const next = String(value || '');
+ return next.length <= 300 && /^\/runs\/run_[0-9A-HJKMNP-TV-Z]{26}$/.test(next) ? next : '/';
}
}
-const NOTICES = {
- provider_status: 'Provider status changed.',
- provider_added: 'Provider added. Point a route at it below, or on the route\'s page.',
- provider_key: 'Key replaced. Runs use it from now on.',
- model_added: 'Model registered.',
- models_synced: 'Model list read. New models are listed switched off: turn on the ones routes may use.',
- model_status: 'Model switched.',
- route_used: 'Route changed: new runs use the provider and model you chose (the previous one is kept as a fallback).',
- route_default: 'Route back to its default.',
- 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.',
- settle_resolved: 'Settle queue row marked resolved. Nothing was retried.',
- price_approved: 'The price change was approved: the price the router charges is the new one.',
- price_rejected: 'The price change was rejected: the price stands.',
- pricebook_ran: 'The provider listings were read.',
-};
-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, ops, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, vault = null, settings = null, personSessions = null, abuse = null, priceBook = null, creditGrants = null, sessionCookies = null }) {
+function consoleRouter({ config, db, registry, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, personSessions = null }) {
const cc = config.console;
const cc = config.console;
- const creditsOn = Boolean(config.credits && config.credits.enabled);
- const routingOn = Boolean(config.routingEvidence && config.routingEvidence.enabled);
- const exchangeOn = creditsOn || routingOn;
- // The GPU provisioner's state (the same module the service runs): the page shows the instances, the
- // spend this month and the cap. The actions only write a request; the reconciler fulfils it.
- const gpus = require('../gpu/provisioner').createGpuProvisioner({ db, registry, settings, clock, fetchImpl, log });
- 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 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' });
- // openvibe-sdk/sso owns the OAuth handshake and /auth/health; the staff session below stays this site's.
- const consoleSso = sso.createConsoleSso({ config, clientSecret: clientSecret(), fetchImpl, log });
-
- /** Every secret value this process can resolve, so that no page can ever carry one (belt and braces). */
- async function secretValues() {
- const vals = new Set([clientSecret(), secret]);
- for (const p of await registry.listProviders()) vals.add(providerSecret(p, env));
- return [...vals].filter((v) => v && v.length >= 8);
- }
- async function scrub(text) {
- let out = text;
- for (const v of await secretValues()) for (const form of new Set([v, esc(v)])) out = out.split(form).join('[redacted]');
- return out;
- }
- const send = async (res, status, text) => res.status(status).type('html').send(await scrub(text));
- const who = (req) => ({ actor: req.staff.subject, trace: req.ov ? req.ov.traceId : null });
- // staff.exchange only picks the extra navigation entry; it is a copy, so the signed-in claims stay as read.
- const common = (req) => ({ staff: req.staff ? { ...req.staff, exchange: exchangeOn } : req.staff, csrf: req.staff ? req.staff.csrf : '' });
- const audit = async (actor, action, targetType, targetId, metadata, req) => await 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, async (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 await 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, async (req, res, next) => {
- req.staff = null;
- const row = await sessions.read(req);
- if (!row) return next();
- const caps = consoleCapabilities(row.staff);
- if (!caps.includes(CAPS.usageRead)) {
- await sessions.revoke(row);
- sessions.clear(res);
- await audit(row.subject, 'console.request.refused', 'console', 'session', { reason: 'the session no longer maps to ai.usage.read' }, req);
- return await 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 async (req, res, next) => {
- if (!req.staff) {
- if (req.method === 'GET') return await send(res, 401, pages.signIn({ next: sanitizeNext(req.originalUrl) }));
- return await 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') await audit(req.staff.subject, 'console.request.refused', 'console', req.path, { reason: `needs ${cap}` }, req);
- return await send(res, 403, pages.message({ title: 'Not authorized', text: `This needs ${cap}.`, ...common(req) }));
- }
- if (req.method === 'POST' && !csrfOk(req)) {
- await audit(req.staff.subject, 'console.request.refused', 'console', req.path, { reason: 'missing or invalid CSRF token, or a cross-site request' }, req);
- return await 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(async () => await fn(req, res, next)).catch(async (e) => {
- if (e instanceof AiError) return await 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) await 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 started = consoleSso.start();
- sessions.setFlow(res, { s: started.state, v: started.verifier, n: sanitizeNext(req.query.next) });
- res.redirect(302, started.url);
+ if (!secret || secret.length < 32) secret = crypto.randomBytes(32).toString('hex');
+ const clientSecret = resolveSecret(cc.clientSecretRef, env);
+ const sessions = createSessions({ db, config, clock, secret });
+ const sso = createConsoleSso({ config, clientSecret, fetchImpl, log });
+ const r = express.Router();
+ const send = (res, status, body) => res.status(status).type('html').send(body);
+ const audit = (subject, action, metadata, req) => registry.audit(subject || 'anonymous', action, 'run_page', 'sign_in', {
+ trace: req.ov && req.ov.traceId || null, metadata,
});
});
- // A person's sign-in for one run receipt. It uses the same OAuth client and redirect URI, but the
- // flow cookie says which callback branch to take, so a non-staff person never receives a staff session.
r.get('/auth/person/login', (req, res) => {
r.get('/auth/person/login', (req, res) => {
- const started = consoleSso.start();
- sessions.setFlow(res, { s: started.state, v: started.verifier, n: sanitizeRunNext(req.query.next), kind: 'person' });
- res.redirect(302, started.url);
+ const flow = sso.start();
+ sessions.setFlow(res, { s: flow.state, v: flow.verifier, n: sanitizeRunNext(req.query.next), kind: 'person' });
+ return res.redirect(302, flow.url);
});
});
- // GET /auth/health: does the Network accept this site's OAuth client? 503 only when it refuses it.
- r.get('/auth/health', wrap(async (req, res) => {
- const client = await consoleSso.checkClient();
- res.setHeader('Cache-Control', 'no-store');
- return res.status(client === 'invalid' ? 503 : 200).json({ ok: client !== 'invalid', client });
- }));
-
- r.get('/auth/callback', wrap(async (req, res) => {
+ r.get('/auth/callback', async (req, res) => {
const flow = sessions.takeFlow(req, res);
const flow = sessions.takeFlow(req, res);
- const personFlow = flow && flow.kind === 'person';
- if (req.query.error) {
- const text = `Sign-in was not completed (${String(req.query.error).slice(0, 60)}).`;
- return await send(res, 400, personFlow ? pages.message({ title: 'Sign-in not completed', text, back: '/', site: 'your runs' }) : pages.signIn({ message: text }));
- }
- if (!flow || !req.query.code || !sameString(req.query.state, flow.s)) {
- const text = 'The sign-in could not be verified (expired or mismatched state). Please try again.';
- return await send(res, 400, personFlow ? pages.message({ title: 'Sign-in not verified', text, back: '/', site: 'your runs' }) : pages.signIn({ message: text }));
+ if (!flow || flow.kind !== 'person' || !req.query.code || !sameString(req.query.state, flow.s)) {
+ return send(res, 400, pages.message({ title: 'Sign-in not verified', text: 'The sign-in could not be verified (expired or mismatched state). Please try again.', back: '/', site: 'your runs' }));
}
}
let person;
let person;
- try {
- person = await consoleSso.exchange({ code: req.query.code, verifier: flow.v, now: clock.now() });
- } catch (e) {
- log.warn(`[console] sign-in failed: ${e.message}`);
- const text = 'OpenVibe.Network did not confirm the sign-in. Please try again.';
- return await send(res, e.status && e.status < 500 ? 400 : 502, personFlow ? pages.message({ title: 'Sign-in failed', text, back: '/', site: 'your runs' }) : pages.signIn({ message: text }));
- }
- const ipHash = sessions.ipHash(req.ip);
- if (flow.kind === 'person') {
- if (!personSessions) return await send(res, 503, pages.message({ title: 'Sign-in unavailable', text: 'The run page sign-in is not configured on this host.', back: '/', site: 'your runs' }));
- if (person.error) {
- await audit('anonymous', 'run_page.sign_in.refused', 'run_page', 'sign_in', { reason: person.error, ip_hash: ipHash }, req);
- return await send(res, 403, pages.message({ title: 'Not authorized', text: 'This page needs an OpenVibe.Network person account. Your sign-in was recorded.', back: '/', site: 'your runs' }));
- }
- await db.tx(async () => {
- await personSessions.create(res, { subject: person.subject, username: person.username });
- await audit(person.subject, 'run_page.sign_in', 'run_page', 'sign_in', { ip_hash: ipHash }, req);
- });
- return res.redirect(303, flow.n || '/');
+ try { person = await sso.exchange({ code: req.query.code, verifier: flow.v, now: clock.now() }); }
+ catch (error) {
+ log.warn(`[run-page] sign-in failed (${error && error.status || 502})`);
+ return send(res, 502, pages.message({ title: 'Sign-in failed', text: 'OpenVibe.Network did not confirm the sign-in. Please try again.', back: '/', site: 'your runs' }));
}
}
+ if (!personSessions) return send(res, 503, pages.message({ title: 'Sign-in unavailable', text: 'The run page sign-in is not configured on this host.', back: '/', site: 'your runs' }));
if (person.error) {
if (person.error) {
- await audit('anonymous', 'console.sign_in.refused', 'console', 'sign_in', { reason: person.error, ip_hash: ipHash }, req);
- return await 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)) {
- await 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 await 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.' }));
+ await audit('anonymous', 'run_page.sign_in.refused', { reason: person.error }, req);
+ return send(res, 403, pages.message({ title: 'Not authorized', text: 'This page needs an OpenVibe.Network person account. Your sign-in was recorded.', back: '/', site: 'your runs' }));
}
}
await db.tx(async () => {
await db.tx(async () => {
- await sessions.create(res, { subject: person.subject, username: person.username, staff: person.staff, ip: req.ip });
- await 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, async (req, res) => {
- await db.tx(async () => {
- await sessions.revoke(req.staff.session);
- await audit(req.staff.subject, 'console.sign_out', 'console', 'sign_in', {}, req);
+ await personSessions.create(res, { subject: person.subject, username: person.username });
+ await audit(person.subject, 'run_page.sign_in', {}, req);
});
});
- sessions.clear(res);
- res.redirect(303, '/console');
- });
-
- // ── Overview ─────────────────────────────────────────────
- r.get('/console', read, wrap(async (req, res) => {
- const since = new Date(clock.now() - DAY_MS).toISOString();
- const st = await ops.status();
- const problems = [];
- for (const p of (await registry.listProviders()).filter((x) => x.status === 'active' && x.kind !== 'stub')) {
- const v = await ops.providerView(p);
- if (v.credentials === 'missing') {
- const ref = /^env:([A-Z][A-Z0-9_]*)$/.exec(String(p.secret_ref || ''));
- problems.push({ key: p.key, kind: p.kind, problem: ref ? `No key: the server's settings file has no ${ref[1]}, and none was entered here. Enter one in the admin panel's Setup tab.` : 'No key: enter one in the admin panel\'s Setup tab or on the AI providers page.' });
- }
- if (v.health && v.health.state !== 'closed') problems.push({ key: p.key, kind: p.kind, problem: healthText(v.health, { breaker: config.breaker, now: clock.now() }).text });
- }
- const stats = await 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);
- };
- await send(res, 200, pages.overview({
- ...common(req), st, counts24: await q.statusCounts(db, since), failures24: await 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(async (req, res) => {
- const estimates = creditGrants ? await Promise.all(['anthropic', 'openai'].map(async (provider) => {
- const grantRows = await creditGrants.list(provider);
- const estimate = creditGrants.values().find((x) => x.provider === provider);
- return { provider, estimate: estimate || (grantRows.length ? { provider, promotional: null, purchased: null, grants: grantRows, source: null, at: null } : null), preference: await creditGrants.preference(provider) };
- })) : [];
- const canManageCookies = req.staff.caps.includes(CAPS.configManage);
- const sessionCredentials = sessionCookies && canManageCookies ? await sessionCookies.list('operator', 'owner') : [];
- const sessionBalances = sessionCookies && canManageCookies ? await Promise.all(sessionCredentials.map((c) => sessionCookies.balance('operator', 'owner', c.id))) : [];
- return await send(res, 200, pages.providers({
- ...common(req), rows: (await Promise.all((await registry.listProviders()).map(ops.providerView))), models: await registry.listModels(), stubFallback: config.stubFallback,
- canManage: req.staff.caps.includes(CAPS.providerManage), notice: notice(req), keyHints: vault ? await vault.hints() : new Map(),
- breaker: config.breaker, now: clock.now(), creditEstimates: estimates.map(({ provider, estimate, preference }) => ({ provider, estimate: estimate || { provider, promotional: null, purchased: null, grants: [], source: null, at: null }, preference })), sessionCredentials, sessionBalances, canManageCookies,
- }));
- }));
- r.post('/console/provider-credits', needs(CAPS.providerManage), wrap(async (req, res) => {
- if (!creditGrants) throw new Error('credit grants are unavailable');
- const provider = ['anthropic', 'openai'].includes(String(req.body.provider)) ? String(req.body.provider) : 'anthropic';
- await creditGrants.add({ provider_key: provider, kind: String(req.body.kind || 'promotional'), amount_usd: Number(req.body.amount_usd), as_of: String(req.body.as_of || ''), received_at: String(req.body.received_at || req.body.as_of || ''), remaining_as_of_usd: req.body.remaining_as_of_usd == null ? Number(req.body.amount_usd) : Number(req.body.remaining_as_of_usd), remaining_as_of: String(req.body.remaining_as_of || req.body.as_of || ''), expires_at: req.body.expires_at ? new Date(`${req.body.expires_at}T23:59:59Z`).toISOString() : null, applies_to: String(req.body.applies_to || '').slice(0, 200) || null });
- res.redirect(303, '/console/providers?done=provider_credit');
- }));
- r.post('/console/provider-credit-preference', needs(CAPS.providerManage), wrap(async (req, res) => {
- if (!creditGrants) throw new Error('credit preferences are unavailable');
- const provider = ['anthropic', 'openai'].includes(String(req.body.provider)) ? String(req.body.provider) : 'anthropic';
- await creditGrants.setPreference({ provider_key: provider, enabled: req.body.enabled === '1', floor_usd: Number(req.body.floor_usd) });
- creditGrants.poll().catch((err) => log.warn(`[ai] credit estimate failed: ${String(err && err.message || err).slice(0, 180)}`));
- res.redirect(303, '/console/providers?done=provider_credit');
- }));
- r.post('/console/provider-session-credentials', needs(CAPS.configManage), wrap(async (req, res) => {
- if (!sessionCookies) throw new Error('session credentials are unavailable');
- await sessionCookies.add('operator', 'owner', { provider: req.body.provider, cookie: String(req.body.cookie || ''), label: req.body.label, expires_at: req.body.expires_at || null, org_id: req.body.org_id });
- res.redirect(303, '/console/providers?done=session_credential');
- }));
- r.get('/console/glossary', read, wrap(async (req, res) => await send(res, 200, pages.glossary({ ...common(req) }))));
- r.post('/console/providers/:key/status', needs(CAPS.providerManage), wrap(async (req, res) => {
- await 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(async (req, res) => {
- if (!await registry.getProvider(req.params.key)) throw new AiError(404, 'ai.not_found', 'no such provider');
- await ops.resetCircuit(req.params.key, who(req));
- res.redirect(303, '/console/providers?done=circuit_reset');
- }));
-
- // Adding a provider with its key, replacing a key, registering a model (owner: ai.provider.manage). The key goes to
- // the vault (server/vault.js, encrypted at rest); the provider's secret_ref names it, never holds it.
- const presets = require('./presets');
- const KEY_RE = /^[a-z0-9][a-z0-9._-]{1,40}$/;
- r.get('/console/providers/new', needs(CAPS.providerManage), wrap(async (req, res) => await send(res, 200, pages.providerNew({
- ...common(req), presets: presets.PRESETS, preset: presets.byId(String(req.query.preset || '')) || null, vaultReady: !!vault && !!settings,
- }))));
- r.post('/console/providers', needs(CAPS.providerManage), wrap(async (req, res) => {
- if (!vault || !settings) throw new AiError(503, 'vault.unavailable', 'provider keys cannot be stored here');
- const b = req.body || {};
- const preset = presets.byId(String(b.preset || 'custom')) || presets.byId('custom');
- const key = String(b.key || preset.id).trim().toLowerCase();
- if (!KEY_RE.test(key)) throw new AiError(422, 'ai.invalid', 'the provider name is 2-41 lowercase letters, digits, dots and dashes');
- if (await registry.getProvider(key)) throw new AiError(409, 'ai.conflict', `a provider called ${key} already exists: replace its key on the providers page`);
- const baseUrl = String(b.base_url || preset.base_url || '').trim();
- if (!/^https:\/\/[^\s]+$/i.test(baseUrl)) throw new AiError(422, 'ai.invalid', 'the base URL is an https address');
- const caps = [...presets.TEXT_CAPS, ...(b.vision ? ['vision'] : [])];
- // The provider comes first: its base URL is the origin its key is sealed to (D11).
- await registry.upsertProvider({
- key, display_name: String(b.display_name || preset.name || key).slice(0, 80), kind: preset.kind, status: 'active', base_url: baseUrl.replace(/\/+$/, ''),
- auth_mode: preset.kind === 'anthropic' ? 'x-api-key' : 'bearer', secret_ref: `vault:${key}`, default_model: String(b.default_model || preset.model || '').trim() || null,
- capabilities: caps, timeout_ms: 60000, priority: 50, billing_profile: 'metered', metadata: { added_from: 'console', preset: preset.id },
- }, { ...who(req), origin: 'admin' });
- await vault.put(key, b.api_key, who(req));
- // Its model list, read right away (best effort: a provider whose list cannot be read is still added).
- const added = await registry.getProvider(key);
- await catalog.sync({ registry, provider: added, apiKey: providerSecret(added, env), fetchImpl, actor: who(req).actor }).catch(() => null);
- // The provider's own model ids get the price the catalogue recorded for them, matched by name (pool-seed).
- await poolSeed.applyCatalogPrices({ registry, provider: added, actor: who(req).actor }).catch(() => null);
- res.redirect(303, '/console/providers?done=provider_added');
- }));
- r.post('/console/providers/:key/key', needs(CAPS.providerManage), wrap(async (req, res) => {
- if (!vault || !settings) throw new AiError(503, 'vault.unavailable', 'provider keys cannot be stored here');
- const p = await registry.getProvider(req.params.key);
- if (!p) throw new AiError(404, 'ai.not_found', 'no such provider');
- await vault.put(p.key, (req.body || {}).api_key, who(req));
- if (p.secret_ref !== `vault:${p.key}`) await registry.upsertProvider({ key: p.key, secret_ref: `vault:${p.key}` }, { ...who(req), origin: 'admin' });
- else await registry.upsertProvider({ key: p.key }, { ...who(req), origin: 'admin' }); // a new updated_at: the pool rebuilds its adapter
- await registry.audit(who(req).actor, 'provider.key_replaced', 'provider', p.key, { trace: who(req).trace });
- res.redirect(303, '/console/providers?done=provider_key');
- }));
- // Read the provider's own model list into the catalogue (L33): new models land switched off, prices stay a person's.
- r.post('/console/providers/:key/sync', needs(CAPS.providerManage), wrap(async (req, res) => {
- const p = await registry.getProvider(req.params.key);
- if (!p) throw new AiError(404, 'ai.not_found', 'no such provider');
- const apiKey = providerSecret(p, env);
- if (!apiKey && p.auth_mode !== 'none') throw new AiError(422, 'ai.invalid', `${p.key} has no key to read its model list with`);
- const out = await catalog.sync({ registry, provider: p, apiKey, fetchImpl, actor: who(req).actor });
- const priced = await poolSeed.applyCatalogPrices({ registry, provider: p, actor: who(req).actor }).catch(() => ({ priced: [], retired: [] }));
- await registry.audit(who(req).actor, 'provider.models_synced', 'provider', p.key, { trace: who(req).trace, metadata: { added: out.added.length, updated: out.updated.length, missing: out.missing.length, priced: priced.priced.length, retired: priced.retired.length } });
- res.redirect(303, '/console/providers?done=models_synced');
- }));
- // Turn one model on or off (a model routes may use, or not).
- r.post('/console/models/:provider/:model/status', needs(CAPS.providerManage), wrap(async (req, res) => {
- const m = await registry.getModel(req.params.provider, req.params.model);
- if (!m) throw new AiError(404, 'ai.not_found', 'no such model');
- const status = (req.body || {}).status === 'active' ? 'active' : 'disabled';
- await registry.upsertModel({ provider_key: m.provider_key, model_key: m.model_key, status }, who(req));
- res.redirect(303, '/console/providers?done=model_status');
- }));
- r.post('/console/providers/:key/models', needs(CAPS.providerManage), wrap(async (req, res) => {
- const p = await registry.getProvider(req.params.key);
- if (!p) throw new AiError(404, 'ai.not_found', 'no such provider');
- const b = req.body || {};
- const price = (v) => (v === '' || v == null ? undefined : Number(v));
- const source = String(b.price_source || '').trim();
- if (source && !/^https:\/\/[^\s]+$/.test(source)) throw new AiError(422, 'ai.invalid', 'the price source is the provider\'s https price page');
- if ((price(b.cost_in) != null || price(b.cost_out) != null) && !source) throw new AiError(422, 'ai.invalid', 'name the price page the prices come from');
- await registry.upsertModel({
- provider_key: p.key, model_key: String(b.model_key || '').trim(), display_name: String(b.display_name || '').trim() || null, type: 'chat',
- supports: { json: true, vision: !!b.vision, tools: !!b.tools, streaming: true },
- // Prices are the person's, typed from the provider's own price page (which they name), checked today.
- // The cache prices are optional (L40): nobody typed them means the record says 'no cache price' and the
- // cache tokens are priced at the plain input price.
- ...(price(b.cost_in) != null || price(b.cost_out) != null ? { cost: { in_per_mtok: price(b.cost_in) ?? null, out_per_mtok: price(b.cost_out) ?? null, cached_per_mtok: price(b.cost_cache_read) ?? null, cache_write_per_mtok: price(b.cost_cache_write) ?? null, ...(source ? { source, verified_at: new Date(clock.now()).toISOString().slice(0, 10) } : {}) } } : {}),
- }, who(req));
- res.redirect(303, '/console/providers?done=model_added');
- }));
- // Which provider and model a route uses (owner: it changes what OpenVibe spends). A new route version with the
- // choice as its primary and the route's previous primary as the first fallback; "default" goes back to what the
- // service seeds (the newest version the seed wrote, as a new version).
- r.post('/console/routes/:key/use', needs(CAPS.providerManage), wrap(async (req, res) => {
- const route = await registry.getRoute(req.params.key);
- if (!route) throw new AiError(404, 'ai.not_found', 'no such route');
- const b = req.body || {};
- if (b.default === '1') {
- const seeded = (await registry.listRoutes({ history: true })).filter((x) => x.key === route.key && x.created_by === 'seed').sort((a, c) => c.version - a.version)[0];
- if (!seeded) throw new AiError(409, 'ai.conflict', 'this route has no default to go back to');
- const { version, created_by, created_at, key: _k, ...def } = seeded;
- await registry.createRouteVersion(route.key, { ...def, status: 'active' }, who(req));
- return res.redirect(303, `/console/routes/${encodeURIComponent(route.key)}?done=route_default`);
- }
- const [provider, model] = String(b.target || '').split('|');
- const p = await registry.getProvider(provider || '');
- if (!p) throw new AiError(422, 'ai.invalid', 'choose a provider');
- const effective = route.alias_of ? await registry.resolveRoute(route.key) : route;
- const previous = effective && effective.primary && effective.primary.provider && !(effective.primary.provider === p.key && (effective.primary.model || null) === (model || null)) ? [{ provider: effective.primary.provider, model: effective.primary.model || null }] : [];
- await registry.createRouteVersion(route.key, {
- primary: { provider: p.key, model: model || p.default_model || null }, fallbacks: previous, capability: null, constraints: {}, pinned: [], alias_of: null,
- options: (effective && effective.options) || {}, max_output_tokens: effective ? effective.max_output_tokens : null,
- response_format: (effective && effective.response_format) || 'text', timeout_ms: effective ? effective.timeout_ms : null, status: 'active',
- learn_from_bench: Boolean(effective && effective.learn_from_bench),
- bench_kind: (effective && effective.bench_kind) || null, bench_category: (effective && effective.bench_category) || null,
- }, who(req));
- res.redirect(303, `/console/routes/${encodeURIComponent(route.key)}?done=route_used`);
- }));
-
- // ── 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(async (req, res) => await send(res, 200, pages.definitions({
- ...common(req), kind, rows: await registry[k.list](), inUse: inUseMap(kind, await registry[k.list]({ history: true })),
- }))));
- r.get(`/console/${kind}/:key`, read, wrap(async (req, res) => {
- const versions = (await registry[k.list]({ history: true })).filter((x) => x.key === req.params.key).sort((a, b) => b.version - a.version);
- if (!versions.length) return await 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];
- const current = await registry[k.get](req.params.key, selected.version);
- // BENCH-11: read-only evidence on a route's page. It changes what runs pick, so it is shown with
- // the leader, the tied offers and the reason; it is not editable here (bench_kind is an API field).
- let bench = null;
- if (kind === 'routes' && current.status === 'active' && pool.benchView) {
- try {
- const target = current.alias_of ? await registry.resolveRoute(current.key) : current;
- if (target && target.status === 'active') bench = await pool.benchView(target, target.capability || 'chat');
- } catch (err) {
- log.warn(`[console] bench evidence for route ${current.key}: ${err.message}`);
- }
- }
- return await send(res, 200, pages.definition({
- ...common(req), kind, key: req.params.key, versions, selected: current, bench,
- inUse: inUseMap(kind, versions).get(req.params.key), canManage: req.staff.caps.includes(CAPS.workflowManage), notice: notice(req),
- ...(kind === 'routes' ? { canChoose: req.staff.caps.includes(CAPS.providerManage), providers: (await registry.listProviders()).filter((p) => p.status === 'active' && p.kind !== 'whisper' && p.kind !== 'stub'), models: await registry.listModels() } : {}),
- }));
- }));
- r.post(`/console/${kind}/:key/versions/:version/status`, needs(CAPS.workflowManage), wrap(async (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}`);
- await 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(async (req, res) => {
- const { filters, form, bad } = q.parseRunFilters(req.query);
- const list = await q.listRuns(db, filters);
- await send(res, bad.length ? 400 : 200, pages.runs({
- ...common(req), form, filters, bad, rows: list.rows, next: list.next, facets: await q.runFacets(db),
- failures24: filters.status === 'failed' ? await q.failures(db, new Date(clock.now() - DAY_MS).toISOString()) : [],
- }));
- }));
- r.get('/console/runs/:id', read, wrap(async (req, res) => {
- const run = await runs.explained(await runs.get(req.params.id));
- if (!run) return await send(res, 404, pages.message({ title: 'Not found', text: `No run ${req.params.id}.`, back: '/console/runs', ...common(req) }));
- return await send(res, 200, pages.run({
- ...common(req), r: run, citations: await runs.citations(run.id), requests: await 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(async (req, res) => {
- await 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 = async (req, res, status, extra = {}) => await send(res, status, pages.quotas({
- ...common(req), counters: await quotas.counters(), all: await quotas.list(), canManage: req.staff.caps.includes(CAPS.providerManage), notice: notice(req), ...extra,
- }));
- r.get('/console/quotas', read, wrap(async (req, res) => await quotaPage(req, res, 200)));
- r.post('/console/quotas', needs(CAPS.providerManage), wrap(async (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 {
- await 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 await 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(async (req, res) => {
- const { filters, form, bad } = q.parseUsageFilters(req.query, clock.now());
- await send(res, bad.length ? 400 : 200, pages.usage({
- ...common(req), form, bad, report: await q.usageReport(quotas, filters),
- free: quotas.freeAllowance ? await quotas.freeAllowance.current() : [], readings: await usageSamples.deliveryReport(db),
- }));
- }));
-
- // ── OpenVibe GPUs: the rented instances, the spend this month and the cap ──
- // The page is read-only; the two actions ask for a change (a request row) and the reconciler does it.
- r.get('/console/gpus', read, wrap(async (req, res) => {
- await send(res, 200, pages.gpus({
- ...common(req), canManage: req.staff.caps.includes(CAPS.providerManage), notice: notice(req),
- state: await gpus.state(),
- }));
- }));
- r.get('/console/gpus/:model/quote', read, wrap(async (req, res) => {
- res.json(await gpus.preview(String(req.params.model), { strategy: req.query.strategy || undefined }));
- }));
- r.post('/console/gpus/:model/:action', needs(CAPS.providerManage), wrap(async (req, res) => {
- await gpus.request(String(req.params.model || ''), String(req.params.action || ''), req.staff.subject);
- res.redirect(303, '/console/gpus?done=gpu_requested');
- }));
-
- // ── The living price book: the prices the router charges, checked against each provider's own listing ──
- // The owner decides the changes the weekly job may not make on its own (a change of more than 20%).
- r.get('/console/pricebook', read, wrap(async (req, res) => {
- await send(res, 200, pages.pricebook({
- ...common(req), canManage: req.staff.caps.includes(CAPS.providerManage), notice: notice(req),
- report: priceBook ? priceBook.last : null,
- state: priceBook ? await priceBook.state.read() : null,
- pending: priceBook ? await priceBook.pending(50) : [],
- recent: priceBook ? await priceBook.changes({ status: 'applied', limit: 50 }) : [],
- }));
- }));
- r.post('/console/pricebook/changes/:id', needs(CAPS.providerManage), wrap(async (req, res) => {
- if (!priceBook) throw new AiError(409, 'ai.conflict', 'the price book is off on this host');
- const id = String(req.params.id || '').slice(0, 60);
- const decision = String((req.body || {}).decision || '') === 'approve' ? 'approve' : 'reject';
- const out = await priceBook.decide(id, { decision, actor: req.staff.subject });
- if (!out.ok) throw new AiError(409, 'ai.conflict', out.error);
- await audit(req.staff.subject, decision === 'approve' ? 'price_change.approved' : 'price_change.rejected', 'price_change', id, { decision }, req);
- return res.redirect(303, '/console/pricebook?done=' + (decision === 'approve' ? 'price_approved' : 'price_rejected'));
- }));
- r.post('/console/pricebook/run', needs(CAPS.providerManage), wrap(async (req, res) => {
- if (!priceBook) throw new AiError(409, 'ai.conflict', 'the price book is off on this host');
- const report = await priceBook.runOnce({ force: true });
- await audit(req.staff.subject, 'pricebook.run', 'pricebook', 'run', {
- sources: report.sources ? report.sources.length : 0,
- applied: (report.applied || []).length,
- pending: (report.pending || []).length,
- }, req);
- return res.redirect(303, '/console/pricebook?done=pricebook_ran');
- }));
-
- // ── AI Exchange (P7): Credits settle queue and routing evidence ──
- // Read-only except "mark resolved", which acknowledges one review row (a 409/422 needs a person) and
- // deliberately does not retry it. Both sections are absent unless their flag is on.
- if (exchangeOn) {
- r.get('/console/exchange', read, wrap(async (req, res) => {
- await send(res, 200, pages.exchange({
- ...common(req), canResolve: req.staff.caps.includes(CAPS.workflowManage), notice: notice(req),
- credits: creditsOn ? await q.settleQueue(db, clock.now()) : null,
- routing: routingOn ? await q.routingEvidence(db) : null,
- }));
- }));
- }
- if (creditsOn) {
- r.post('/console/exchange/credits/:id/resolve', needs(CAPS.workflowManage), wrap(async (req, res) => {
- const id = String(req.params.id || '').slice(0, 300);
- const row = id ? await db.prepare('SELECT id, hold_id, request_id, status FROM credit_settle_queue WHERE id = ?').get(id) : null;
- if (!row) throw new AiError(404, 'ai.not_found', 'no such settle queue row');
- if (row.status !== 'review') throw new AiError(409, 'ai.conflict', 'only a row in review can be marked resolved');
- let changed = false;
- await db.tx(async () => {
- const upd = await db.prepare(`UPDATE credit_settle_queue SET status = 'resolved', resolved_at = ?::timestamptz,
- resolved_by = ?, updated_at = ?::timestamptz WHERE id = ? AND status = 'review'`)
- .run(iso(clock.now()), req.staff.subject, iso(clock.now()), id);
- changed = Number(upd && upd.changes) > 0;
- if (changed) await audit(req.staff.subject, 'credit_settle.resolve', 'credit_settle', id, { hold_id: row.hold_id, request_id: row.request_id, from: 'review', to: 'resolved' }, req);
- });
- if (!changed) throw new AiError(409, 'ai.conflict', 'the row is no longer in review');
- return res.redirect(303, '/console/exchange?done=settle_resolved');
- }));
- }
-
- // ── Cache ────────────────────────────────────────────────
- const cachePage = async (req, res, status, extra = {}) => await send(res, status, pages.cache({
- ...common(req), stats: await cache.stats(), canManage: req.staff.caps.includes(CAPS.providerManage), notice: notice(req), ...extra,
- }));
- r.get('/console/cache', read, wrap(async (req, res) => await cachePage(req, res, 200)));
- r.post('/console/cache/purge', needs(CAPS.providerManage), wrap(async (req, res) => {
- const workflow = String((req.body || {}).workflow || '').trim();
- if (workflow && !/^[a-z][a-z0-9_-]*(\.[a-z0-9_-]+)*$/.test(workflow)) return await cachePage(req, res, 422, { error: 'that is not a workflow key', notice: null });
- if (!workflow && (req.body || {}).confirm_all !== 'yes') return await cachePage(req, res, 422, { error: 'give a workflow key, or tick the box to purge every entry', notice: null });
- const removed = await ops.purgeCache(workflow || null, who(req));
- return res.redirect(303, `/console/cache?done=cache_purged&removed=${removed}`);
- }));
-
- // ── Abuse prevention (plan E5) ───────────────────────────
- // A read-only page: the counters, the throttles the module already recorded in the audit log, and the size of
- // the network/device signals. The actions (throttling, allowance) are automatic; this is where a person looks.
- r.get('/console/abuse', read, wrap(async (req, res) => {
- const report = abuse ? await abuse.overview() : null;
- await send(res, 200, pages.abuse({ ...common(req), report }));
- }));
-
- // ── Audit ────────────────────────────────────────────────
- r.get('/console/audit', read, wrap(async (req, res) => {
- const { filters, form, bad } = q.parseAuditFilters(req.query);
- const page = await q.listAudit(db, filters);
- await send(res, bad.length ? 400 : 200, pages.audit({ ...common(req), form, bad, rows: page.rows, next: page.next, names: await 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/*'], async (req, res) => {
- if (!req.staff) return await send(res, 401, pages.signIn({ next: req.method === 'GET' ? sanitizeNext(req.originalUrl) : '/console' }));
- return await send(res, 404, pages.message({ title: 'Not found', text: 'There is no such console page.', ...common(req) }));
+ return res.redirect(303, flow.n || '/');
});
});
return r;
return r;
}
}
-module.exports = { consoleRouter, consoleCapabilities, sanitizeNext, sanitizeRunNext, STAFF_TO_AI };
+module.exports = { consoleRouter, sanitizeRunNext };
@@ -310,9 +310,9 @@ function createCredentials({ db, config, clock = { now: () => Date.now() }, fetc
@@ -310,9 +310,9 @@ function createCredentials({ db, config, clock = { now: () => Date.now() }, fetc
* the person: it names the page and carries the two values the page needs.
* the person: it names the page and carries the two values the page needs.
*/
*/
function allowHint(service, provider) {
function allowHint(service, provider) {
- const base = String(config.baseUrl || 'https://ai.openvibe.services').replace(/\/+$/, '');
+ const base = String(config.exchangeUrl || 'https://openvibe.exchange').replace(/\/+$/, '');
const q = new URLSearchParams({ allow: String(service || ''), provider: String(provider || '') });
const q = new URLSearchParams({ allow: String(service || ''), provider: String(provider || '') });
- return `allow ${service} to use your ${provider} key at ${base}/you?${q.toString()}`;
+ return `allow ${service} to use your ${provider} key at ${base}/privacy/keys?${q.toString()}`;
}
}
/**
/**
@@ -384,6 +384,16 @@ function createCredentials({ db, config, clock = { now: () => Date.now() }, fetc
@@ -384,6 +384,16 @@ function createCredentials({ db, config, clock = { now: () => Date.now() }, fetc
return (await selfRows(subject)).length > 0;
return (await selfRows(subject)).length > 0;
}
}
+ /** A person's provider balance, when that provider exposes one; the key stays inside AI. */
+ async function balanceSelf(subject, provider) {
+ const own = await selfForRun(subject, provider);
+ if (!own || !own.apiKey) return null;
+ const base = own.base_url || PROVIDER_BASE_URL[provider];
+ if (!base) return null;
+ const balance = await balanceOf(provider, base, own.apiKey, doFetch, testTimeout());
+ return balance == null ? null : { provider, balance };
+ }
+
/** A person's own credential for one provider (no key returned to the caller; server-side use only). */
/** A person's own credential for one provider (no key returned to the caller; server-side use only). */
async function selfForRun(subject, provider) {
async function selfForRun(subject, provider) {
if (provider === 'node') {
if (provider === 'node') {
@@ -587,7 +597,7 @@ function createCredentials({ db, config, clock = { now: () => Date.now() }, fetc
@@ -587,7 +597,7 @@ function createCredentials({ db, config, clock = { now: () => Date.now() }, fetc
return {
return {
put, get, remove, admit, forRun, spentToday, providerKeyFor,
put, get, remove, admit, forRun, spentToday, providerKeyFor,
- listSelf, hasSelf, selfForRun, selfForAllRuns, putSelf, removeSelf, testSelf, markFailed, getSelfNode, putSelfNode, removeSelfNode,
+ listSelf, hasSelf, selfForRun, selfForAllRuns, putSelf, removeSelf, testSelf, balanceSelf, markFailed, getSelfNode, putSelfNode, removeSelfNode,
allowedProvidersWithKey, consentUse,
allowedProvidersWithKey, consentUse,
};
};
}
}
@@ -428,6 +428,8 @@ function llmsTxt({ siteUrl }) {
@@ -428,6 +428,8 @@ function llmsTxt({ siteUrl }) {
],
],
});
});
const optional = seo.llmsTxt({ name: '', sections: [], optional: [
const optional = seo.llmsTxt({ name: '', sections: [], optional: [
+ { title: 'Owner AI operator console', url: 'https://openvibe.exchange/console' },
+ { title: 'Your keys and accounts', url: 'https://openvibe.exchange/privacy/keys' },
{ title: 'What shipped on OpenVibe.AI', url: `${siteUrl}/updates` },
{ title: 'What shipped on OpenVibe.AI', url: `${siteUrl}/updates` },
{ title: 'Source (AGPL-3.0)', url: SOURCE },
{ title: 'Source (AGPL-3.0)', url: SOURCE },
{ title: 'Sitemap', url: `${siteUrl}/sitemap.xml` },
{ title: 'Sitemap', url: `${siteUrl}/sitemap.xml` },
@@ -15,9 +15,9 @@
@@ -15,9 +15,9 @@
* `on_behalf_of`, whatever capabilities it holds (memory rule, AI #204/#205): a Codes run token holds ai.run.create,
* `on_behalf_of`, whatever capabilities it holds (memory rule, AI #204/#205): a Codes run token holds ai.run.create,
* so a capability check alone would admit an agent. `ai.me.*` are person capabilities, never a service's.
* so a capability check alone would admit an agent. `ai.me.*` are person capabilities, never a service's.
*
*
- * Writes are stricter than reads. A user token must carry audience `openvibe.ai` explicitly: a token minted for
- * `openvibe.network` is every OpenVibe site's session, and a site's server holding it must not be able to point a
- * person's prompts at its own endpoint or delete their keys. Such a token may still read (hints and settings only).
+ * Writes are stricter than reads. A user token must carry audience `openvibe.ai` explicitly, except Exchange may
+ * forward the signed-in person's `openvibe.network` token from its configured same-origin server request. Service
+ * tokens are always refused, including those with on_behalf_of; no caller can write another person's credentials.
* AI's own person session must prove same-origin for a write: with neither Origin nor Sec-Fetch-Site the write is
* AI's own person session must prove same-origin for a write: with neither Origin nor Sec-Fetch-Site the write is
* refused, so a site cannot use the cookie with a bare request. Reads keep accepting that cookie as before.
* refused, so a site cannot use the cookie with a bare request. Reads keep accepting that cookie as before.
*
*
@@ -51,7 +51,12 @@ function sameOrigin(req, config, { write = false } = {}) {
@@ -51,7 +51,12 @@ function sameOrigin(req, config, { write = false } = {}) {
if (!site && !origin) return !write;
if (!site && !origin) return !write;
if (site && site !== 'same-origin' && site !== 'none') return false;
if (site && site !== 'same-origin' && site !== 'none') return false;
if (origin) {
if (origin) {
- try { return new URL(origin).origin === new URL(config.baseUrl).origin; } catch { return false; }
+ try {
+ const actual = new URL(origin).origin;
+ return [config.baseUrl, config.exchangeUrl].some((base) => {
+ try { return actual === new URL(base).origin; } catch { return false; }
+ });
+ } catch { return false; }
}
}
return true;
return true;
}
}
@@ -84,9 +89,13 @@ function meRouter({ credentials, preferences, grants, auth, registry, config, cl
@@ -84,9 +89,13 @@ function meRouter({ credentials, preferences, grants, auth, registry, config, cl
return http.sendProblem(res, 403, 'capability.denied', { detail: "a principal token is not accepted on the person's own route", ctx });
return http.sendProblem(res, 403, 'capability.denied', { detail: "a principal token is not accepted on the person's own route", ctx });
}
}
if (claims.on_behalf_of != null) return http.sendProblem(res, 403, 'capability.denied', { detail: 'a token acting on behalf of someone is not the person themselves', ctx });
if (claims.on_behalf_of != null) return http.sendProblem(res, 403, 'capability.denied', { detail: 'a token acting on behalf of someone is not the person themselves', ctx });
- // A token minted for openvibe.network is every OpenVibe site's session: it may read hints and settings,
- // but a write needs a token whose aud explicitly names AI itself.
- if (write && !audiencesOf(claims).includes(WRITE_AUDIENCE)) {
+ // A token minted for openvibe.network is every OpenVibe site's session. It may write only when the
+ // request came from Exchange (which holds the user's token in its httpOnly session),
+ // or when the person minted an AI-audience token directly. No service token can enter this route.
+ const audiences = audiencesOf(claims);
+ const exchangeWrite = audiences.includes('openvibe.network') && sameOrigin(req, config, { write: true })
+ && req.get('origin') && new URL(req.get('origin')).origin === new URL(config.exchangeUrl).origin;
+ if (write && !audiences.includes(WRITE_AUDIENCE) && !exchangeWrite) {
return http.sendProblem(res, 403, 'capability.denied', { detail: 'a write needs a user token for audience openvibe.ai; a site session token may only read these settings', ctx });
return http.sendProblem(res, 403, 'capability.denied', { detail: 'a write needs a user token for audience openvibe.ai; a site session token may only read these settings', ctx });
}
}
const subject = SUBJECT_RE.test(String(claims.subject_id || '')) ? claims.subject_id : (SUBJECT_RE.test(String(claims.sub || '')) ? claims.sub : '');
const subject = SUBJECT_RE.test(String(claims.subject_id || '')) ? claims.subject_id : (SUBJECT_RE.test(String(claims.sub || '')) ? claims.sub : '');
@@ -126,7 +135,11 @@ function meRouter({ credentials, preferences, grants, auth, registry, config, cl
@@ -126,7 +135,11 @@ function meRouter({ credentials, preferences, grants, auth, registry, config, cl
const saved = await credentials.putSelf(personOf(req), body.provider, body);
const saved = await credentials.putSelf(personOf(req), body.provider, body);
return { credential: { id: saved.provider, kind: 'api_key', provider: saved.provider, created: saved.created_at, label: body.label || saved.provider } };
return { credential: { id: saved.provider, kind: 'api_key', provider: saved.provider, created: saved.created_at, label: body.label || saved.provider } };
}));
}));
- r.get('/api/v1/me/credentials/:id/balance', guard, run(async (req) => ({ balance: await sessionCookies.balance('self', personOf(req), req.params.id) })));
+ r.get('/api/v1/me/credentials/:id/balance', guard, run(async (req) => {
+ const cookieBalance = await sessionCookies.balance('self', personOf(req), req.params.id);
+ if (cookieBalance) return { balance: cookieBalance };
+ return { balance: await credentials.balanceSelf(personOf(req), req.params.id) };
+ }));
r.delete('/api/v1/me/credentials/:id', guard, async (req, res) => { try { const gone = await sessionCookies.remove('self', personOf(req), req.params.id) || await credentials.removeSelf(personOf(req), req.params.id); res.status(gone ? 204 : 404).end(); } catch (err) { sendError(res, err, req.ov); } });
r.delete('/api/v1/me/credentials/:id', guard, async (req, res) => { try { const gone = await sessionCookies.remove('self', personOf(req), req.params.id) || await credentials.removeSelf(personOf(req), req.params.id); res.status(gone ? 204 : 404).end(); } catch (err) { sendError(res, err, req.ov); } });
}
}
r.put('/api/v1/me/credentials/:provider', guard, run(async (req) => {
r.put('/api/v1/me/credentials/:provider', guard, run(async (req) => {
@@ -240,7 +240,7 @@ const GPU_SCHEMA = Object.freeze({
@@ -240,7 +240,7 @@ const GPU_SCHEMA = Object.freeze({
'x-owner-input': {
'x-owner-input': {
group: 'gpus',
group: 'gpus',
unlocks: 'Lets AI rent GPUs up to this amount each month. Without a cap nothing is rented, even with a key.',
unlocks: 'Lets AI rent GPUs up to this amount each month. Without a cap nothing is rented, even with a key.',
- get_it_at: 'https://ai.openvibe.services/console/gpus',
+ get_it_at: 'https://openvibe.exchange/console/gpus',
get_it_how: 'Pick the most you are happy to spend on GPUs in a month; the GPU page shows what this month has cost so far.',
get_it_how: 'Pick the most you are happy to spend on GPUs in a month; the GPU page shows what this month has cost so far.',
test: false,
test: false,
},
},
deleted file mode 100644
deleted file mode 100644
@@ -1,315 +0,0 @@
@@ -1,315 +0,0 @@
-'use strict';
-/**
- * Your AI (plan L36 Y1, Y3, Y8): GET /you on ai.openvibe.services. A person's own provider keys and how their AI is
- * paid, in one place for every OpenVibe site and agent. Save a key once, test it, replace it or remove it; choose
- * "only my keys" (never anything paid) or "my keys first, then OpenVibe's allowance", free tiers first, and a price
- * ceiling. What it all cost lives on openvibe.report/you (L40, L41).
- *
- * Server-rendered forms on AI's own person session (the run receipt's sign-in, /auth/person/login), each POST behind
- * a strict same-origin check: Sec-Fetch-Site same-origin, or an Origin header naming this site; neither is refused.
- * The page calls the same functions the person's API does (server/credentials.js, server/person-preferences.js), so
- * a key is sealed the same way and only its last four characters ever come back.
- *
- * GET /you the page (signed out: what it is and a sign-in)
- * POST /you/keys/:provider save or replace a key (key, base_url for "custom")
- * POST /you/keys/:provider/test test it now
- * POST /you/keys/:provider/remove forget it
- * POST /you/preferences only_my_keys, free_first, price_ceiling_usd_per_mtok
- * POST /you/computer a model on your own computer (model; any of your computers)
- * POST /you/computer/remove forget it
- *
- * Your computer (plan L55 C3): a model the person runs at home, reached through OpenVibe.Bot and their OpenVibe Link,
- * kept here like a key but with no key: the model, and any of their computers that lets their AI use its models. An
- * app uses it only when the person allows that app below (provider node), and it never stands in for a key.
- */
-const express = require('express');
-const shell = require('openvibe-shared/shell');
-const ovServe = require('openvibe-shared/serve');
-const appIcon = require('openvibe-shared/app-icon');
-const { CREDENTIAL_PROVIDERS, CREDENTIAL_PRESET, byId } = require('./console/presets');
-const { HOME_CSP } = require('./home');
-
-const SITE_NAME = 'OpenVibe.AI';
-const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
-// The order people look for them in: the big labs, then the fast and cheap, then the routers, then their own.
-const ORDER = ['anthropic', 'openai', 'gemini', 'deepseek', 'mistral', 'xai', 'groq', 'cerebras', 'together', 'fireworks', 'openrouter', 'minimax', 'custom'];
-const NAMES = { deepseek: 'DeepSeek', minimax: 'MiniMax', custom: 'Another OpenAI-compatible API', node: 'Your computer' };
-// What an app is allowed to use, in words: all your keys, one key, or your computer's models.
-const useWords = (provider) => (provider === '*' ? 'keys' : provider === 'node' ? 'computer\'s models' : `${providerName(provider)} key`);
-const providerName = (id) => NAMES[id] || (byId(CREDENTIAL_PRESET[id]) || {}).name || id;
-const keysUrl = (id) => (byId(CREDENTIAL_PRESET[id]) || {}).keys || null;
-const ago = (iso) => {
- const t = Date.parse(iso || '');
- if (!Number.isFinite(t)) return '';
- const m = Math.round((Date.now() - t) / 60000);
- return m < 1 ? 'just now' : m < 60 ? `${m} min ago` : m < 1440 ? `${Math.round(m / 60)} h ago` : `${Math.round(m / 1440)} d ago`;
-};
-
-const STYLE = `<style>
-.you{max-width:900px;margin:0 auto;padding:clamp(18px,4vw,44px) clamp(16px,3vw,28px) 90px;font-family:Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:var(--text-primary,#e6edf7)}
-.you h1,.you h2{font-family:'OV Brand',Nunito,Inter,-apple-system,sans-serif;font-weight:900}
-.you-eye{margin:0 0 8px;color:var(--accent,#3b82f6);font-size:12px;font-weight:750;letter-spacing:.12em;text-transform:uppercase}
-.you h1{margin:0;font-size:clamp(30px,5vw,48px);line-height:1.06;letter-spacing:-.02em;text-wrap:balance}
-.you-lede{margin:12px 0 0;max-width:62ch;color:var(--text-secondary,#96a7c2);font-size:16.5px;line-height:1.6}
-.you-state{display:flex;flex-wrap:wrap;gap:8px;margin:18px 0 0;padding:0;list-style:none}
-.you-state li{padding:5px 12px;border-radius:999px;border:1px solid var(--border,#1f2d47);font-size:13.5px;color:var(--text-secondary,#96a7c2)}
-.you-state b{color:var(--text-primary,#e6edf7)}
-.you section{margin-top:34px}
-.you h2{margin:0 0 6px;font-size:20px}
-.you-sub{margin:0 0 14px;color:var(--text-secondary,#96a7c2);font-size:14.5px;line-height:1.55;max-width:66ch}
-.you-keys{list-style:none;margin:0;padding:0;border:1px solid var(--border,#1f2d47);border-radius:16px;overflow:hidden}
-.you-key{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 14px;align-items:center;padding:14px 16px;border-top:1px solid var(--border,#1f2d47);background:var(--bg-secondary,#101828)}
-.you-key:first-child{border-top:0}
-.you-key-t{display:flex;flex-wrap:wrap;align-items:center;gap:6px 10px;min-width:0}
-.you-key-t b{font-size:15.5px}
-.you-key-n{grid-column:1/-1;color:var(--text-muted,#7d8ba3);font-size:13px;overflow-wrap:anywhere}
-.you-pill{padding:2px 9px;border-radius:999px;font-size:12px;font-weight:750;background:var(--bg-primary,#0b1220);color:var(--text-secondary,#96a7c2)}
-.you-pill.is-ok{color:#16a34a;background:color-mix(in srgb,#16a34a 15%,transparent)}
-.you-pill.is-bad{color:#ef4444;background:color-mix(in srgb,#ef4444 15%,transparent)}
-.you-acts{display:flex;flex-wrap:wrap;gap:6px;justify-content:flex-end}
-.you-acts form{margin:0}
-.you-btn{display:inline-flex;align-items:center;justify-content:center;min-height:38px;padding:0 14px;border-radius:999px;border:1px solid var(--border,#1f2d47);background:transparent;color:var(--text-primary,#e6edf7);font:inherit;font-size:13.5px;font-weight:650;text-decoration:none;cursor:pointer}
-.you-btn:hover{background:var(--bg-primary,#0b1220)}
-.you-btn.is-go{border-color:transparent;background:var(--accent-strong,var(--accent,#3b82f6));color:var(--on-accent-strong,#fff)}
-.you-btn:focus-visible{outline:2px solid var(--accent,#3b82f6);outline-offset:2px}
-.you-add{grid-column:1/-1}
-.you-add summary{display:inline-flex;cursor:pointer;list-style:none}
-.you-add summary::-webkit-details-marker{display:none}
-.you-add form{display:flex;flex-wrap:wrap;gap:8px;align-items:flex-end;margin-top:10px}
-.you-add label{display:grid;gap:4px;flex:1 1 220px;font-size:12.5px;font-weight:700;color:var(--text-secondary,#96a7c2)}
-.you input[type=password],.you input[type=url],.you input[type=number],.you input[type=text]{min-height:42px;box-sizing:border-box;width:100%;padding:0 12px;border-radius:10px;border:1px solid var(--border,#1f2d47);background:var(--bg-primary,#0b1220);color:var(--text-primary,#e6edf7);font:inherit;font-size:15px}
-.you-pay{display:grid;gap:10px;padding:16px;border-radius:16px;border:1px solid var(--border,#1f2d47);background:var(--bg-secondary,#101828)}
-.you-pay fieldset{margin:0;padding:0;border:0;display:grid;gap:8px}
-.you-pay legend{margin-bottom:6px;font-weight:700}
-.you-opt{display:flex;gap:10px;align-items:flex-start;padding:10px 12px;border-radius:12px;border:1px solid var(--border,#1f2d47);cursor:pointer}
-.you-opt:has(input:checked){border-color:var(--accent,#3b82f6);background:color-mix(in srgb,var(--accent,#3b82f6) 10%,transparent)}
-.you-opt input{margin-top:3px;accent-color:var(--accent,#3b82f6)}
-.you-opt span{display:grid;gap:2px}.you-opt small{color:var(--text-secondary,#96a7c2);font-size:13px}
-.you-row{display:flex;flex-wrap:wrap;gap:10px 18px;align-items:flex-end}
-.you-row>label:not(.you-opt){display:grid;gap:4px;font-size:12.5px;font-weight:700;color:var(--text-secondary,#96a7c2)}
-.you-pc-form{display:flex;flex-wrap:wrap;gap:8px;align-items:flex-end;margin-top:12px}
-.you-pc-form label{display:grid;gap:4px;flex:1 1 220px;font-size:12.5px;font-weight:700;color:var(--text-secondary,#96a7c2)}
-.you-pcs{margin:0;padding-left:20px;color:var(--text-secondary,#96a7c2);font-size:14px;line-height:1.75}
-.you-pcs b{color:var(--text-primary,#e6edf7)}
-.you code{padding:1px 6px;border-radius:6px;background:var(--bg-primary,#0b1220);font-size:13px}
-.you-new{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:8px}
-.you-new>li{padding:12px 14px;border-radius:14px;border:1px solid var(--border,#1f2d47);background:var(--bg-secondary,#101828)}
-.you-new>li:has(details[open]){grid-column:1/-1}
-.you-new summary{display:flex;align-items:center;justify-content:space-between;gap:8px;cursor:pointer;list-style:none;font-weight:700}
-.you-new summary::-webkit-details-marker{display:none}
-.you-new summary span{color:var(--accent,#3b82f6);font-size:13px;font-weight:650}
-.you-new form{display:flex;flex-wrap:wrap;gap:8px;align-items:flex-end;margin-top:12px}
-.you-new label{display:grid;gap:4px;flex:1 1 220px;font-size:12.5px;font-weight:700;color:var(--text-secondary,#96a7c2)}
-.you-empty{padding:16px;border-radius:14px;border:1px dashed var(--border,#1f2d47);color:var(--text-secondary,#96a7c2)}
-.you-note{margin:10px 0 0;padding:10px 14px;border-radius:12px;font-size:14px}
-.you-note.is-ok{background:color-mix(in srgb,#16a34a 14%,transparent)}.you-note.is-bad{background:color-mix(in srgb,#ef4444 14%,transparent)}
-.you-fine{color:var(--text-muted,#7d8ba3);font-size:13px;line-height:1.55;max-width:70ch}
-@media (max-width:560px){.you-key{grid-template-columns:minmax(0,1fr)}.you-acts{justify-content:flex-start}}
-</style>`;
-
-function frame({ title, body, siteUrl }) {
- return shell.page({
- name: SITE_NAME, service: 'ai', lang: 'en', title, siteName: SITE_NAME,
- description: 'Your own AI keys for every OpenVibe site and agent: saved once, no fee, and you choose how your AI is paid.',
- canonical: `${String(siteUrl).replace(/\/+$/, '')}/you`, robots: 'noindex, nofollow',
- navbar: { service: 'ai', apiBase: 'https://openvibe.network', links: [{ label: 'Your AI', href: '/you', active: true }, { label: 'Prices', href: '/stats' }], history: { type: 'page', title } },
- footer: { service: 'ai', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' },
- home: '/', navLinks: [{ label: 'Your AI', href: '/you' }, { label: 'Prices', href: '/stats' }],
- head: [appIcon.headTags({ site: 'ai' }), STYLE].join('\n'),
- body: `<div id="navbar-mount"></div>\n<main id="main" class="you">${body}</main>`,
- });
-}
-
-function signedOut({ siteUrl }) {
- return frame({ siteUrl, title: 'Your AI', body: `<p class="you-eye">Your AI</p><h1>Your keys, on every OpenVibe site.</h1>
-<p class="you-lede">Save a key from Anthropic, OpenAI, Google, DeepSeek or any provider once, and every OpenVibe site and agent can use it, with no fee, ever. You choose whether anything else may pay.</p>
-<p style="margin-top:20px"><a class="you-btn is-go" href="/auth/person/login?next=${encodeURIComponent('/you')}">Sign in to see yours</a></p>` });
-}
-
-/** The app names a grant may name, for a person who reads the list. */
-const APP_NAMES = { actor: 'OpenVibe.Actor', codes: 'OpenVibe.Codes', chat: 'OpenVibe.Chat', live: 'OpenVibe.Live', vision: 'OpenVibe.Vision' };
-const appName = (id) => APP_NAMES[id] || id;
-/** The link a service sends: /you?allow=<app>&provider=<provider>. Only a confirm button follows it; a POST changes it. */
-function allowRequest(query) {
- const q = query || {};
- const service = String(q.allow || '').trim().toLowerCase();
- // Only OpenVibe's own apps are offered: a link naming anything else shows no button (server/key-grants.js APPS).
- if (!/^[a-z][a-z0-9._-]{1,39}$/.test(service) || !require('./key-grants').APPS.includes(service)) return null;
- const raw = String(q.provider == null ? '*' : q.provider).trim().toLowerCase();
- const provider = raw === '*' || raw === '' ? '*' : (/^[a-z][a-z0-9._-]{0,39}$/.test(raw) ? raw : null);
- if (!provider) return null;
- return { service, provider };
-}
-
-/** Your computer: the saved model and where it runs, the form, and which of your computers can answer now. */
-function computerSection(pc, machines) {
- const models = [...new Set(machines.flatMap((m) => m.models))].slice(0, 60);
- const onNow = machines.filter((m) => m.online);
- const line = (m) => `<li><b>${esc(m.name)}</b>${m.online ? '' : ' <span class="you-pill">away</span>'}${m.models.length ? ` · ${esc(m.models.slice(0, 6).join(', '))}${m.models.length > 6 ? ` and ${m.models.length - 6} more` : ''}` : ' · no models listed'}${m.tools ? '' : ' · <span class="you-pill">update OpenVibe Link to v0.36.0 for agents</span>'}</li>`;
- const where = machines.length
- ? `<ul class="you-pcs">${machines.map(line).join('')}</ul>`
- : '<p class="you-empty">None of your computers lets OpenVibe use its models yet. On the computer, run a model server (Ollama, LM Studio or llama.cpp) and OpenVibe Link with <code>--ai</code>, then turn on “Let my AI use this computer’s models” at <a href="https://openvibe.bot/machines">openvibe.bot/machines</a>.</p>';
- const saved = pc ? `<ul class="you-keys"><li class="you-key"><div class="you-key-t"><b>Any of your computers</b><span class="you-pill${onNow.some((m) => m.models.includes(pc.model)) ? ' is-ok' : ''}">${esc(pc.model || 'no model')}</span></div>
-<div class="you-acts"><form method="post" action="/you/computer/remove"><button class="you-btn" type="submit">Remove</button></form></div>
-<div class="you-key-n">${onNow.some((m) => m.models.includes(pc.model)) ? 'On now, with this model' : 'None of your computers with this model is on right now'}${pc.last_used_at ? ` · used ${esc(ago(pc.last_used_at))}` : ''}</div></li></ul>` : '';
- const form = `<form class="you-pc-form" method="post" action="/you/computer"><label>Model${pc ? ' (change it)' : ''}<input type="text" name="model" required maxlength="120" pattern="\\S+" list="you-pc-models" autocomplete="off" spellcheck="false" placeholder="${esc(models[0] || 'qwen3:8b')}" value="${esc(pc && pc.model ? pc.model : '')}"></label><datalist id="you-pc-models">${models.map((m) => `<option value="${esc(m)}">`).join('')}</datalist><button class="you-btn is-go" type="submit">Save</button></form>`;
- return `<section aria-labelledby="you-pc"><h2 id="you-pc">Your computer</h2><p class="you-sub">A model you run at home answers your own work: no key, nothing spent, and the conversation stays between OpenVibe and your machine. Whichever of your computers is on with the model answers, the fastest first. Only the apps you allow below may use it.</p>
-${saved}${form}
-<h3 style="margin:18px 0 8px;font-size:15px">Your computers</h3>${where}</section>`;
-}
-
-function page({ siteUrl, keys = [], prefs = {}, apps = [], allow = null, ok = '', error = '', pc = null, machines = [] }) {
- const mine = new Map(keys.map((k) => [k.provider, k]));
- const working = keys.filter((k) => k.status === 'ok').length;
- const keyForm = (id, k) => `<form method="post" action="/you/keys/${esc(id)}"><label>${k ? 'New key' : 'Key'}<input type="password" name="key" required minlength="8" maxlength="400" autocomplete="off" spellcheck="false" placeholder="${id === 'anthropic' ? 'sk-ant-…' : 'sk-…'}"></label>${id === 'custom' ? `<label>Base URL<input type="url" name="base_url" required placeholder="https://…/v1" autocomplete="off"></label>` : ''}<button class="you-btn is-go" type="submit">Save</button>${keysUrl(id) ? `<a class="you-btn" href="${esc(keysUrl(id))}" rel="noopener" target="_blank">Get a key ↗</a>` : ''}</form>`;
- const row = (id) => {
- const k = mine.get(id);
- const pill = k.status === 'ok' ? '<span class="you-pill is-ok">Working</span>'
- : k.status === 'failing' ? '<span class="you-pill is-bad">Not working</span>' : '<span class="you-pill">Not tested yet</span>';
- const note = [`…${esc(k.hint)}`, k.base_url ? esc(k.base_url) : '', k.models_count ? `${k.models_count} models` : '', k.checked_at ? `checked ${ago(k.checked_at)}` : '', k.status === 'failing' && k.last_error ? esc(String(k.last_error).slice(0, 140)) : ''].filter(Boolean).join(' · ');
- return `<li class="you-key"><div class="you-key-t"><b>${esc(providerName(id))}</b>${pill}</div>
-<div class="you-acts"><form method="post" action="/you/keys/${esc(id)}/test"><button class="you-btn" type="submit">Test</button></form><form method="post" action="/you/keys/${esc(id)}/remove"><button class="you-btn" type="submit">Remove</button></form></div>
-${note ? `<div class="you-key-n">${note}</div>` : ''}
-<details class="you-add"><summary class="you-btn">Replace the key</summary>${keyForm(id, k)}</details></li>`;
- };
- const known = ORDER.filter((id) => CREDENTIAL_PROVIDERS.includes(id));
- const saved = known.filter((id) => mine.has(id));
- const toAdd = known.filter((id) => !mine.has(id));
- const addCard = (id) => `<li><details><summary>${esc(providerName(id))}<span>Add</span></summary>${keyForm(id, null)}</details></li>`;
- const only = prefs.only_my_keys === true;
- // L55 C3: the apps the person allowed to use their own key, and the one that is asking.
- const appRow = (g) => `<li class="you-key"><div class="you-key-t"><b>${esc(appName(g.service))}</b><span class="you-pill">${g.provider === '*' ? 'all your keys' : g.provider === 'node' ? 'your computer' : esc(providerName(g.provider))}</span></div>
-<div class="you-acts"><form method="post" action="/you/apps/remove"><input type="hidden" name="service" value="${esc(g.service)}"><input type="hidden" name="provider" value="${esc(g.provider)}"><button class="you-btn" type="submit">Remove</button></form></div>
-<div class="you-key-n">allowed ${esc(g.created_at ? ago(g.created_at) : 'just now')}</div></li>`;
- const appList = apps.length ? `<ul class="you-keys">${apps.map(appRow).join('')}</ul>` : '<p class="you-empty">No app may use your keys yet. An app that needs one asks you here, and never takes it by itself.</p>';
- const allowBlock = allow ? `<section aria-labelledby="you-allow"><h2 id="you-allow">Let ${esc(appName(allow.service))} use your ${esc(useWords(allow.provider))}?</h2><p class="you-sub">${esc(appName(allow.service))} asked to run your work on ${allow.provider === 'node' ? 'a model on your own computer' : 'your own key'}, so the call costs you nothing. No other app may use it, and you can take it away at any time.</p>${allow.provider === 'node' && !pc ? '<p class="you-sub">Add the model under “Your computer” first, then allow it here.</p>' : ''}
-<form method="post" action="/you/apps/allow"><input type="hidden" name="service" value="${esc(allow.service)}"><input type="hidden" name="provider" value="${esc(allow.provider)}"><button class="you-btn is-go" type="submit">Allow</button></form></section>` : '';
- const body = `<p class="you-eye">Your AI</p><h1>Your keys, on every OpenVibe site.</h1>
-<p class="you-lede">A key saved here works on every OpenVibe site and agent, with no fee, ever. It is sealed the moment it arrives; only its last four characters are ever shown again.</p>
-<ul class="you-state"><li><b>${keys.length}</b> ${keys.length === 1 ? 'key' : 'keys'}${keys.length ? `, ${working} working` : ''}</li><li>${only ? '<b>Only your keys</b>' : 'Your keys first'}</li><li>Free tiers first: <b>${prefs.free_first === false ? 'off' : 'on'}</b></li></ul>
-${ok ? `<p class="you-note is-ok" role="status">${esc(ok)}</p>` : ''}${error ? `<p class="you-note is-bad" role="alert">${esc(error)}</p>` : ''}
-<section aria-labelledby="you-keys"><h2 id="you-keys">Your keys</h2><p class="you-sub">Each is tested when you save it: one tiny request, and the models it can reach. A key that stops working is marked here and never quietly swapped for a paid one.</p>
-${saved.length ? `<ul class="you-keys">${saved.map(row).join('')}</ul>` : '<p class="you-empty">No keys yet. Add one below: Anthropic, OpenAI, Google or DeepSeek are good first ones.</p>'}
-<h3 style="margin:22px 0 10px;font-size:15px">Add a key</h3>
-<ul class="you-new">${toAdd.map(addCard).join('')}</ul></section>
-${allowBlock}
-${computerSection(pc, machines)}
-<section aria-labelledby="you-pay"><h2 id="you-pay">How your AI is paid</h2><p class="you-sub">For every site and agent that runs AI for you.</p>
-<form class="you-pay" method="post" action="/you/preferences">
-<fieldset><legend>When none of your keys can do a job</legend>
-<label class="you-opt"><input type="radio" name="only_my_keys" value="0"${only ? '' : ' checked'}><span><b>Use OpenVibe’s allowance</b><small>Your keys always go first; a job none of them can do runs on the network’s own, within its free allowance.</small></span></label>
-<label class="you-opt"><input type="radio" name="only_my_keys" value="1"${only ? ' checked' : ''}><span><b>Only my keys</b><small>Nothing else ever pays for your AI. A job none of your keys can do stops and says which provider would do it.</small></span></label>
-</fieldset>
-<div class="you-row"><label class="you-opt" style="flex:1 1 260px"><input type="checkbox" name="free_first" value="1"${prefs.free_first === false ? '' : ' checked'}><span><b>Free tiers first</b><small>Use a provider’s free tier before anything that costs.</small></span></label>
-<label>Most per million tokens (USD)<input type="number" name="price_ceiling_usd_per_mtok" min="0" max="1000" step="0.01" value="${prefs.price_ceiling_usd_per_mtok == null ? '' : esc(prefs.price_ceiling_usd_per_mtok)}" placeholder="No limit"></label>
-<button class="you-btn is-go" type="submit">Save</button></div>
-</form></section>
-<section aria-labelledby="you-apps"><h2 id="you-apps">Apps that may use my keys</h2><p class="you-sub">Only the apps you allow here may run on your own key. Nothing uses it by default, and a key never leaves OpenVibe.AI.</p>
-${appList}</section>
-<section aria-labelledby="you-cost"><h2 id="you-cost">What it cost</h2><p class="you-sub">Tokens, how much came from cache, what each call cost and whose key paid, across every site: <a href="https://openvibe.report/you">on openvibe.report</a>.</p></section>
-<p class="you-fine">Removing a key deletes it from OpenVibe.AI. Keys are personal: one serves your own work and nobody else’s. Sites and agents that run AI for you use these settings; the ones you have not opened never see your keys.</p>`;
- return frame({ siteUrl, title: 'Your AI', body });
-}
-
-function youPageRouter({ credentials, preferences, grants, personSessions, config, log = console, machines = null }) {
- const r = express.Router();
- const form = express.urlencoded({ extended: false, limit: '4kb' });
- const site = new URL(config.baseUrl).origin;
- // A POST from this site only: Sec-Fetch-Site same-origin, or an Origin naming this site. Neither is refused.
- const sameSite = (req) => {
- const sfs = req.headers['sec-fetch-site'];
- if (sfs) return sfs === 'same-origin';
- const origin = req.headers.origin;
- if (!origin) return false;
- try { return new URL(origin).origin === site; } catch { return false; }
- };
- const headers = (res) => res.set({ 'Content-Security-Policy': HOME_CSP, 'Cache-Control': 'no-store', 'X-Robots-Tag': 'noindex, nofollow', 'Referrer-Policy': 'same-origin' });
- const back = (res, q) => res.redirect(303, `/you?${new URLSearchParams(q)}`);
- async function person(req, res) {
- if (!personSessions) { headers(res).status(503).type('text/plain').send('Sign-in is not configured on this host.\n'); return null; }
- const viewer = await personSessions.read(req);
- if (!viewer) { res.redirect(303, `/auth/person/login?next=${encodeURIComponent('/you')}`); return null; }
- return viewer;
- }
- const guard = (fn) => [form, async (req, res) => {
- if (!sameSite(req)) return headers(res).status(403).type('text/plain').send('Open this form on ai.openvibe.services.\n');
- const viewer = await person(req, res);
- if (!viewer) return;
- try { await fn(req, res, viewer.subject); } catch (err) {
- const word = err && err.status && err.status < 500 ? err.message : 'That did not work this time; try again in a minute.';
- if (!err || !err.status || err.status >= 500) log.warn(`[you] ${req.path}: ${err && err.message}`);
- back(res, { error: String(word).slice(0, 200) });
- }
- }];
-
- r.get('/you', async (req, res) => {
- headers(res);
- const viewer = personSessions ? await personSessions.read(req) : null;
- if (!viewer) return res.type('html').send(signedOut({ siteUrl: config.baseUrl }));
- const [keys, prefs, apps, pc, mine] = await Promise.all([
- credentials.listSelf(viewer.subject).catch(() => []),
- preferences.get(viewer.subject).catch(() => ({})),
- grants.list(viewer.subject).catch(() => []),
- credentials.getSelfNode ? credentials.getSelfNode(viewer.subject).catch(() => null) : null,
- machines && machines.mine ? machines.mine(viewer.subject).catch(() => []) : [],
- ]);
- res.type('html').send(page({ siteUrl: config.baseUrl, keys, prefs, apps, pc, machines: mine, allow: allowRequest(req.query), ok: String(req.query.ok || '').slice(0, 200), error: String(req.query.error || '').slice(0, 200) }));
- });
- r.post('/you/keys/:provider', ...guard(async (req, res, subject) => {
- const id = req.params.provider;
- if (!CREDENTIAL_PROVIDERS.includes(id)) return back(res, { error: 'That provider is not one OpenVibe.AI knows.' });
- const body = { provider: id, key: String((req.body || {}).key || '').trim(), ...(id === 'custom' && req.body.base_url ? { base_url: String(req.body.base_url).trim() } : {}) };
- const out = await credentials.putSelf(subject, id, body);
- const tested = out.status === 'untested' && credentials.testSelf ? await credentials.testSelf(subject, id).catch(() => null) : null;
- const okNow = (tested ? tested.ok : out.status === 'ok');
- back(res, okNow ? { ok: `Saved your ${providerName(id)} key, and it works.` } : { error: `Saved your ${providerName(id)} key, but it did not work: ${String((tested && tested.error) || out.last_error || 'the provider refused it').slice(0, 120)}` });
- }));
- r.post('/you/keys/:provider/test', ...guard(async (req, res, subject) => {
- const out = await credentials.testSelf(subject, req.params.provider);
- back(res, out.ok ? { ok: `Your ${providerName(req.params.provider)} key works${out.models_count ? `: ${out.models_count} models` : ''}.` } : { error: `Your ${providerName(req.params.provider)} key did not work: ${String(out.error || 'the provider refused it').slice(0, 120)}` });
- }));
- r.post('/you/keys/:provider/remove', ...guard(async (req, res, subject) => {
- const gone = await credentials.removeSelf(subject, req.params.provider);
- back(res, gone ? { ok: `Removed your ${providerName(req.params.provider)} key.` } : { error: 'There was no key to remove.' });
- }));
- // L55 C3: the person allows an app to use their own key (one provider, or all of them). A POST on this site
- // only; the link an app sends (/you?allow=...) only shows the button.
- r.post('/you/apps/allow', ...guard(async (req, res, subject) => {
- const body = req.body || {};
- const service = String(body.service || '').trim().toLowerCase();
- const provider = String(body.provider == null ? '*' : body.provider).trim().toLowerCase() || '*';
- if (!/^[a-z][a-z0-9._-]{1,39}$/.test(service) || !require('./key-grants').APPS.includes(service)) return back(res, { error: 'That is not an OpenVibe app that may use a key.' });
- await grants.allow(subject, service, provider);
- back(res, { ok: `${appName(service)} may use your ${useWords(provider)} now.` });
- }));
- // Your computer (L55 C3): the model, on any of the person's computers. No key: nothing to seal or test here.
- r.post('/you/computer', ...guard(async (req, res, subject) => {
- const saved = await credentials.putSelfNode(subject, { machine: 'any', model: String((req.body || {}).model || '').trim() });
- back(res, { ok: `Saved: ${saved.model}, on whichever of your computers is on with it.` });
- }));
- r.post('/you/computer/remove', ...guard(async (req, res, subject) => {
- const gone = await credentials.removeSelfNode(subject);
- back(res, gone ? { ok: 'Removed your computer\'s model. No app can use it now.' } : { error: 'There was nothing to remove.' });
- }));
- r.post('/you/apps/remove', ...guard(async (req, res, subject) => {
- const body = req.body || {};
- const service = String(body.service || '').trim().toLowerCase();
- const provider = body.provider == null || body.provider === '' ? null : String(body.provider).trim().toLowerCase();
- const gone = await grants.remove(subject, service, provider);
- back(res, { ok: gone ? `${appName(service)} may not use your keys any more.` : 'There was nothing to take away.' });
- }));
- r.post('/you/preferences', ...guard(async (req, res, subject) => {
- const b = req.body || {};
- const cur = await preferences.get(subject).catch(() => ({}));
- const ceiling = String(b.price_ceiling_usd_per_mtok || '').trim();
- await preferences.put(subject, {
- only_my_keys: b.only_my_keys === '1', free_first: b.free_first === '1',
- price_ceiling_usd_per_mtok: ceiling === '' ? null : Number(ceiling),
- defaults: cur.defaults || { text: 'auto', code: 'auto', vision: 'auto', image: 'auto' },
- });
- back(res, { ok: 'Saved how your AI is paid.' });
- }));
- return r;
-}
-
-module.exports = { youPageRouter, page, signedOut };
new file mode 100644
new file mode 100644
@@ -0,0 +1,53 @@
@@ -0,0 +1,53 @@
+'use strict';
+
+const assert = require('assert');
+const express = require('express');
+const { createServer } = require('http');
+const { consoleDataRouter } = require('../server/api/console-data');
+
+const secret = 'sk-console-secret-not-in-json';
+const saved = [];
+const audit = [];
+const app = express();
+const auth = { requireCap: () => (req, _res, next) => { req.principal = { sub: 'svc:exchange' }; req.ov = { traceId: 'trace-test' }; next(); } };
+app.use(consoleDataRouter({
+ db: {}, registry: {
+ async listProviders() { return [{ key: 'openai', secret_ref: 'vault:openai' }]; },
+ async listModels() { return []; },
+ async getProvider(key) { return key === 'openai' ? { key, base_url: 'https://api.openai.com/v1' } : null; },
+ async upsertProvider(provider) { return provider; }, async audit(...args) { audit.push(args); },
+ }, pool: {}, quotas: {}, cache: {}, runs: {}, auth,
+ ops: { async providerView(p) { return { key: p.key, has_key: true, secret_ref: p.secret_ref }; } },
+ vault: { async put(key, value) { saved.push([key, value]); } },
+ settings: { providerKeys: { async setOrigin() {} } },
+ fetchImpl: async () => new Response(JSON.stringify({ data: [] }), { status: 200, headers: { 'content-type': 'application/json' } }),
+}));
+
+(async () => {
+ const server = createServer(app);
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ try {
+ const read = await fetch(`${base}/api/v1/admin/console/providers`);
+ const body = await read.text();
+ assert.strictEqual(read.status, 200);
+ assert.ok(!body.includes(secret));
+ const write = await fetch(`${base}/api/v1/admin/console/providers/openai/key`, {
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ api_key: secret }),
+ });
+ const answer = await write.text();
+ assert.strictEqual(write.status, 200, answer);
+ assert.ok(!answer.includes(secret));
+ assert.deepStrictEqual(saved, [['openai', secret]]);
+ assert.strictEqual(audit[0][1], 'provider.key_replaced');
+ const created = await fetch(`${base}/api/v1/admin/console/providers`, {
+ method: 'POST', headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ preset: 'custom', key: 'custom-ai', base_url: 'https://api.example.test/v1', api_key: secret }),
+ });
+ const createdText = await created.text();
+ assert.strictEqual(created.status, 200, createdText);
+ assert.ok(!createdText.includes(secret));
+ assert.deepStrictEqual(saved[1], ['custom-ai', secret]);
+ } finally { await new Promise((resolve) => server.close(resolve)); }
+ process.stdout.write('console-data: metadata reads and secret-safe key writes passed\n');
+})().catch((err) => { process.stderr.write(`${err.stack || err}\n`); process.exitCode = 1; });
deleted file mode 100644
deleted file mode 100644
@@ -1,253 +0,0 @@
@@ -1,253 +0,0 @@
-'use strict';
-// AI Exchange on the operator console (AI_CREDITS / AI_ROUTING_EVIDENCE): staff only, the Credits settle
-// queue counts and review rows with one audited "mark resolved" action that never retries, the measured
-// routing evidence and incumbents, nothing from a settle body beyond the four usage counts, and no
-// Exchange page or navigation when both flags are off.
-
-const assert = require('assert');
-const crypto = require('crypto');
-const { serviceAuth, ids } = require('openvibe-contracts');
-const { boot, suite, privateKey, ISSUER } = require('./helpers');
-
-const t = suite('console-exchange');
-const NETWORK = 'http://network.test';
-const CLIENT_SECRET = `sentinel-client-${crypto.randomBytes(12).toString('hex')}`;
-const SESSION_SECRET = crypto.randomBytes(32).toString('hex');
-const people = { user: ids.newId('user'), admin: ids.newId('user') };
-
-const codes = new Map();
-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`) {
- // openvibe-sdk/sso posts the grant as JSON; the real Network takes that (and this stub keeps the form shape too).
- const text = String(opts.body || '');
- const body = text.trim().startsWith('{') ? JSON.parse(text) : Object.fromEntries(new URLSearchParams(text));
- 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' });
- if (crypto.createHash('sha256').update(String(body.code_verifier || '')).digest('base64url') !== c.challenge) return json(400, { error: 'invalid_grant' });
- 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);
-}
-
-const baseEnv = {
- 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_STUB_FALLBACK: 'false',
-};
-let h;
-let adminCookie = null;
-const setCookies = (res) => (res.headers.getSetCookie ? res.headers.getSetCookie() : []);
-async function get(p, cookie) {
- const res = await fetch(h.base + p, { redirect: 'manual', headers: cookie ? { Cookie: cookie } : {} });
- return { status: res.status, headers: res.headers, text: await res.text(), cookies: setCookies(res) };
-}
-async function post(p, cookie, form) {
- const res = await fetch(h.base + p, {
- method: 'POST', redirect: 'manual',
- headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...(cookie ? { Cookie: cookie } : {}) },
- 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/exchange' } = {}) {
- 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, cookie: sess ? sess.split(';')[0] : null };
-}
-const csrfOf = (page) => (page.text.match(/name="_csrf" value="([^"]+)"/) || [])[1];
-const sessionCount = async () => (await h.db.prepare('SELECT COUNT(*) AS n FROM console_sessions').get()).n;
-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`);
- const csp = r.headers.get('content-security-policy') || '';
- assert.ok(csp.includes("default-src 'none'") && csp.includes("form-action 'self'") && !csp.includes('unsafe-inline'), `${p}: CSP`);
- assert.strictEqual(r.headers.get('x-robots-tag'), 'noindex, nofollow', p);
- 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.ok(!r.text.includes(CLIENT_SECRET) && !r.text.includes(SESSION_SECRET), `${p}: no secret`);
-}
-
-const REVIEW_A = 'ai-settle:req_REVIEWA';
-const REVIEW_B = 'ai-settle:req_REVIEWB';
-const REVIEW_BODY_A = JSON.stringify({
- idempotency_key: REVIEW_A,
- subject: 'SUBJECT-MARKER-usr_01JABCDEFGHJKMNPQRSTVWXYZ00',
- usage: { input_tokens: 111, cached_read_tokens: 222, cache_write_tokens: 333, output_tokens: 444 },
- retail: { version: 'RETAIL-MARKER-9f3a', nano_usd_per_m: { input: 'SUPPLY-MARKER-111', cached_read: 'SUPPLY-MARKER-222', cache_write: 'SUPPLY-MARKER-333', output: 'SUPPLY-MARKER-444' }, per_request_nano_usd: 'SUPPLY-MARKER-555' },
- supply_cost_nano_usd: 'SUPPLY-MARKER-666',
- byok: false,
- attempts: [{ deployment_id: 'DEPLOY-MARKER-xyz', started_at: '2026-11-05T10:00:00.000Z', ended_at: '2026-11-05T10:00:01.000Z', error_code: null }],
-});
-const REVIEW_BODY_B = JSON.stringify({
- idempotency_key: REVIEW_B,
- usage: { input_tokens: 5, cached_read_tokens: 6, cache_write_tokens: 7, output_tokens: 8 },
- retail: { version: 'SECOND-MARKER', nano_usd_per_m: { input: '0', cached_read: '0', cache_write: '0', output: '0' }, per_request_nano_usd: '0' },
- supply_cost_nano_usd: '0', byok: false, attempts: [],
-});
-
-async function seedQueue({ id, hold, request, body, status, tries, createdAt, lastError = null }) {
- const at = new Date(createdAt).toISOString();
- await h.db.prepare(`INSERT INTO credit_settle_queue
- (id, hold_id, request_id, settle_body, attempts, status, tries, next_at, last_error, created_at, updated_at)
- VALUES (?, ?, ?, ?::jsonb, '[]'::jsonb, ?, ?, ?::timestamptz, ?, ?::timestamptz, ?::timestamptz)`).run(
- id, hold, request, body, status, tries, at, lastError, at, at);
-}
-
-t.test('boot with both flags on and seed queue, metrics and incumbent rows', async () => {
- h = await boot({ env: { ...baseEnv, AI_CREDITS: 'true', AI_ROUTING_EVIDENCE: 'true' }, fetchImpl: fakeFetch });
- const now = Date.now();
- await seedQueue({ id: 'ai-settle:req_QUEUED', hold: 'hold_queued', request: 'req_QUEUED', body: REVIEW_BODY_B, status: 'queued', tries: 4, createdAt: now - 300000, lastError: 'billing unavailable' });
- await seedQueue({ id: 'ai-settle:req_SENT', hold: 'hold_sent', request: 'req_SENT', body: REVIEW_BODY_B, status: 'sent', tries: 0, createdAt: now - 200000 });
- await seedQueue({ id: REVIEW_A, hold: 'hold_review_a', request: 'req_REVIEWA', body: REVIEW_BODY_A, status: 'review', tries: 2, createdAt: now - 150000, lastError: 'billing.invalid_request: output_tokens' });
- await seedQueue({ id: REVIEW_B, hold: 'hold_review_b', request: 'req_REVIEWB', body: REVIEW_BODY_B, status: 'review', tries: 1, createdAt: now - 100000 });
- await h.db.prepare(`INSERT INTO provider_metrics
- (provider_key, model_key, ttft_ms, ttft_samples, decode_tps, decode_samples, success_rate, timeout_rate, cache_hit_ratio, cache_samples, sample_count, updated_at)
- VALUES ('alpha', 'model-a', 420, 30, 55.5, 25, 0.97, 0.01, 0.42, 22, 40, ?::timestamptz)`).run(new Date(now).toISOString());
- await h.db.prepare(`INSERT INTO provider_metrics
- (provider_key, model_key, ttft_ms, ttft_samples, decode_tps, decode_samples, success_rate, timeout_rate, cache_hit_ratio, cache_samples, sample_count, updated_at)
- VALUES ('beta', 'model-b', NULL, 0, NULL, 0, 0.5, 0.25, 0, 0, 2, ?::timestamptz)`).run(new Date(now).toISOString());
- await h.db.prepare(`INSERT INTO routing_incumbents
- (decision_key, provider_key, model_key, challenger_provider, challenger_model, challenger_streak, updated_at)
- VALUES ('openvibe/auto|chat|balanced', 'alpha', 'model-a', 'beta', 'model-b', 2, ?::timestamptz)`).run(new Date(now).toISOString());
-});
-
-t.test('anonymous get the sign-in page (401), never data', async () => {
- const r = await get('/console/exchange');
- assert.strictEqual(r.status, 401);
- assert.ok(r.text.includes('Sign in with OpenVibe.Network'));
- assert.ok(!r.text.includes('<table'));
-});
-
-t.test('non-staff are refused at sign-in (403) and get no session; the refusal is audited', async () => {
- const r = await signIn(people.user, { role: 'user' });
- assert.strictEqual(r.cb.status, 403);
- assert.strictEqual(r.cookie, null);
- assert.ok(r.cb.text.includes('only for OpenVibe.Network staff'));
- assert.strictEqual(await sessionCount(), 0);
- assert.strictEqual((await h.db.prepare("SELECT COUNT(*) AS n FROM audit_log WHERE action = 'console.sign_in.refused' AND actor = ?").get(people.user)).n, 1);
- assert.ok(revoked.length >= 1, 'the Network refresh token is revoked');
-});
-
-t.test('an admin sees both sections from the seeded rows; no settle body text beyond the four counts', async () => {
- const s = await signIn(people.admin, { username: 'ops' });
- assert.strictEqual(s.cb.status, 303);
- assert.strictEqual(s.cb.headers.get('location'), '/console/exchange');
- assert.ok(s.cookie);
- adminCookie = s.cookie;
- const page = await get('/console/exchange', s.cookie);
- assertPage(page, '/console/exchange');
- const text = page.text;
- assert.ok(text.includes('<a href="/console/exchange" aria-current="page">Exchange</a>'), 'the nav entry is current');
- assert.ok(text.includes('Credits settle queue') && text.includes('Routing evidence'), 'both sections');
- assert.match(text, /<div class="k">Queued<\/div><div class="v">1<\/div>/);
- assert.match(text, /<div class="k">Sent<\/div><div class="v">1<\/div>/);
- assert.match(text, /<div class="k">Review<\/div><div class="v">2<\/div>/);
- assert.match(text, /<div class="k">Oldest queued<\/div><div class="v">[4-6] min<\/div>/);
- assert.ok(text.includes('hold_review_a') && text.includes('hold_review_b'));
- assert.ok(text.includes('req_REVIEWA') && text.includes('billing.invalid_request: output_tokens'));
- const wraps = (text.match(/class="tablewrap"/g) || []).length;
- assert.ok(wraps >= 3, `every table is inside a scroll wrapper (${wraps})`);
- // The four settle usage counts (summed over the review rows) are the only settle-body data shown.
- assert.match(text, /tokens in 116 · cached read 228 · cache write 340 · out 452/);
- for (const marker of ['SUBJECT-MARKER-usr_01JABCDEFGHJKMNPQRSTVWXYZ00', 'RETAIL-MARKER-9f3a', 'SUPPLY-MARKER-111', 'SUPPLY-MARKER-666', 'DEPLOY-MARKER-xyz', 'SECOND-MARKER', '"byok"', 'supply_cost_nano_usd', 'nano_usd_per_m']) {
- assert.ok(!text.includes(marker), `settle body field leaked: ${marker}`);
- }
- assert.ok(text.includes('alpha:model-a') && text.includes('beta:model-b'));
- assert.ok(text.includes('97.0 %') && text.includes('1.0 %') && text.includes('420 ms') && text.includes('55.5') && text.includes('42.0 %'));
- assert.ok(text.includes('openvibe/auto|chat|balanced') && text.includes('n=30') && text.includes('n=25') && text.includes('n=22'));
-});
-
-t.test('"mark resolved" changes only that row, never retries it, and writes the console audit row', async () => {
- const page = await get('/console/exchange', adminCookie);
- const csrf = csrfOf(page);
- assert.ok(csrf, 'the page carries the CSRF token');
- const before = await h.db.prepare('SELECT * FROM credit_settle_queue WHERE id = ?').get(REVIEW_A);
- const other = await h.db.prepare('SELECT * FROM credit_settle_queue WHERE id = ?').get(REVIEW_B);
- const url = `/console/exchange/credits/${encodeURIComponent(REVIEW_A)}/resolve`;
- const r = await post(url, adminCookie, { _csrf: csrf });
- assert.strictEqual(r.status, 303, r.text.slice(0, 300));
- assert.strictEqual(r.headers.get('location'), '/console/exchange?done=settle_resolved');
- const after = await h.db.prepare('SELECT * FROM credit_settle_queue WHERE id = ?').get(REVIEW_A);
- assert.strictEqual(after.status, 'resolved');
- assert.strictEqual(after.tries, before.tries, 'no retry');
- assert.strictEqual(after.next_at, before.next_at, 'the retry schedule is untouched');
- assert.strictEqual(after.resolved_by, people.admin);
- assert.ok(after.resolved_at);
- const otherAfter = await h.db.prepare('SELECT * FROM credit_settle_queue WHERE id = ?').get(REVIEW_B);
- assert.strictEqual(otherAfter.status, other.status, 'the other review row is unchanged');
- assert.strictEqual(otherAfter.tries, other.tries);
- const auditPage = await get('/console/audit?kind=exchange', adminCookie);
- assert.ok(auditPage.text.includes('credit_settle.resolve') && auditPage.text.includes('AI Exchange'), 'the audit page lists it under AI Exchange');
- const audited = await h.db.prepare("SELECT * FROM audit_log WHERE action = 'credit_settle.resolve' ORDER BY id").all();
- assert.strictEqual(audited.length, 1);
- assert.strictEqual(audited[0].actor, people.admin);
- assert.strictEqual(audited[0].target_type, 'credit_settle');
- assert.strictEqual(audited[0].target_id, REVIEW_A);
- assert.strictEqual(JSON.parse(audited[0].metadata).hold_id, 'hold_review_a');
- // A resolved row is no longer offered, and a second attempt is a 409 that changes and audits nothing.
- const shown = await get('/console/exchange', adminCookie);
- assert.match(shown.text, /<div class="k">Review<\/div><div class="v">1<\/div>/);
- assert.ok(!shown.text.includes('hold_review_a'));
- const again = await post(url, adminCookie, { _csrf: csrf });
- assert.strictEqual(again.status, 409, again.text.slice(0, 300));
- assert.strictEqual((await h.db.prepare("SELECT COUNT(*) AS n FROM audit_log WHERE action = 'credit_settle.resolve'").get()).n, 1);
- // A missing CSRF token is refused and does not resolve the other row.
- const denied = await post(`/console/exchange/credits/${encodeURIComponent(REVIEW_B)}/resolve`, adminCookie, {});
- assert.strictEqual(denied.status, 403);
- assert.strictEqual((await h.db.prepare('SELECT status FROM credit_settle_queue WHERE id = ?').get(REVIEW_B)).status, 'review');
-});
-
-t.test('a staff session without ai.workflow.manage reads the page but cannot mark a row resolved', async () => {
- const s = await signIn(people.admin, { username: 'reader', staff_caps: ['staff.site.view'] });
- assert.strictEqual(s.cb.status, 303);
- const page = await get('/console/exchange', s.cookie);
- assertPage(page, '/console/exchange (reader)');
- assert.ok(page.text.includes('Credits settle queue'));
- assert.ok(!page.text.includes('Mark resolved'), 'no action without ai.workflow.manage');
- assert.ok(page.text.toLowerCase().includes('never retries the settle'));
- const denied = await post(`/console/exchange/credits/${encodeURIComponent(REVIEW_B)}/resolve`, s.cookie, { _csrf: csrfOf(page) });
- assert.strictEqual(denied.status, 403);
- assert.ok(denied.text.includes('This needs ai.workflow.manage'));
- assert.strictEqual((await h.db.prepare('SELECT status FROM credit_settle_queue WHERE id = ?').get(REVIEW_B)).status, 'review');
-});
-
-t.test('with both flags off the Exchange page and its sections are absent', async () => {
- await h.stop();
- h = await boot({ env: { ...baseEnv, AI_CREDITS: 'false', AI_ROUTING_EVIDENCE: 'false' }, fetchImpl: fakeFetch });
- const s = await signIn(people.admin, { username: 'ops' });
- assert.strictEqual(s.cb.status, 303);
- const overview = await get('/console', s.cookie);
- assertPage(overview, '/console');
- assert.ok(!overview.text.includes('href="/console/exchange"'), 'no Exchange navigation');
- const page = await get('/console/exchange', s.cookie);
- assert.strictEqual(page.status, 404);
- assert.ok(!page.text.includes('Credits settle queue') && !page.text.includes('Routing evidence'));
-});
-
-t.run();
deleted file mode 100644
deleted file mode 100644
@@ -1,627 +0,0 @@
@@ -1,627 +0,0 @@
-'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 { personFromClaims } = require('../server/console/sso');
-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 VAULT_KEY = crypto.randomBytes(32).toString('hex');
-const KEK = crypto.randomBytes(32).toString('base64');
-const ADDED_KEY = `sentinel-added-${crypto.randomBytes(12).toString('hex')}`;
-const REPLACED_KEY = `sentinel-replaced-${crypto.randomBytes(12).toString('hex')}`;
-const SECRETS = [PROVIDER_SECRET, CLIENT_SECRET, SESSION_SECRET, VAULT_KEY, KEK, ADDED_KEY, REPLACED_KEY];
-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, expired = false }) {
- const code = crypto.randomBytes(16).toString('hex');
- codes.set(code, { subject_id, role, is_owner, username, staff_caps, challenge, redirect_uri, expired });
- 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: u.expired ? now - 7200 : now, exp: u.expired ? now - 3600 : 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`) {
- // openvibe-sdk/sso posts the grant as JSON; the real Network takes that (and this stub keeps the form shape too).
- const text = String(opts.body || '');
- const body = text.trim().startsWith('{') ? JSON.parse(text) : Object.fromEntries(new URLSearchParams(text));
- 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 }); }
- // The added provider's own model list (L33: read when it is added, and again from its row).
- if (u === 'https://api.cheap.test/v1/models') return json(200, { data: [{ id: 'cheap-1' }, { id: 'cheap-pro-reasoner' }] });
- 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 = async (where, ...args) => await h.db.prepare(`SELECT * FROM audit_log WHERE ${where} ORDER BY id`).all(...args);
-const sessionCount = async () => (await 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', AI_CREDENTIALS_KEY: VAULT_KEY,
- OV_CONFIG_KEKS: `k1:${KEK}`, OV_CONFIG_KEK_CURRENT: 'k1',
- },
- 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((await 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/usage', '/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 = (await 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((await h.quotas.list()).length, before);
- assert.strictEqual((await h.registry.getProvider('okseam')).status, 'active');
-});
-
-t.test('the sign-in module takes a Network person only: no service/app principal, no FedCM assertion', () => {
- assert.strictEqual(personFromClaims({ sub: 'svc:live', subject_id: people.admin }).error, 'not a person\'s token');
- assert.strictEqual(personFromClaims({ sub: 42, actor_type: 'app', subject_id: people.admin }).error, 'not a person\'s token');
- assert.strictEqual(personFromClaims({ sub: 42, typ: 'fedcm', subject_id: people.admin }).error, 'a FedCM assertion is not a sign-in');
- assert.strictEqual(personFromClaims({ sub: 42, username: people.admin }).error, 'the token carries no network subject');
- const person = personFromClaims({ sub: 42, subject_id: people.admin, username: 'ada', role: 'admin', is_owner: true, staff_caps: ['staff.site.view'], staff_map: '1.1.0' });
- assert.strictEqual(person.subject, people.admin);
- assert.strictEqual(person.username, 'ada');
- assert.deepStrictEqual(person.staff, { role: 'admin', is_owner: true, staff_caps: ['staff.site.view'], staff_map: '1.1.0' });
-});
-
-t.test('GET /auth/health answers whether the Network accepts the ai client', async () => {
- const r = await get('/auth/health');
- assert.strictEqual(r.status, 200);
- assert.deepStrictEqual(JSON.parse(r.text), { ok: true, client: 'ok' });
- assert.strictEqual(r.headers.get('cache-control'), 'no-store');
-});
-
-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(await sessionCount(), 0);
-});
-
-// The SDK takes the clock as a number: a conversion that passed a function made every expiry check pass.
-t.test('an expired access token from the exchange starts no session', 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 code = authorize({ subject_id: people.admin, role: 'admin', challenge: loc.searchParams.get('code_challenge'), redirect_uri: loc.searchParams.get('redirect_uri'), expired: true });
- const cb = await get(`/auth/callback?code=${code}&state=${encodeURIComponent(loc.searchParams.get('state'))}`, flow);
- assert.ok(cb.status >= 400, `expired token: ${cb.status}`);
- assert.strictEqual(await 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(await sessionCount(), 0);
- const refused = await 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 = await 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((await 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>AI_API_KEY</code>') && /From the server's settings file|the settings file has no/.test(prov.text), 'the settings-file name is shown, in words');
- 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((await h.registry.getProvider('okseam')).status, 'active');
- assert.ok((await h.cache.stats()).length >= 1);
- assert.strictEqual((await 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 = (await 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((await h.registry.getWorkflow('ai.generate', v)).status, 'draft', why);
- }
- assert.strictEqual((await 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((await h.registry.getWorkflow('ai.generate', v)).status, 'archived');
- const row = (await 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((await h.registry.getProvider('badstub')).status, 'disabled');
- assert.strictEqual((await 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((await 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 = (await 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((await 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((await 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((await h.cache.stats()).length, 0);
- const purge = (await 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('owner: add a provider with its key (kept encrypted), replace the key, register a model, point a route at it and back', async () => {
- let x = await get('/console/providers/new?preset=deepseek', cookies.owner);
- assert.strictEqual(x.status, 200);
- assert.match(x.text, /value="https:\/\/api\.deepseek\.com\/v1"/);
- assert.match(x.text, /name="api_key" required/);
- assert.strictEqual((await get('/console/providers/new', cookies.admin)).status, 403, 'an admin does not add providers');
- x = await post('/console/providers', cookies.owner, { preset: 'custom', key: 'cheapapi', display_name: 'Cheap API', base_url: 'http://insecure.test/v1', api_key: ADDED_KEY, default_model: 'cheap-1', _csrf: csrf.owner });
- assert.ok(x.status >= 400, 'https only');
- x = await post('/console/providers', cookies.owner, { preset: 'custom', key: 'cheapapi', display_name: 'Cheap API', base_url: 'https://api.cheap.test/v1/', api_key: ADDED_KEY, default_model: 'cheap-1', _csrf: csrf.owner });
- assert.strictEqual(x.status, 303, x.text);
- const p = await h.registry.getProvider('cheapapi');
- assert.deepStrictEqual([p.kind, p.secret_ref, p.base_url, p.default_model, p.auth_mode], ['openai', 'vault:cheapapi', 'https://api.cheap.test/v1', 'cheap-1', 'bearer']);
- const row = await h.db.prepare("SELECT values_json FROM config_snapshots WHERE namespace = 'ai.provider_keys' AND state = 'active'").get();
- assert.match(JSON.parse(row.values_json)['cheapapi.key'], /^v1:k1:/, 'sealed at rest in the D11 store');
- assert.ok(!row.values_json.includes(ADDED_KEY), 'encrypted at rest');
- assert.strictEqual(h.settings.providerKeys.store.get('cheapapi.key'), ADDED_KEY);
- assert.strictEqual(require('../server/util').resolveSecret('vault:cheapapi'), ADDED_KEY, 'the pool resolves it');
- assert.strictEqual((await h.settings.providerKeys.store.summary()).secrets['cheapapi.key'].bound_to, 'https://api.cheap.test');
- assert.strictEqual((await h.settings.providerKeys.store.summary()).secrets['cheapapi.key'].hint, ADDED_KEY.slice(-4));
- x = await get('/console/providers', cookies.owner);
- assert.ok(!x.text.includes(ADDED_KEY), 'the page never shows the key');
- assert.match(x.text, new RegExp(`Entered here, locked</span><br><span class="muted">ends …${ADDED_KEY.slice(-4)}`));
- x = await post('/console/providers', cookies.owner, { preset: 'custom', key: 'cheapapi', base_url: 'https://api.cheap.test/v1', api_key: ADDED_KEY, _csrf: csrf.owner });
- assert.ok(x.status >= 400, 'no second provider of the same name');
- x = await post('/console/providers/cheapapi/key', cookies.owner, { api_key: REPLACED_KEY, _csrf: csrf.owner });
- assert.strictEqual(x.status, 303);
- assert.strictEqual(require('../server/util').resolveSecret('vault:cheapapi'), REPLACED_KEY);
- assert.strictEqual(h.settings.providerKeys.store.get('cheapapi.key'), REPLACED_KEY, 'the store holds the replaced key');
- x = await post('/console/providers/cheapapi/models', cookies.owner, { model_key: 'cheap-2', cost_in: '0.1', cost_out: '0.4', _csrf: csrf.owner });
- assert.ok(x.status >= 400, 'prices name the page they come from');
- x = await post('/console/providers/cheapapi/models', cookies.owner, { model_key: 'cheap-2', cost_in: '0.1', cost_out: '0.4', price_source: 'https://cheap.test/pricing', tools: '1', _csrf: csrf.owner });
- assert.strictEqual(x.status, 303, x.text);
- const m = await h.registry.getModel('cheapapi', 'cheap-2');
- assert.deepStrictEqual([m.cost.in_per_mtok, m.cost.out_per_mtok, m.supports.tools], [0.1, 0.4, true]);
- // Its model list was read when it was added: listed, described, switched off until a person turns one on (L33).
- const listed = await h.registry.getModel('cheapapi', 'cheap-pro-reasoner');
- assert.ok(listed && listed.status === 'disabled' && listed.metadata.tier === 'strong' && listed.metadata.reasoning, 'read on add, described, off');
- x = await get('/console/providers', cookies.owner);
- assert.ok(x.text.includes('action="/console/providers/cheapapi/sync"') && x.text.includes('action="/console/models/cheapapi/cheap-pro-reasoner/status"'), 'the page offers the list and the switch');
- x = await post('/console/providers/cheapapi/sync', cookies.owner, { _csrf: csrf.owner });
- assert.strictEqual(x.status, 303, x.text);
- assert.match(x.headers.get('location'), /done=models_synced$/);
- assert.strictEqual((await auditRows("action = 'provider.models_synced' AND target_id = 'cheapapi' AND actor = ?", people.owner)).length, 1);
- x = await post('/console/models/cheapapi/cheap-pro-reasoner/status', cookies.owner, { status: 'active', _csrf: csrf.owner });
- assert.strictEqual(x.status, 303);
- assert.strictEqual((await h.registry.getModel('cheapapi', 'cheap-pro-reasoner')).status, 'active', 'turned on');
- assert.strictEqual((await post('/console/models/cheapapi/cheap-pro-reasoner/status', cookies.admin, { status: 'disabled', _csrf: csrf.admin })).status, 403, 'only the owner switches models');
- // Point default.json at it: a new version, the previous primary kept as the first fallback; then back to the default.
- const before = await h.registry.getRoute('default.json');
- x = await get('/console/routes/default.json', cookies.owner);
- assert.match(x.text, /<option value="cheapapi\|cheap-2">Cheap API · cheap-2<\/option>/);
- x = await post('/console/routes/default.json/use', cookies.owner, { target: 'cheapapi|cheap-2', _csrf: csrf.owner });
- assert.strictEqual(x.status, 303, x.text);
- const used = await h.registry.getRoute('default.json');
- assert.strictEqual(used.version, before.version + 1);
- assert.deepStrictEqual(used.primary, { provider: 'cheapapi', model: 'cheap-2' });
- assert.strictEqual(used.created_by, people.owner);
- x = await post('/console/routes/default.json/use', cookies.owner, { default: '1', _csrf: csrf.owner });
- assert.strictEqual(x.status, 303, x.text);
- const back = await h.registry.getRoute('default.json');
- assert.strictEqual(back.version, used.version + 1);
- assert.notStrictEqual((back.primary || {}).provider, 'cheapapi');
- assert.strictEqual((await post('/console/routes/default.json/use', cookies.admin, { target: 'cheapapi|', _csrf: csrf.admin })).status, 403, 'only the owner changes what a route spends');
- const audits = await auditRows("target_id = 'cheapapi'");
- assert.ok(audits.some((a) => a.action === 'provider.create') && audits.some((a) => a.action === 'provider.key_replaced'));
- assert.ok(audits.every((a) => !String(a.metadata).includes(ADDED_KEY) && !String(a.metadata).includes(REPLACED_KEY)), 'no key in the audit');
-});
-
-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((await 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('usage: free allowance per subject this period and Billing readings by state, with the last send errors', async () => {
- await h.registry.upsertModel({ provider_key: 'okseam', model_key: 'seam-model', type: 'chat', cost: { in_per_mtok: 1, out_per_mtok: 1, free_allowance: 500, reset_period: 'month' } });
- assert.strictEqual((await h.quotas.freeAllowance.claim('live:user:4242', 'okseam', 'seam-model', { in: 120 })).tokens, 120);
- const key = `ai:${runIds.ok}:0:in`;
- assert.ok(await h.db.prepare('SELECT 1 AS x FROM usage_sample_outbox WHERE event_id = ?').get(key), 'the succeeded run queued a reading');
- await h.db.prepare("UPDATE usage_sample_outbox SET attempts = 2, last_error = 'billing.usage.record answered 503' WHERE event_id = ?").run(key);
- const r = await get('/console/usage', cookies.admin);
- assertPage(r, '/console/usage');
- const row = r.text.split('</tr>').find((x) => x.includes('<code>live:user:4242</code>'));
- assert.ok(row, 'the subject has a row');
- const cells = [...row.matchAll(/<td[^>]*>([\s\S]*?)<\/td>/g)].map((m) => m[1].replace(/<[^>]+>/g, '').trim());
- assert.deepStrictEqual(cells.slice(0, 6), ['live:user:4242', 'okseam', 'input-tokens:seam-model', '120', '500', '380']);
- assert.ok(r.text.includes('Readings by delivery state') && r.text.includes(`<code>${key}</code>`) && r.text.includes('billing.usage.record answered 503'));
- const run = await get(`/console/runs/${runIds.ok}`, cookies.admin);
- assert.ok(run.text.includes(`<code>${key}</code>`) && run.text.includes('billing.usage.record answered 503'), 'the run page shows its reading\'s state and error');
- assert.strictEqual((await get('/console/usage')).status, 401, 'signed out: refused');
- assert.ok(!(await fetch(`${h.base}/stats`).then((x) => x.text())).includes('live:user:4242'), 'the public page has no subject');
-});
-
-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((await h.runs.get(id)).status, 'cancelled');
- assert.strictEqual((await 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);
- await 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((await auditRows("action = 'console.sign_out' AND actor = ?", people.admin)).length, 1);
- for (const s of SECRETS) assert.ok(!JSON.stringify(await 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; the sitemap lists only public pages', 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/'));
- const map = await request(h.base, 'GET', '/sitemap.xml');
- assert.strictEqual(map.status, 200);
- assert.ok(!/\/console|\/auth\//.test(map.text), 'the console and sign-in are never in the sitemap');
- 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.services.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();
@@ -62,7 +62,9 @@ t.test('public GET preflight succeeds; rejected origin or method gets no CORS he
@@ -62,7 +62,9 @@ t.test('public GET preflight succeeds; rejected origin or method gets no CORS he
t.test('token and console routes never receive CORS headers', async () => {
t.test('token and console routes never receive CORS headers', async () => {
for (const [method, path] of [['GET', '/api/v1/runs'], ['OPTIONS', '/api/v1/runs'], ['GET', '/console'], ['OPTIONS', '/console'], ['GET', '/auth/login']]) {
for (const [method, path] of [['GET', '/api/v1/runs'], ['OPTIONS', '/api/v1/runs'], ['GET', '/console'], ['OPTIONS', '/console'], ['GET', '/auth/login']]) {
- const r = await request(h.base, method, path, { headers: { Origin: allowed, 'Access-Control-Request-Method': 'GET' } });
+ // Inspect the legacy routes' own answers. Following their redirects lands on a human page that
+ // may establish its own browser session and has a different CORS policy.
+ const r = await request(h.base, method, path, { redirect: 'manual', headers: { Origin: allowed, 'Access-Control-Request-Method': 'GET' } });
assertNoCors(r.headers);
assertNoCors(r.headers);
}
}
for (const path of ['/stats/extra', '/release.json/extra']) {
for (const path of ['/stats/extra', '/release.json/extra']) {
@@ -34,7 +34,7 @@ t.test('the home: a browser is sent to Exchange, curl still gets the API index',
@@ -34,7 +34,7 @@ t.test('the home: a browser is sent to Exchange, curl still gets the API index',
assert.strictEqual(text.status, 200, String(accept));
assert.strictEqual(text.status, 200, String(accept));
assert.match(text.headers.get('content-type'), /^text\/plain/, String(accept));
assert.match(text.headers.get('content-type'), /^text\/plain/, String(accept));
assert.ok(text.text.startsWith('OpenVibe.AI: providers, models, routing'), String(accept));
assert.ok(text.text.startsWith('OpenVibe.AI: providers, models, routing'), String(accept));
- assert.ok(text.text.includes('POST /api/v1/runs') && text.text.includes('GET /runs/:id') && text.text.includes('GET /console'), String(accept));
+ assert.ok(text.text.includes('POST /api/v1/runs') && text.text.includes('GET /runs/:id') && text.text.includes(`${EXCHANGE}/console`), String(accept));
assert.ok(text.text.includes(`${EXCHANGE}/`), 'the text index names the new home');
assert.ok(text.text.includes(`${EXCHANGE}/`), 'the text index names the new home');
}
}
await moved('/?format=markdown', '/index.md');
await moved('/?format=markdown', '/index.md');
@@ -70,13 +70,19 @@ t.test('an unknown human page goes to the Exchange home; an unknown API path sta
@@ -70,13 +70,19 @@ t.test('an unknown human page goes to the Exchange home; an unknown API path sta
}
}
});
});
-t.test('the machine and session routes are not redirected', async () => {
+t.test('console paths redirect to Exchange while machine and receipt routes stay on AI', async () => {
const health = await request(h.base, 'GET', '/api/health');
const health = await request(h.base, 'GET', '/api/health');
assert.strictEqual(health.status, 200);
assert.strictEqual(health.status, 200);
assert.strictEqual(health.body.status, 'ok');
assert.strictEqual(health.body.status, 'ok');
const release = await request(h.base, 'GET', '/release.json');
const release = await request(h.base, 'GET', '/release.json');
assert.strictEqual(release.status, 200);
assert.strictEqual(release.status, 200);
- for (const p of ['/console', '/runs/run_01J0000000000000000000000', '/auth/login']) {
+ await moved('/console', '/console');
+ await moved('/console/providers?tab=all', '/console/providers?tab=all');
+ await moved('/llms.txt', '/llms.txt');
+ await moved('/auth/login', '/auth/login');
+ await moved('/you', '/privacy/keys');
+ await moved('/you?allow=actor&provider=openai', '/privacy/keys?allow=actor&provider=openai');
+ for (const p of ['/runs/run_01J0000000000000000000000', '/auth/person/login']) {
const r = await request(h.base, 'GET', p, { headers: { Accept: BROWSER }, redirect: 'manual' });
const r = await request(h.base, 'GET', p, { headers: { Accept: BROWSER }, redirect: 'manual' });
assert.ok(!location(r).startsWith(EXCHANGE), `${p} stays on this origin (${r.status} ${location(r)})`);
assert.ok(!location(r).startsWith(EXCHANGE), `${p} stays on this origin (${r.status} ${location(r)})`);
}
}
@@ -55,7 +55,7 @@ t.test('a service that may not use the key is refused, and the answer says where
@@ -55,7 +55,7 @@ t.test('a service that may not use the key is refused, and the answer says where
assert.strictEqual(res.status, 403, res.text);
assert.strictEqual(res.status, 403, res.text);
assert.strictEqual(res.body.error.code, 'ai.credential_not_allowed');
assert.strictEqual(res.body.error.code, 'ai.credential_not_allowed');
assert.ok(res.body.error.message.includes('allow actor to use your openai key'), res.text);
assert.ok(res.body.error.message.includes('allow actor to use your openai key'), res.text);
- assert.ok(res.body.error.message.includes('/you?allow=actor&provider=openai'), 'the detail names the page');
+ assert.ok(res.body.error.message.includes('https://openvibe.exchange/privacy/keys?allow=actor&provider=openai'), 'the detail names the Exchange page');
assert.ok(!res.text.includes(DANA_KEY), 'the key is not in the error');
assert.ok(!res.text.includes(DANA_KEY), 'the key is not in the error');
});
});
@@ -2,8 +2,8 @@
@@ -2,8 +2,8 @@
// L36 Y1/Y3/Y7/Y8: the person's own provider keys and how their runs route.
// L36 Y1/Y3/Y7/Y8: the person's own provider keys and how their runs route.
// - GET/PUT/DELETE/POST /api/v1/me/credentials and GET/PUT /api/v1/me/ai/preferences are person-only: a service
// - GET/PUT/DELETE/POST /api/v1/me/credentials and GET/PUT /api/v1/me/ai/preferences are person-only: a service
// token (with or without on_behalf_of) is refused, so is another site's cookie session;
// token (with or without on_behalf_of) is refused, so is another site's cookie session;
-// - a token minted for openvibe.network (every site's session) may read but never write; a write needs aud
-// openvibe.ai, and a cookie write needs Origin or Sec-Fetch-Site;
+// - a token minted for openvibe.network may read; Exchange may write from its configured origin, while other
+// writes need aud openvibe.ai;
// - a key is sealed with the AAD self|subject|provider and never returned, logged or put in an error;
// - a key is sealed with the AAD self|subject|provider and never returned, logged or put in an error;
// - test on save lists models, falls back to a one-token request, records 401 and a timeout;
// - test on save lists models, falls back to a one-token request, records 401 and a timeout;
// - a run on_behalf_of a person tries their self key first, only_my_keys never falls back, and the explain names
// - a run on_behalf_of a person tries their self key first, only_my_keys never falls back, and the explain names
@@ -74,7 +74,7 @@ t.test('the person routes refuse a service token, an on_behalf_of token, and ano
@@ -74,7 +74,7 @@ t.test('the person routes refuse a service token, an on_behalf_of token, and ano
assert.deepStrictEqual([r.status, r.body.code], [403, 'capability.denied'], 'another site cannot spend the session');
assert.deepStrictEqual([r.status, r.body.code], [403, 'capability.denied'], 'another site cannot spend the session');
});
});
-t.test('a site session token may read but never write; a token for openvibe.ai may write', async () => {
+t.test('a site session token may read; Exchange may write for that signed-in person; a service token remains refused', async () => {
let r = await request(h.base, 'GET', '/api/v1/me/credentials', { tok: siteSession });
let r = await request(h.base, 'GET', '/api/v1/me/credentials', { tok: siteSession });
assert.strictEqual(r.status, 200, 'a site token reads the hints');
assert.strictEqual(r.status, 200, 'a site token reads the hints');
r = await request(h.base, 'GET', '/api/v1/me/ai/preferences', { tok: siteSession });
r = await request(h.base, 'GET', '/api/v1/me/ai/preferences', { tok: siteSession });
@@ -90,7 +90,19 @@ t.test('a site session token may read but never write; a token for openvibe.ai m
@@ -90,7 +90,19 @@ t.test('a site session token may read but never write; a token for openvibe.ai m
r = await request(h.base, method, path, { tok: siteSession, body });
r = await request(h.base, method, path, { tok: siteSession, body });
assert.deepStrictEqual([r.status, r.body.code], [403, 'capability.denied'], `${method} ${path} refused a site session token: ${r.text}`);
assert.deepStrictEqual([r.status, r.body.code], [403, 'capability.denied'], `${method} ${path} refused a site session token: ${r.text}`);
}
}
- // A token whose aud explicitly names openvibe.ai reaches the writes.
+ r = await request(h.base, 'POST', '/api/v1/me/credentials', {
+ tok: siteSession, headers: { Origin: 'https://untrusted.example' },
+ body: { provider: 'gemini', key: API_KEY },
+ });
+ assert.deepStrictEqual([r.status, r.body.code], [403, 'capability.denied'], 'another site cannot forward a network session into a credential write');
+ // Exchange forwards the signed user's site token server-to-server from its allowlisted origin.
+ r = await request(h.base, 'POST', '/api/v1/me/credentials', {
+ tok: siteSession, headers: { Origin: 'https://openvibe.exchange' },
+ body: { provider: 'gemini', key: API_KEY },
+ });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.ok(!r.text.includes(API_KEY), 'Exchange receives metadata only');
+ // A token whose aud explicitly names openvibe.ai reaches the writes without an Exchange origin.
r = await request(h.base, 'PUT', '/api/v1/me/ai/preferences', { tok: person, body: prefsBody });
r = await request(h.base, 'PUT', '/api/v1/me/ai/preferences', { tok: person, body: prefsBody });
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.status, 200, r.text);
});
});
@@ -49,6 +49,7 @@ function mounted(app) {
@@ -49,6 +49,7 @@ function mounted(app) {
*/
*/
const LEFT_OUT = [
const LEFT_OUT = [
'/api/health', '/api/ready', '/api/v1/openapi.json',
'/api/health', '/api/ready', '/api/v1/openapi.json',
+ '/api/v1/admin',
'/api/v1/status', '/api/v1/providers', '/api/v1/models', '/api/v1/routes', '/api/v1/templates', '/api/v1/workflows',
'/api/v1/status', '/api/v1/providers', '/api/v1/models', '/api/v1/routes', '/api/v1/templates', '/api/v1/workflows',
'/api/v1/quotas', '/api/v1/usage', '/api/v1/requests', '/api/v1/audit', '/api/v1/cache',
'/api/v1/quotas', '/api/v1/usage', '/api/v1/requests', '/api/v1/audit', '/api/v1/cache',
'/api/v1/credentials', '/api/v1/attribution-quotas', '/api/v1/me',
'/api/v1/credentials', '/api/v1/attribution-quotas', '/api/v1/me',
@@ -198,8 +198,10 @@ t.test('a service token or an expired token starts no person session; a bad stat
@@ -198,8 +198,10 @@ t.test('a service token or an expired token starts no person session; a bad stat
assert.strictEqual((await get(`/auth/callback?code=${code}&state=wrong`, flow)).status, 400);
assert.strictEqual((await get(`/auth/callback?code=${code}&state=wrong`, flow)).status, 400);
});
});
-t.test('a run receipt is personal: no console page and no API is opened by the person cookie', async () => {
- assert.strictEqual((await get('/console', runIds.adaCookie)).status, 401);
+t.test('a run receipt is personal: operator pages move to Exchange and no API is opened by the person cookie', async () => {
+ const consolePage = await get('/console', runIds.adaCookie);
+ assert.strictEqual(consolePage.status, 301);
+ assert.strictEqual(consolePage.headers.get('location'), 'https://openvibe.exchange/console');
assert.strictEqual((await get(`/api/v1/runs/${runIds.ok}`, runIds.adaCookie)).status, 401);
assert.strictEqual((await get(`/api/v1/runs/${runIds.ok}`, runIds.adaCookie)).status, 401);
});
});
deleted file mode 100644
deleted file mode 100644
@@ -1,143 +0,0 @@
@@ -1,143 +0,0 @@
-'use strict';
-// Your AI (plan L36 Y1/Y3/Y8): GET /you and its forms on ai.openvibe.services. Signed out it says what it is; signed in
-// (AI's own person session) it lists every provider with the person's key state, saves, tests and removes keys, and
-// sets how their AI is paid. Every POST needs this site's own origin signal; a key never comes back in the page.
-const assert = require('assert');
-const crypto = require('crypto');
-const { boot, suite, request, token, ALL } = require('./helpers');
-const { SELF_OWNER } = require('../server/credentials');
-
-const t = suite('you-page');
-const KEY = 'ef'.repeat(32);
-const DANA = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPY';
-const ANTHROPIC_KEY = 'sk-ant-test-you-0123456789WXYZ';
-const json = (body, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
-let refuse = false;
-const credentialFetch = async (url) => {
- if (refuse) return json({ error: { message: 'invalid key' } }, 401);
- if (String(url).endsWith('/models')) return json({ data: [{ id: 'm-one' }, { id: 'm-two' }] });
- return json({});
-};
-// The person's computers as Bot lists them to AI (only those that let their AI use their models), and the answers.
-const MACHINE = 'dev_01M5YGM39VC2RRGDQPRD6D4SHD';
-const asked = [];
-const machineModels = {
- enabled: true,
- async mine() { return [{ id: MACHINE, name: 'Desk PC', online: true, models: ['qwen3:8b', 'llama3.1:8b'], tools: true }, { id: 'dev_01M5YGM39VC2RRGDQPRD6D4SHE', name: 'Old laptop', online: false, models: ['phi3'], tools: false }]; },
- async chat(args) { asked.push(args); return { text: 'From your desk.', message: { role: 'assistant', content: 'From your desk.' }, finish: 'stop', model: args.model, usage: { input: 5, output: 3, cached: 0 }, machine: MACHINE, tried: [] }; },
-};
-let h, cookie;
-const page = (path, o = {}) => fetch(h.base + path, { redirect: 'manual', ...o, headers: { ...(o.headers || {}) } }).then(async (r) => ({ status: r.status, headers: r.headers, text: await r.text() }));
-const post = (path, fields, headers = { 'Sec-Fetch-Site': 'same-origin' }) => page(path, { method: 'POST', body: new URLSearchParams(fields).toString(), headers: { 'content-type': 'application/x-www-form-urlencoded', Cookie: cookie, ...headers } });
-const flash = (r) => Object.fromEntries(new URLSearchParams(String(r.headers.get('location') || '').split('?')[1] || ''));
-
-t.test('boot', async () => {
- h = await boot({ env: { AI_CREDENTIALS_KEY: KEY, AI_CREDENTIAL_TEST_TIMEOUT_MS: '80' }, credentialFetch, machineModels, log: { log() {}, warn() {}, error() {} } });
- const raw = crypto.randomBytes(32).toString('base64url');
- await h.db.exec("INSERT INTO person_sessions (id_hash, subject, username, created_at, expires_at) VALUES ($1, $2, 'dana', '2026-10-01', '2030-10-01')", [crypto.createHash('sha256').update(raw).digest('hex'), DANA]);
- cookie = `ovai_person=${raw}`;
-});
-
-t.test('signed out: what it is and a sign-in, private and not indexed', async () => {
- const r = await page('/you');
- assert.strictEqual(r.status, 200);
- assert.strictEqual(r.headers.get('cache-control'), 'no-store');
- assert.match(r.headers.get('x-robots-tag'), /noindex/);
- assert.ok(r.text.includes('Your keys, on every OpenVibe site.') && r.text.includes('href="/auth/person/login?next=%2Fyou"'));
-});
-
-t.test('signed in: every provider, none added yet, and how the AI is paid', async () => {
- const r = await page('/you', { headers: { Cookie: cookie } });
- assert.strictEqual(r.status, 200);
- for (const name of ['Anthropic', 'OpenAI', 'Google Gemini', 'DeepSeek', 'Mistral', 'xAI', 'Groq', 'OpenRouter', 'Another OpenAI-compatible API']) assert.ok(r.text.includes(`<summary>${name}<span>Add</span></summary>`), name);
- assert.ok(r.text.includes('No keys yet.'), 'an empty list says so');
- assert.ok(r.text.includes('action="/you/keys/anthropic"') && r.text.includes('href="https://console.anthropic.com/settings/keys"'), 'a form and where to get a key');
- assert.ok(r.text.includes('name="only_my_keys" value="0" checked'), 'my keys first by default');
- assert.ok(r.text.includes('href="https://openvibe.report/you"'), 'what it cost is on Report');
-});
-
-t.test('save a key: sealed, tested, never shown again; the page says it works', async () => {
- const r = await post('/you/keys/anthropic', { key: ANTHROPIC_KEY });
- assert.strictEqual(r.status, 303);
- assert.match(flash(r).ok, /Saved your Anthropic key, and it works/);
- const row = await h.db.prepare('SELECT key_enc, status FROM subject_credentials WHERE owner = ? AND subject = ? AND provider = ?').get(SELF_OWNER, DANA, 'anthropic');
- assert.ok(row && row.key_enc.startsWith('v2:') && !row.key_enc.includes(ANTHROPIC_KEY));
- const view = await page('/you', { headers: { Cookie: cookie } });
- assert.ok(view.text.includes('Working') && view.text.includes('…WXYZ') && view.text.includes('2 models'));
- assert.ok(!view.text.includes(ANTHROPIC_KEY), 'the key never comes back');
- assert.ok(view.text.includes('action="/you/keys/anthropic/test"') && view.text.includes('Replace the key'));
- assert.ok(view.text.includes('<b>Anthropic</b>') && !view.text.includes('<summary>Anthropic<span>Add</span></summary>'), 'a saved key moves out of the add list');
-});
-
-t.test('a key the provider refuses is saved and says so; test and remove work', async () => {
- refuse = true;
- let r = await post('/you/keys/openai', { key: 'sk-test-you-refused-012345' });
- assert.match(flash(r).error, /Saved your OpenAI key, but it did not work/);
- r = await post('/you/keys/openai/test', {});
- assert.match(flash(r).error, /did not work/);
- refuse = false;
- r = await post('/you/keys/openai/test', {});
- assert.match(flash(r).ok, /Your OpenAI key works/);
- r = await post('/you/keys/openai/remove', {});
- assert.match(flash(r).ok, /Removed your OpenAI key/);
- r = await post('/you/keys/openai/remove', {});
- assert.match(flash(r).error, /no key to remove/);
-});
-
-t.test('how the AI is paid: only my keys, free tiers, a ceiling', async () => {
- const r = await post('/you/preferences', { only_my_keys: '1', price_ceiling_usd_per_mtok: '2.5' });
- assert.match(flash(r).ok, /Saved how your AI is paid/);
- const view = await page('/you', { headers: { Cookie: cookie } });
- assert.ok(view.text.includes('name="only_my_keys" value="1" checked') && view.text.includes('<b>Only your keys</b>'));
- assert.ok(view.text.includes('Free tiers first: <b>off</b>'), 'an unticked box is off');
- assert.ok(view.text.includes('value="2.5"'));
-});
-
-t.test('another site, a missing origin signal or no session cannot change anything', async () => {
- let r = await post('/you/keys/anthropic/remove', {}, { Origin: 'https://evil.example', 'Sec-Fetch-Site': 'cross-site' });
- assert.strictEqual(r.status, 403);
- r = await post('/you/keys/anthropic/remove', {}, {});
- assert.strictEqual(r.status, 403, 'no origin signal at all is refused');
- r = await page('/you/preferences', { method: 'POST', body: 'only_my_keys=0', headers: { 'content-type': 'application/x-www-form-urlencoded', 'Sec-Fetch-Site': 'same-origin' } });
- assert.strictEqual(r.status, 303);
- assert.match(r.headers.get('location'), /^\/auth\/person\/login/);
- const still = await h.db.prepare('SELECT 1 AS x FROM subject_credentials WHERE owner = ? AND subject = ? AND provider = ?').get(SELF_OWNER, DANA, 'anthropic');
- assert.ok(still, 'the key is still there');
-});
-
-t.test('your computer (L55 C3): a model at home, no key; an app reaches it only once the person allows it', async () => {
- let r = await page('/you', { headers: { Cookie: cookie } });
- assert.ok(r.text.includes('<h2 id="you-pc">Your computer</h2>'));
- assert.ok(r.text.includes('<b>Desk PC</b> · qwen3:8b, llama3.1:8b') && r.text.includes('<b>Old laptop</b> <span class="you-pill">away</span>'), 'its computers and their models');
- assert.ok(r.text.includes('update OpenVibe Link to v0.36.0'), 'a Link that cannot pass tools says what to update');
- assert.ok(r.text.includes('<option value="qwen3:8b">'), 'the models to pick from');
- assert.ok(flash(await post('/you/computer', { model: 'two words' })).error, 'a model name has no spaces');
- r = await post('/you/computer', { model: 'qwen3:8b' });
- assert.match(flash(r).ok, /qwen3:8b/);
- const row = await h.db.prepare('SELECT machine, models, key_enc FROM subject_credentials WHERE owner = ? AND subject = ? AND provider = ?').get(SELF_OWNER, DANA, 'node');
- assert.deepStrictEqual([row.machine, JSON.parse(row.models).chat, row.key_enc], ['any', 'qwen3:8b', ''], 'any of their computers, the model, nothing sealed');
- r = await page('/you', { headers: { Cookie: cookie } });
- assert.ok(r.text.includes('<b>Any of your computers</b><span class="you-pill is-ok">qwen3:8b</span>') && r.text.includes('On now, with this model'));
- // An app asks for it by name; before the person allows it, AI refuses and says where.
- const actor = token('actor', ALL);
- const ask = () => request(h.base, 'POST', '/v1/chat/completions', { tok: actor, headers: { 'x-openvibe-credential': `node:${DANA}` }, body: { model: 'openvibe', messages: [{ role: 'user', content: 'hello' }] } });
- r = await ask();
- assert.strictEqual(r.status, 403, r.text);
- assert.strictEqual(r.body.error.code, 'ai.credential_not_allowed');
- assert.strictEqual(asked.length, 0, 'nothing reached the computer');
- r = await page('/you?allow=actor&provider=node', { headers: { Cookie: cookie } });
- assert.ok(r.text.includes('use your computer's models?'), 'the ask in words');
- assert.match(flash(await post('/you/apps/allow', { service: 'actor', provider: 'node' })).ok, /computer's models/);
- r = await ask();
- assert.strictEqual(r.status, 200, r.text);
- assert.strictEqual(r.body.choices[0].message.content, 'From your desk.');
- assert.deepStrictEqual([asked[0].machine, asked[0].subject, asked[0].model], ['any', DANA, 'qwen3:8b']);
- assert.strictEqual(r.body.x_openvibe.cost_usd, 0);
- assert.match(flash(await post('/you/computer/remove', {})).ok, /No app can use it/);
- r = await ask();
- assert.ok(r.status === 404 || r.status === 403, r.text);
- assert.strictEqual(asked.length, 1, 'removed: nothing reaches the computer');
-});
-
-t.test('close', async () => { await h.close(); });
-t.run();