openvibers/openvibe.ai Commits
AI's runs in the Action Center, and a page for one run (#186)
Changes
@@@ -44,6 +44,8 @@ OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000#AI_CONSOLE_SESSION_SECRET=# Absolute console session lifetime in minutes (5-720)#AI_CONSOLE_SESSION_TTL_MIN=60++# A person's run-receipt session lifetime in minutes (5-10080)++#AI_PERSON_SESSION_TTL_MIN=1440# Audience a Network user token must carry#AI_SSO_AUDIENCE=openvibe.network# ── Billing (plan T5 lane F) ──@@@ -107,6 +107,7 @@ idempotent replay):| `POST /api/v1/research` — a run of `research.gather` ([MegaSource](#megasource-research)) | `ai.run.create` (namespace `research.*`) or `ai.app.run` (free sources only) || `POST /api/v1/{chat,generate,summarize,classify,extract,embed,image}` (no `enrich`), `GET /runs/:id` (the app's own runs) | `ai.app.run` for a developer-app token ([Developer apps](#developer-apps)) || `GET /api/v1/runs`, `GET /runs/:id` (+ citations, request-log metadata), `GET /runs/:id/citations` | `ai.run.read` (own runs) |++| `GET /api/v1/actions?subject=usr_…` — one person's Action Center items (`common.action-list-result@1`, runs whose `on_behalf_of` is that person) | `ai.actions.read` (first-party service) || `GET /api/v1/workflows\|templates\|routes[/:key]` | `ai.run.create`, `ai.workflow.manage` or `ai.usage.read` || `GET /api/v1/routes?operation=generate` | the same read: every route's `offers` (`{provider, model}`), plus the operation's deduplicated `offers` || `POST /api/v1/{templates,workflows,routes}/:key/versions`, `…/versions/:v/status` | `ai.workflow.manage` |@@@ -115,7 +116,9 @@ idempotent replay):| `GET /api/health`, `GET /api/ready`, `GET /release.json` | public || `GET /metrics` | direct loopback callers only (a request carrying X-Forwarded-For gets 404) || `GET /console…`, `/auth/login\|callback`, `POST /auth/logout` | Network staff, signed in (see [Operator console](#operator-console)) |--| `GET /robots.txt` | public: disallows `/console`, `/auth/`, `/api/` |++| `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/` || `GET /stats` | public: p50/p95 latency, success rate and rate-card price per registered provider and model over the last 7 days (server-rendered, `openvibe-shared/cache-policy` HTML headers, nothing per caller) |Browser reads of `GET /stats` and `GET /release.json` allow exact origins from `AI_CORS_ORIGINS`@@@ -292,6 +295,22 @@ versions of routes, templates and workflows, models and provider records are stithe admin API only. There is no drill mode in AI; the console starts nothing (no timer, no outboundcall) until someone signs in.++## Run receipts++++`GET /runs/:id` is the person-facing page the Action Center's AI item links to (plan 2026-10-28++§3.10). It shows one run's status, workflow, model, cost, created/started/finished times and the++output or the error — never the stored input, the request log, the trace or another person's run.++It is server-rendered with the console's classes and CSP (no script, no form, no external resource),++`noindex`, `no-store`, `Referrer-Policy: no-referrer`.++++A person signs in through the same Network OAuth client and callback as the console, but++`GET /auth/person/login` marks the flow as a person flow: the callback stores a read-only session++(`__Host-ovai_person`, host-only, HttpOnly, Secure, SameSite=Lax, `AI_PERSON_SESSION_TTL_MIN` = 24 h;++only its hash in `person_sessions`) and redirects only to the exact `/runs/run_<ULID>` page asked++for. It grants no capability and never opens `/console`. The page shows a run only when the run's++`on_behalf_of` is that session's `usr_…` subject; any other run is a 404. `GET /api/v1/actions` for++the Action Center is separate: a first-party service token with `ai.actions.read` names the person.++### Provider keys added from the console (`vault:`)A provider the owner adds from the console keeps its key in `provider_secrets` (migration `0010_provider_secrets.sql`,@@@ -43,6 +43,7 @@"ai.run.* events through the SDK outbox","ai.preferences and ai.usage_summary user modules","server-rendered operator console for Network staff",++ "Action Center read (ai.actions.read) and the person-facing run receipt (GET /runs/:id, OpenVibe.Network person sign-in)","Live import","platform.usage-sample@1 readings to Billing with a per-subject provider free allowance; each run's reading state in explain and the free allowance on the staff console","tier budgets (free, paid, staff) per subject and per project on openvibe-sdk/govern in Valkey, behind AI_GOVERN_TIERS (off by default); readings from govern's onUsage",nnew file mode 100644@@@ -0,0 +1,21 @@++{++ "id": "ai.actions.read",++ "version": "1.0",++ "owner": "ai",++ "status": "active",++ "visibility": "first-party",++ "description": "Read one person's Action Center items from OpenVibe.AI (plan 2026-10-28 §3.10): AI runs made on the person's behalf (on_behalf_of), as common.action-list-result@1. GET /api/v1/actions?subject=usr_… with a service token; granted to OpenVibe.Network's aggregator only. Never another person's items, and only the fields of common.action-item@1.",++ "permissions": [++ "ai:read"++ ],++ "resourceConstraints": [++ "none"++ ],++ "quotaClass": "standard",++ "events": [],++ "implementedBy": [++ "GET /api/v1/actions"++ ],++ "inputSchema": "common.no-body@1",++ "outputSchema": "common.action-list-result@1"++}@@@ -20,6 +20,7 @@"note": "the public home, /stats and the developer-app API at ai.openvibe.services (2026-10-07); the old ai.openvibe.network address was retired on 2026-10-10 (no vhost); services on the host call 127.0.0.1:4700"},"capabilities": [++ "ai.actions.read","ai.app.run","ai.credential.manage","ai.provider.manage",nnew file mode 100644@@@ -0,0 +1,12 @@++-- phase: expand++-- Person sessions for the run page (plan 2026-10-28 §3.10): a signed-in person's receipt for one run.++-- The cookie is a random 256-bit id; only its SHA-256 is stored. Staff console sessions stay in console_sessions.++CREATE TABLE IF NOT EXISTS person_sessions (++ id_hash text COLLATE "C" PRIMARY KEY,++ subject text COLLATE "C" NOT NULL, -- the Network person (usr_…)++ username text COLLATE "C",++ created_at text COLLATE "C" NOT NULL,++ expires_at text COLLATE "C" NOT NULL,++ revoked_at text COLLATE "C",++ ip_hash text COLLATE "C"++);@@@ -13,7 +13,7 @@"dotenv": "^16.4.7","express": "^4.21.2","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.229.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0","pg": "^8.23.0"@@@ -1265,9 +1265,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.222.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",-- "integrity": "sha512-wdUQCilq9glRMwfxEswbIj3l0QQqKDXZAi2+CR5Mlh34rZjI1Q+7GlAoR0UtNwZAgtLwXhPxFyJ/aPk8lcDG1Q==",++ "version": "0.229.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.229.0",++ "integrity": "sha512-BYazi/dna8bgdM6+Go8t+D7lTyI9UaWYnJIx0hYEdD0v2ps87iTPONkjnCBadk5RUrBDtvcNoSONS3R+bwFG0w==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -25,7 +25,7 @@"dotenv": "^16.4.7","express": "^4.21.2","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.229.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0","pg": "^8.23.0"nnew file mode 100644@@@ -0,0 +1,83 @@++'use strict';++++/**++ * AI's Action Center items (plan 2026-10-28 §3.10, common.action-item@1): runs made on one person's behalf++ * (on_behalf_of), mapped from the run's own status to the five Action Center buckets. bucketFor() is exported on its++ * own so its state table can be tested.++ */++++const PUBLIC_ORIGIN = 'https://ai.openvibe.services';++const ENTITIES = Object.freeze({ amp: '&', lt: '<', gt: '>', quot: '"', '#39': "'", nbsp: ' ' });++++const STATUS_BUCKET = Object.freeze({++ queued: 'running',++ running: 'running',++ waiting: 'waiting', // reserved for a workflow that needs the person; AI has none today++ succeeded: 'done',++ cached: 'done',++ failed: 'failed',++ cancelled: 'failed',++});++++function bucketFor(status) {++ return STATUS_BUCKET[status] || 'failed';++}++++/**++ * What the person may do from the list ([email protected]). The item opens the run receipt.++ * Cancel and retry are not offered: Network has no AI decide route, and AI would need a delegated++ * person principal to authorize them.++ */++function actionsFor() {++ return ['open'];++}++++/** Plain text (no HTML or Markdown), single line, at most `max` characters. */++function plain(value, max = 400) {++ let s = value == null ? '' : String(value);++ s = s.replace(/```[\s\S]*?```/g, ' ').replace(/<\/?[^>]+>/g, ' ')++ .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')++ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')++ .replace(/&([a-zA-Z#0-9]+);/g, (m, k) => (Object.hasOwn(ENTITIES, k) ? ENTITIES[k] : m))++ .replace(/[`*_~]+/g, '').replace(/^[#>\s-]+/gm, '')++ .replace(/\s+/g, ' ').trim();++ if (s.length > max) s = `${s.slice(0, Math.max(0, max - 1)).trimEnd()}…`;++ return s;++}++++/** The readable outcome a run's output carries, when it carries one. */++function summaryOf(output) {++ if (typeof output === 'string') return output;++ if (!output || typeof output !== 'object' || Array.isArray(output)) return '';++ for (const key of ['answer', 'text', 'summary', 'content']) if (typeof output[key] === 'string') return output[key];++ return '';++}++++function runPage(id) {++ return `${PUBLIC_ORIGIN}/runs/${encodeURIComponent(id)}`;++}++++function toItem(run, workflow = null) {++ const bucket = bucketFor(run.status);++ const detail = run.error ? (run.error.detail || run.error.code || '') : '';++ const summary = detail || summaryOf(run.output);++ return {++ service: 'ai',++ id: run.id,++ kind: 'ai_run',++ title: plain((workflow && workflow.name) || (run.workflow && run.workflow.key) || run.id, 160) || run.id,++ summary: summary ? plain(summary, 400) : null,++ bucket,++ state: run.status,++ agent: run.provenance && run.provenance.model ? plain(run.provenance.model, 80) : null,++ cost_usd: run.usage && Number.isFinite(Number(run.usage.cost_usd)) ? Number(run.usage.cost_usd) : null,++ steps: null, // AI keeps token/attempt counts, not tool-call steps++ error: detail ? plain(detail, 400) : null,++ created_at: run.created_at,++ updated_at: run.finished_at || run.started_at || run.created_at,++ href: runPage(run.id),++ actions: actionsFor(bucket),++ };++}++++module.exports = { PUBLIC_ORIGIN, bucketFor, actionsFor, plain, summaryOf, runPage, toItem };nnew file mode 100644@@@ -0,0 +1,39 @@++'use strict';++++/**++ * The Action Center read (plan 2026-10-28 §3.10): GET /api/v1/actions?subject=usr_…++ *++ * A Network service token for audience openvibe.ai holding ai.actions.read names one person and gets the runs whose++ * on_behalf_of is that user, as common.action-list-result@1: everything queued/running, then done and failed runs from++ * the last 30 days, newest updated first, at most 50. Never a run made for anyone else; a person's token, a token++ * without the capability and a subject that is not a usr_… id are refused.++ */++const express = require('express');++const { AiError, sendError } = require('../util');++const { CAPS } = require('../auth');++const items = require('../actions/items');++++const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++const DAY = 86_400_000;++++function actionsRouter({ runs, registry, auth, log = console }) {++ const r = express.Router();++ r.get('/api/v1/actions', auth.requireCap(CAPS.actionsRead), async (req, res) => {++ try {++ const subject = String(req.query.subject || '').trim();++ if (!USER.test(subject)) throw new AiError(400, 'input.invalid', 'subject must be a usr_… subject id');++ const list = await runs.listOnBehalfOf(subject, { since: Date.now() - 30 * DAY, limit: 50 });++ const workflows = new Map();++ const out = [];++ for (const run of list) {++ const key = `${run.workflow.key}@${run.workflow.version}`;++ if (!workflows.has(key)) workflows.set(key, await registry.getWorkflow(run.workflow.key, run.workflow.version));++ out.push(items.toItem(run, workflows.get(key)));++ }++ res.json({ items: out });++ } catch (err) { sendError(res, err, req.ov, log); }++ });++ return r;++}++++module.exports = { actionsRouter };@@@ -7,10 +7,13 @@ const { instrument } = require('openvibe-shared/metrics');const { createRelease } = require('openvibe-shared/release');const { createAiReadiness, registerAiGauges } = require('./observability');const { runsRouter } = require('./api/runs');++const { actionsRouter } = require('./api/actions');const { sendError } = require('./util');const { adminRouter } = require('./api/admin');const { createOps } = require('./ops');const { consoleRouter } = require('./console');++const { createPersonSessions } = require('./person-session');++const { runPageRouter } = require('./run-pages');const views = require('./console/views');const cachePolicy = require('openvibe-shared/cache-policy');const ovServe = require('openvibe-shared/serve');@@@ -87,13 +90,17 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys// Operator actions shared by the admin API and the operator console (one implementation, one audit row).const ops = createOps({ db, registry, pool, quotas, cache, runs });app.use(runsRouter({ runs, registry, auth, config, log }));++ app.use(actionsRouter({ runs, registry, auth, log }));// The OpenAI-compatible endpoint (POST /v1/chat/completions, GET /v1/models): runs of ai.complete.app.use(require('./api/openai').openaiRouter({ runs, auth, config, log }));app.use(require('./api/attribution-quotas').attributionQuotasRouter({ db, quotas, auth, clock, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));if (credentials) app.use(require('./credentials').credentialsRouter({ credentials, auth, registry, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));app.use(adminRouter({ db, registry, pool, quotas, cache, runs, auth, ops, log }));// The operator console (/console, /auth/*): Network staff, server-rendered, no scripts (server/console).-- app.use(consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, env, clock, fetchImpl, log, vault }));++ // Person sessions are separate, read-only and open one run receipt each (server/person-session.js).++ const personSessions = createPersonSessions({ db, config, clock });++ app.use(consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, env, clock, fetchImpl, log, vault, personSessions }));++ app.use(runPageRouter({ runs, personSessions, log }));// Public price and latency (no sign-in, no scripts): provider_stats_daily totals and the rate cards,// nothing per caller. Only registered providers are listed: a person's own key records its runs as@@@ -136,7 +143,7 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys// Discovery for the public home (ai.openvibe.services): the home and the price page are for search engines// and assistants; the console, its sign-in and the API are not.app.get('/robots.txt', (_req, res) => {-- res.type('text/plain').send(`User-agent: *\nAllow: /$\nAllow: /stats\nAllow: /updates\nDisallow: /console\nDisallow: /auth/\nDisallow: /api/\n\nSitemap: ${config.baseUrl}/sitemap.xml\n`);++ res.type('text/plain').send(`User-agent: *\nAllow: /$\nAllow: /stats\nAllow: /updates\nDisallow: /console\nDisallow: /runs\nDisallow: /auth/\nDisallow: /api/\n\nSitemap: ${config.baseUrl}/sitemap.xml\n`);});app.get('/sitemap.xml', async (_req, res, next) => {try {@@@ -179,6 +186,7 @@ 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 /stats price and latency per provider and model (public)',++ 'GET /runs/:id your run receipt (OpenVibe.Network sign-in; your own runs only)','GET /console the operator console (OpenVibe.Network staff sign-in)','','Callers authenticate with OpenVibe.Network tokens (audience openvibe.ai): first-party service tokens, or a',@@@ -34,6 +34,7 @@ const CAPS = Object.freeze({credentialManage: 'ai.credential.manage',quotaAttributionManage: 'ai.quota.attribution.manage',appRun: 'ai.app.run',++ actionsRead: 'ai.actions.read',});/**@@@ -73,6 +73,8 @@ function load(env = process.env) {// Signs the sign-in flow cookie and keys the client-address hashes: 32+ random characters.sessionSecretRef: 'env:AI_CONSOLE_SESSION_SECRET',sessionTtlMin: Math.max(5, Math.min(12 * 60, int(env.AI_CONSOLE_SESSION_TTL_MIN, 60))),++ // A person's session for their run receipt (GET /runs/:id): a longer, read-only session.++ personSessionTtlMin: Math.max(5, Math.min(7 * 24 * 60, int(env.AI_PERSON_SESSION_TTL_MIN, 24 * 60))),redirectUri: `${baseUrl}/auth/callback`,// Secure (and __Host-) cookies whenever the console is served over https (always in production).cookieSecure: baseUrl.startsWith('https://'),@@@ -4,6 +4,7 @@* 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@@@ -75,6 +76,13 @@ function sanitizeNext(v) {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 NOTICES = {provider_status: 'Provider status changed.',provider_added: 'Provider added. Point a route at it below, or on the route\'s page.',@@@ -95,7 +103,7 @@ const DEF_KINDS = {};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 }) {++function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, vault = null, personSessions = null }) {const cc = config.console;const r = express.Router();const scoped = ['/console', '/auth'];@@@ -208,20 +216,47 @@ function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, eres.redirect(302, sso.authorizeUrl(config, { state, challenge }));});++ // 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 state = random(32);++ const { verifier, challenge } = sso.pkcePair();++ sessions.setFlow(res, { s: state, v: verifier, n: sanitizeRunNext(req.query.next), kind: 'person' });++ res.redirect(302, sso.authorizeUrl(config, { state, challenge }));++ });++r.get('/auth/callback', wrap(async (req, res) => {const flow = sessions.takeFlow(req, res);-- if (req.query.error) return await send(res, 400, pages.signIn({ message: `Sign-in was not completed (${String(req.query.error).slice(0, 60)}).` }));++ 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)) {-- return await send(res, 400, pages.signIn({ message: 'The sign-in could not be verified (expired or mismatched state). Please try again.' }));++ 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 }));}let person;try {person = await sso.exchange({ config, clientSecret: clientSecret(), code: req.query.code, verifier: flow.v, now: clock.now(), fetchImpl, log });} catch (e) {log.warn(`[console] sign-in failed: ${e.message}`);-- return await send(res, e.status && e.status < 500 ? 400 : 502, pages.signIn({ message: 'OpenVibe.Network did not confirm the sign-in. Please try again.' }));++ 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 || '/');++ }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.' }));@@@ -475,4 +510,4 @@ function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, ereturn r;}--module.exports = { consoleRouter, consoleCapabilities, sanitizeNext, STAFF_TO_AI };++module.exports = { consoleRouter, consoleCapabilities, sanitizeNext, sanitizeRunNext, STAFF_TO_AI };@@@ -107,14 +107,19 @@ async function runFacets(db) {};}--/** A bounded, credential-blanked copy of a stored input/output for display. */--function preview(value) {++/**++ * A bounded, credential-blanked copy of a stored input/output for display. The run receipt asks for++ * more text than the console's 240 characters; the byte cap still bounds the page.++ */++function preview(value, { string = PREVIEW_STRING, bytes = PREVIEW_BYTES } = {}) {++ const maxString = Math.max(1, Number(string) || PREVIEW_STRING);++ const maxBytes = Math.max(256, Number(bytes) || PREVIEW_BYTES);let cut = false;const walk = (v, depth) => {if (typeof v === 'string') {-- if (v.length <= PREVIEW_STRING) return v;++ if (v.length <= maxString) return v;cut = true;-- return `${v.slice(0, PREVIEW_STRING)}… (${v.length - PREVIEW_STRING} more characters)`;++ return `${v.slice(0, maxString)}… (${v.length - maxString} more characters)`;}if (v == null || typeof v !== 'object') return v;if (depth >= PREVIEW_DEPTH) { cut = true; return '…'; }@@@ -131,7 +136,7 @@ function preview(value) {};if (value === null || value === undefined) return { text: null, cut: false };let text = JSON.stringify(walk(value, 0), null, 2);-- if (text.length > PREVIEW_BYTES) { text = `${text.slice(0, PREVIEW_BYTES)}\n… (truncated)`; cut = true; }++ if (text.length > maxBytes) { text = `${text.slice(0, maxBytes)}\n… (truncated)`; cut = true; }return { text, cut };}@@@ -152,8 +152,8 @@ function signIn({ next, message }) {});}--function message({ title, text, staff, csrf, back = '/console' }) {-- return layout({ title, staff, csrf, body: html`<h1>${title}</h1><div class="card"><p>${text}</p><p><a href="${back}">Back</a></p></div>` });++function message({ title, text, staff, csrf, back = '/console', site = 'operator console' }) {++ return layout({ title, staff, csrf, site, body: html`<h1>${title}</h1><div class="card"><p>${text}</p><p><a href="${back}">Back</a></p></div>` });}const CAP_LABELS = {@@@ -381,7 +381,7 @@ const ref = (x) => (x ? (x.service ? `${x.service}:${x.type}:${x.id}` : `${x.typfunction run({ staff, csrf, r, citations, requests, input, output, canCancel, notice, error }) {const g = r.grounding;const sampleIds = r.usage.usage_sample_ids || [];-- const open = r.status === 'queued' || r.status === 'running';++ const open = r.status === 'queued' || r.status === 'running' || r.status === 'waiting';return layout({title: `Run ${r.id}`, section: 'runs', staff, csrf, notice, error, body: html`<h1>Run <span class="mono">${r.id}</span> ${badge(r.status)}</h1><div class="cols"><div class="card"><dl>@@@ -534,6 +534,42 @@ ${table('Audit rows, newest first', ['#', 'At', 'Actor', 'Action', 'Target', 'De});}++// ── Person: one run (GET /runs/:id, signed in) ───────────────++/**++ * A person's receipt for one run: status, workflow, model, cost, times, and the output or the error.++ * It never shows the input, the request log, the trace or anyone else's run. The console's classes++ * and CSS are reused; no script, no form and no external resource.++ */++function runReceipt({ viewer, r, output, outputCut }) {++ const open = r.status === 'queued' || r.status === 'running' || r.status === 'waiting';++ const provider = r.provenance && r.provenance.provider;++ const model = r.provenance && r.provenance.model;++ const usage = r.usage || {};++ const cost = usage.cost_usd == null ? '—' : usd(usage.cost_usd);++ return layout({++ title: `Run ${r.id}`, site: 'your runs', body: html`<h1>Run <span class="mono">${r.id}</span> ${badge(r.status)}</h1>++${viewer && viewer.username ? html`<p class="muted">Signed in as @${viewer.username}.</p>` : ''}++${open ? html`<div class="banner" role="status"><strong>${r.status === 'queued' ? 'Waiting to start' : r.status === 'waiting' ? 'Waiting for you' : 'Running'}</strong> — reload this page for the latest.</div>` : ''}++${r.error ? html`<div class="banner error" role="alert"><strong>${r.error.code || 'The run failed'}</strong><br>${clip(r.error.detail, 1000)}</div>` : ''}++<div class="cols"><div class="card"><dl>++<dt>Status</dt><dd>${badge(r.status)}</dd>++<dt>Workflow</dt><dd><code>${r.workflow.key}</code> v${r.workflow.version}</dd>++<dt>Model</dt><dd>${provider || model ? html`${provider ? html`<code>${provider}</code>` : '—'}${model ? html` / <code>${model}</code>` : ''}` : '—'}</dd>++<dt>Cost</dt><dd>${cost}</dd>++<dt>Created</dt><dd>${when(r.created_at)}</dd>++<dt>Started</dt><dd>${when(r.started_at)}</dd>++<dt>Finished</dt><dd>${when(r.finished_at)}</dd>++${r.created_at && r.finished_at ? html`<dt>Duration</dt><dd>${span(r.created_at, r.finished_at)}</dd>` : ''}++${r.provenance && r.provenance.cached_from ? html`<dt>From cache</dt><dd><a class="mono" href="/runs/${enc(r.provenance.cached_from)}">${r.provenance.cached_from}</a></dd>` : ''}++${r.retry_of ? html`<dt>Retry of</dt><dd><a class="mono" href="/runs/${enc(r.retry_of)}">${r.retry_of}</a></dd>` : ''}++</dl>${r.synthetic ? html`<p class="muted mt">Synthetic: produced by the stub provider.</p>` : ''}</div></div>++<h2>Output</h2>++${output != null ? html`<pre tabindex="0">${output}</pre>${outputCut ? html`<p class="muted">Shown bounded; the run has more.</p>` : ''}` : html`<div class="card muted"><p class="m0">${r.error ? 'No output: the run failed.' : open ? 'No output yet.' : 'This run has no output.'}</p></div>`}++<p class="muted">This page shows only your own run. Its input is not shown.</p>++<p><a href="/">OpenVibe.AI</a></p>`,++ });++}++// ── Public: price and latency (GET /stats, no sign-in) ───────const ms = (v) => (v == null ? '—' : `${n(v)} ms`);function rate(ok, total) {@@@ -571,4 +607,4 @@ ${table(`Providers and models, ${from} to ${to}`, ['Provider', 'Model', 'Capabil});}--module.exports = { CSP, html, esc, safeUrl, pages: { signIn, message, overview, providers, providerNew, definitions, definition, runs, run, quotas, usage, cache, audit, stats } };++module.exports = { CSP, html, esc, safeUrl, pages: { signIn, message, overview, providers, providerNew, definitions, definition, runs, run, runReceipt, quotas, usage, cache, audit, stats } };nnew file mode 100644@@@ -0,0 +1,62 @@++'use strict';++/**++ * Person sessions for the run receipt page (GET /runs/:id): a random 256-bit cookie id, stored as its++ * SHA-256; like the console's sessions, the cookie itself is never written down. A person session is++ * read-only and only opens runs whose on_behalf_of is its subject; it grants no capability and never++ * opens the operator console.++ *++ * cookie `__Host-ovai_person` (plain `ovai_person` over http in development): host-only, Path=/,++ * HttpOnly, SameSite=Lax, Secure when BASE_URL is https, Max-Age = AI_PERSON_SESSION_TTL_MIN.++ */++const crypto = require('crypto');++const { iso } = require('./util');++const { parseCookies } = require('./console/session');++++const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');++const random = (bytes = 32) => crypto.randomBytes(bytes).toString('base64url');++++function createPersonSessions({ db, config, clock = { now: () => Date.now() } }) {++ const cc = config.console;++ const now = () => clock.now();++ const cookieSecure = Boolean(cc.cookieSecure);++ const sessionCookie = cookieSecure ? '__Host-ovai_person' : 'ovai_person';++ const ttlMs = Math.max(5, Number(cc.personSessionTtlMin) || 24 * 60) * 60 * 1000;++++ function appendCookie(res, value) {++ const prev = res.getHeader('Set-Cookie');++ res.setHeader('Set-Cookie', prev ? [].concat(prev, value) : [value]);++ }++ function cookieHeader(value, maxAgeSec) {++ const parts = [`${sessionCookie}=${value}`, 'Path=/', 'HttpOnly', 'SameSite=Lax'];++ if (cookieSecure) parts.push('Secure');++ parts.push(`Max-Age=${Math.max(0, Math.floor(maxAgeSec))}`);++ return parts.join('; ');++ }++++ async function create(res, { subject, username }) {++ const t = now();++ await db.prepare('DELETE FROM person_sessions WHERE expires_at < ?').run(iso(t - 24 * 3600 * 1000));++ const id = random(32);++ await db.prepare(`INSERT INTO person_sessions (id_hash, subject, username, created_at, expires_at)++ VALUES (?, ?, ?, ?, ?)`).run(sha256(id), subject, username || null, iso(t), iso(t + ttlMs));++ appendCookie(res, cookieHeader(id, ttlMs / 1000));++ return id;++ }++++ /** The live person session of this request, or null (unknown, expired or revoked). */++ async function read(req) {++ const id = parseCookies(req)[sessionCookie];++ if (!id || !/^[A-Za-z0-9_-]{43}$/.test(id)) return null;++ const row = await db.prepare('SELECT * FROM person_sessions WHERE id_hash = ?').get(sha256(id));++ if (!row || row.revoked_at || Date.parse(row.expires_at) <= now()) return null;++ return { subject: row.subject, username: row.username, session: row };++ }++++ function clear(res) {++ appendCookie(res, cookieHeader('', 0));++ }++++ return { create, read, clear, sessionCookie, ttlMs };++}++++module.exports = { createPersonSessions, sha256, random };nnew file mode 100644@@@ -0,0 +1,55 @@++'use strict';++/**++ * A person's run receipt (plan 2026-10-28 §3.10): GET /runs/:id.++ *++ * The Action Center's AI item links here. A person signs in with OpenVibe.Network (GET++ * /auth/person/login, the same OAuth client and callback as the console), and the page shows one run++ * only when the run's on_behalf_of is that person. It never renders the input, the request log, the++ * trace or anyone else's run, and it is read-only: no script, no form, no external resource.++ */++const express = require('express');++const q = require('./console/queries');++const { CSP, pages } = require('./console/views');++++const RUN_ID = /^run_[0-9A-HJKMNP-TV-Z]{26}$/;++++function runPageRouter({ runs, personSessions, log = console }) {++ const r = express.Router();++++ function headers(res) {++ 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');++ }++ function send(res, status, text) {++ headers(res);++ return res.status(status).type('html').send(text);++ }++ const missing = (res) => send(res, 404, pages.message({ title: 'Run not found', text: 'There is no run here that belongs to you.', back: '/', site: 'your runs' }));++ const unavailable = (res) => send(res, 503, pages.message({ title: 'Run page unavailable', text: 'The run page sign-in is not configured on this host.', back: '/', site: 'your runs' }));++++ r.get('/runs/:id', async (req, res) => {++ try {++ const id = String(req.params.id);++ if (!RUN_ID.test(id)) return missing(res);++ if (!personSessions) return unavailable(res);++ const viewer = await personSessions.read(req);++ if (!viewer) return res.redirect(302, `/auth/person/login?next=${encodeURIComponent(`/runs/${id}`)}`);++ const run = await runs.getReceipt(id);++ const person = run && run.on_behalf_of;++ if (!run || !person || person.type !== 'user' || person.id !== viewer.subject) return missing(res);++ const output = q.preview(run.output, { string: 4000, bytes: 20000 });++ return send(res, 200, pages.runReceipt({ viewer, r: run, output: output.text, outputCut: output.cut }));++ } catch (err) {++ log.error(`[run-page] ${req.method} ${req.path}: ${(err && err.stack) || err}`);++ if (res.headersSent) return res.end();++ return send(res, 500, pages.message({ title: 'Run page error', text: 'The run could not be read. Try again in a moment.', back: '/', site: 'your runs' }));++ }++ });++++ return r;++}++++module.exports = { runPageRouter, RUN_ID };@@@ -509,6 +509,33 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { nowreturn (await db.prepare(`SELECT * FROM runs ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY id DESC LIMIT ?`).all(...args, Math.min(200, Number(limit) || 50))).map(decode);}++ // The columns a person's Action Center item and run receipt need. Input, its hash, the trace and the++ // request/idempotency identifiers are deliberately absent: another person's input is never even read.++ const PERSON_COLUMNS = `id, workflow_key, workflow_version, status, on_behalf_of, output, error_code, error_detail,++ provider_key, model_key, cost_usd, tokens_in, tokens_out, attempts, created_at, started_at, finished_at,++ synthetic, cached_from, retry_of, citations_count`;++++ /**++ * A person's runs for the Action Center: on_behalf_of is that user. Everything still queued/running, then the++ * terminal ones updated since `since`; newest updated first, at most limit (50 by the route).++ */++ async function listOnBehalfOf(subject, { since = null, limit = 50 } = {}) {++ const where = ["on_behalf_of IS NOT NULL", "on_behalf_of::jsonb->>'type' = 'user'", "on_behalf_of::jsonb->>'id' = ?"];++ const args = [subject];++ if (since != null) {++ where.push("(status IN ('queued', 'running', 'waiting') OR COALESCE(finished_at, started_at, created_at) >= ?)");++ args.push(iso(since));++ }++ const rows = await db.prepare(`SELECT ${PERSON_COLUMNS} FROM runs WHERE ${where.join(' AND ')}++ ORDER BY COALESCE(finished_at, started_at, created_at) DESC LIMIT ?`).all(...args, Math.min(50, Number(limit) || 50));++ return rows.map(decode);++ }++++ /** One run for its person's receipt page, without its input. The caller checks on_behalf_of. */++ async function getReceipt(id) {++ return decode(await db.prepare(`SELECT ${PERSON_COLUMNS} FROM runs WHERE id = ?`).get(id));++ }++/** The run with its Billing readings in explain.usage_readings (server/usage-samples.js readingsFor); unchanged when it has none. */async function explained(run) {const ids = run && run.usage ? run.usage.usage_sample_ids : null;@@@ -553,7 +580,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now/** Queue depth for /metrics and /api/ready: runs waiting for a slot, and runs holding one. */function stats() { return { queued: queue.length, running: active, inflight: inflight.size, max_concurrent: config.runs.maxConcurrent, max_queued: config.runs.maxQueued }; }-- return { create, wait, get, list, cancel, retry, citations, addCitations, requestsFor, explained, recoverInterrupted, prune, drain, stats, inflight, TERMINAL, decode };++ return { create, wait, get, getReceipt, list, listOnBehalfOf, cancel, retry, citations, addCitations, requestsFor, explained, recoverInterrupted, prune, drain, stats, inflight, TERMINAL, decode };}module.exports = { createRuns, entityKey, subjectKey };nnew file mode 100644@@@ -0,0 +1,121 @@++'use strict';++/**++ * AI's Action Center read (plan 2026-10-28 §3.10, common.action-list-result@1): a first-party service with++ * ai.actions.read names one person and gets the runs made on their behalf (on_behalf_of), never anyone else's —++ * running first-class, done and failed from the last 30 days only, newest updated first, at most 50, plain text,++ * https links and the right actions. A person's token, a token without the capability, an app token and a bad subject++ * are refused.++ */++const assert = require('assert');++const { ids, validate } = require('openvibe-contracts');++const { boot, request, token, suite } = require('./helpers');++const items = require('../server/actions/items');++++const DAY = 86_400_000;++const t = suite('actions');++let h;++let wf;++let ada;++let bo;++++const iso = (ms) => new Date(ms).toISOString();++++async function addRun({ id = `run_${ids.ulid()}`, subject = null, status = 'queued', created = Date.now(), started = null, finished = null,++ output = null, errorCode = null, errorDetail = null, cost = 0, model = null, requester = 'network', onBehalf = undefined }) {++ const obo = onBehalf === undefined ? (subject ? JSON.stringify({ type: 'user', id: subject }) : null) : onBehalf;++ await h.db.prepare(`INSERT INTO runs (id, workflow_key, workflow_version, status, requester_type, requester_id, on_behalf_of,++ input, input_hash, output, error_code, error_detail, cost_usd, provider_key, model_key, created_at, started_at, finished_at)++ VALUES (?, ?, ?, ?, 'service', ?, ?, '{}', 'x', ?, ?, ?, ?, ?, ?, ?, ?, ?)`)++ .run(id, wf.key, wf.version, status, requester, obo, output == null ? null : JSON.stringify(output), errorCode, errorDetail, cost, model ? 'deepseek' : null, model, iso(created), started ? iso(started) : null, finished ? iso(finished) : null);++ return id;++}++++t.test('boot with the seeded ai.generate workflow', async () => {++ h = await boot();++ wf = await h.registry.getWorkflow('ai.generate');++ ada = ids.newId('user');++ bo = ids.newId('user');++});++++t.test('the state → bucket table and the actions each bucket allows', async () => {++ const table = [['queued', 'running'], ['running', 'running'], ['waiting', 'waiting'], ['succeeded', 'done'], ['cached', 'done'], ['failed', 'failed'], ['cancelled', 'failed']];++ for (const [status, bucket] of table) assert.strictEqual(items.bucketFor(status), bucket, status);++ assert.deepStrictEqual(items.actionsFor('running'), ['open']);++ assert.deepStrictEqual(items.actionsFor('failed'), ['open']);++ assert.deepStrictEqual(items.actionsFor('done'), ['open']);++ assert.deepStrictEqual(items.actionsFor('waiting'), ['open']);++ assert.strictEqual(items.plain('<b>All</b> **done** & shipped', 160), 'All done & shipped');++ assert.ok(items.plain('x'.repeat(500), 400).length <= 400);++});++++t.test('only that person\'s runs, newest first, with the buckets, actions, text and links common.action-item@1 allows', async () => {++ const now = Date.now();++ const queuedOld = await addRun({ subject: ada, status: 'queued', created: now - 40 * DAY });++ const running = await addRun({ subject: ada, status: 'running', created: now - 3 * 3600_000, started: now - 2 * 3600_000, model: 'deepseek-flash' });++ const done = await addRun({ subject: ada, status: 'succeeded', created: now - 3 * DAY, started: now - 2 * DAY, finished: now - DAY, cost: 0.0012, model: 'deepseek-flash', output: { text: '<b>All **done**</b> & shipped.' } });++ const cached = await addRun({ subject: ada, status: 'cached', created: now - 4 * DAY, finished: now - 2 * DAY, cost: 0, output: { text: 'cached answer' } });++ const failed = await addRun({ subject: ada, status: 'failed', created: now - 5 * DAY, finished: now - 3 * DAY, errorCode: 'run.provider_error', errorDetail: '<script>it broke</script>' });++ const cancelled = await addRun({ subject: ada, status: 'cancelled', created: now - 6 * DAY, finished: now - 4 * DAY, errorCode: 'run.cancelled', errorDetail: 'cancelled by caller' });++ const old = await addRun({ subject: ada, status: 'succeeded', created: now - 45 * DAY, finished: now - 40 * DAY, output: { text: 'ancient' } });++ const theirs = await addRun({ subject: bo, status: 'succeeded', created: now - 60_000, finished: now - 30_000, output: { text: 'bo' } });++ const notPersonal = await addRun({ status: 'running', created: now - 30_000, onBehalf: JSON.stringify({ type: 'app', id: 'app_01J8Z3Q4R5S6T7V8W9X0Y1Z2A1' }) });++ const otherRequester = await addRun({ subject: ada, requester: 'actor', status: 'running', created: now - 15_000, model: 'deepseek-flash' });++++ const r = await request(h.base, 'GET', `/api/v1/actions?subject=${ada}`, { tok: token('network', ['ai.actions.read']) });++ assert.strictEqual(r.status, 200, r.text);++ const v = validate('common.action-list-result@1', r.body);++ assert.ok(v.valid, JSON.stringify(v.errors));++ const byId = new Map(r.body.items.map((i) => [i.id, i]));++ assert.deepStrictEqual([byId.get(queuedOld).bucket, byId.get(queuedOld).actions], ['running', ['open']], 'an old queued run still runs');++ assert.deepStrictEqual([byId.get(running).bucket, byId.get(running).actions], ['running', ['open']]);++ assert.deepStrictEqual([byId.get(done).bucket, byId.get(done).cost_usd, byId.get(done).steps, byId.get(done).actions], ['done', 0.0012, null, ['open']]);++ assert.strictEqual(byId.get(done).title, 'Generate text');++ assert.strictEqual(byId.get(done).summary, 'All done & shipped.');++ assert.strictEqual(byId.get(done).agent, 'deepseek-flash');++ assert.strictEqual(byId.get(cached).bucket, 'done');++ assert.deepStrictEqual([byId.get(failed).bucket, byId.get(failed).error, byId.get(failed).actions], ['failed', 'it broke', ['open']]);++ assert.deepStrictEqual([byId.get(cancelled).bucket, byId.get(cancelled).actions], ['failed', ['open']]);++ assert.ok(byId.has(otherRequester), 'the run is the person\'s though another service asked for it');++ assert.ok(!byId.has(old), 'done and failed older than 30 days are gone');++ assert.ok(!byId.has(theirs) && !byId.has(notPersonal), 'never another person\'s or a project\'s run');++ for (const it of r.body.items) {++ assert.strictEqual(it.href, `https://ai.openvibe.services/runs/${it.id}`);++ assert.ok(it.title.length <= 160 && (!it.summary || it.summary.length <= 400) && (!it.error || it.error.length <= 400));++ assert.ok(!/[<>*_`]/.test(it.title) && !/[<>*_`]/.test(it.summary || '') && !/[<>*_`]/.test(it.error || ''));++ }++ const times = r.body.items.map((i) => i.updated_at);++ assert.deepStrictEqual(times, [...times].sort().reverse(), 'newest updated first');++});++++t.test('at most 50 items, newest first', async () => {++ const now = Date.now();++ const mara = ids.newId('user');++ const newest = `run_${ids.ulid()}`;++ for (let i = 0; i < 55; i++) {++ await addRun({ id: i === 0 ? newest : `run_${ids.ulid()}`, subject: mara, status: 'running', created: now - i * 60_000 });++ }++ const r = await request(h.base, 'GET', `/api/v1/actions?subject=${mara}`, { tok: token('network', ['ai.actions.read']) });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.body.items.length, 50);++ assert.strictEqual(r.body.items[0].id, newest);++});++++t.test('a person\'s token, a service without the capability, an app token and a bad subject are refused', async () => {++ const person = await request(h.base, 'GET', `/api/v1/actions?subject=${ada}`, { tok: token('x', ['ai.actions.read'], { sub: `user:${ada}` }) });++ assert.strictEqual(person.status, 401);++ const noCap = await request(h.base, 'GET', `/api/v1/actions?subject=${ada}`, { tok: token('network', ['ai.run.read']) });++ assert.strictEqual(noCap.status, 403);++ assert.strictEqual(noCap.body.code, 'capability.denied');++ const app = await request(h.base, 'GET', `/api/v1/actions?subject=${ada}`, { tok: token('x', ['ai.actions.read'], { sub: 'app:app_01J8Z3Q4R5S6T7V8W9X0Y1Z2A1', actorType: 'app', extra: { project_id: 'prj_01J8Z3Q4R5S6T7V8W9X0Y1Z2P1', env: 'production' } }) });++ assert.strictEqual(app.status, 403);++ const none = await request(h.base, 'GET', `/api/v1/actions?subject=${ada}`);++ assert.strictEqual(none.status, 401);++ const bad = await request(h.base, 'GET', '/api/v1/actions?subject=usr_nope', { tok: token('network', ['ai.actions.read']) });++ assert.strictEqual(bad.status, 400);++ assert.strictEqual(bad.body.code, 'input.invalid');++ const missing = await request(h.base, 'GET', '/api/v1/actions', { tok: token('network', ['ai.actions.read']) });++ assert.strictEqual(missing.status, 400);++});++++t.test('stop the service', async () => { await h.stop(); });++t.run();nnew file mode 100644@@@ -0,0 +1,212 @@++'use strict';++/**++ * The person-facing run receipt (plan 2026-10-28 §3.10, GET /runs/:id): a Network person signs in with++ * PKCE, and sees one run only when its on_behalf_of is that person — status, workflow, model, cost,++ * times, and the output or the error, never the input or anyone else's run. The Action Center link++ * points here. The page is server-rendered, noindex, no-store, script-free and escaped.++ */++const assert = require('assert');++const crypto = require('crypto');++const { serviceAuth, ids } = require('openvibe-contracts');++const { boot, request, suite, privateKey, ISSUER } = require('./helpers');++const { sanitizeRunNext } = require('../server/console');++const items = require('../server/actions/items');++++const t = suite('run-page');++const NETWORK = 'http://network.test';++const CLIENT_SECRET = `sentinel-client-${crypto.randomBytes(12).toString('hex')}`;++const SESSION_SECRET = crypto.randomBytes(32).toString('hex');++const DAY = 86_400_000;++const iso = (ms) => new Date(ms).toISOString();++++let h;++let wf;++let ada;++let bo;++let runIds = {};++++// ── A fake Network: the token endpoint redeems the code for a person's access token ──++const codes = new Map();++function authorize({ subject_id, username = 'someone', expired = false }) {++ const code = crypto.randomBytes(16).toString('hex');++ codes.set(code, { subject_id, username, 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,++ iss: ISSUER, aud: ['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`) {++ const body = Object.fromEntries(new URLSearchParams(String(opts.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);++ 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`) return json(200, { revoked: true });++ return globalThis.fetch(url, opts);++}++++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 signIn(subject, { username = 'ada', next = '/runs/run_01J8Z3Q4R5S6T7V8W9X0Y1Z2A1', expired = false } = {}) {++ const login = await get(`/auth/person/login?next=${encodeURIComponent(next)}`);++ assert.strictEqual(login.status, 302, login.text);++ const loc = new URL(login.headers.get('location'));++ const flow = login.cookies.find((c) => c.startsWith('ovai_flow='));++ const code = authorize({ subject_id: subject, username, expired });++ 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_person=') && !c.startsWith('__Host-ovai_person=;'));++ return { login, loc, cb, setCookie: sess || null, cookie: sess ? sess.split(';')[0] : null };++}++++async function addRun({ id = `run_${ids.ulid()}`, subject = ada, status = 'succeeded', created = Date.now(), started = null, finished = null,++ output = null, errorCode = null, errorDetail = null, cost = 0, model = null, provider = null, input = { prompt: 'PRIVATE-INPUT-DO-NOT-SHOW' } }) {++ await h.db.prepare(`INSERT INTO runs (id, workflow_key, workflow_version, status, requester_type, requester_id, on_behalf_of,++ input, input_hash, output, error_code, error_detail, cost_usd, provider_key, model_key, created_at, started_at, finished_at)++ VALUES (?, ?, ?, ?, 'service', 'network', ?, ?, 'x', ?, ?, ?, ?, ?, ?, ?, ?, ?)`)++ .run(id, wf.key, wf.version, status, JSON.stringify({ type: 'user', id: subject }), JSON.stringify(input), output == null ? null : JSON.stringify(output),++ errorCode, errorDetail, cost, provider, model, iso(created), started ? iso(started) : null, finished ? iso(finished) : null);++ return id;++}++++t.test('boot with the seeded ai.generate workflow', async () => {++ 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_PERSON_SESSION_TTL_MIN: '1440', AI_USER_MODULES: 'off',++ },++ fetchImpl: fakeFetch,++ });++ wf = await h.registry.getWorkflow('ai.generate');++ ada = ids.newId('user');++ bo = ids.newId('user');++});++++t.test('the next is exactly one run page: open-redirect shapes go home', () => {++ for (const bad of ['//evil.com', '/\\evil.com', '/\t/runs/run_x', 'https://evil.com', '/auth/login', '/console', '/runs/', '/runs/run_nope', '', null]) {++ assert.strictEqual(sanitizeRunNext(bad), '/', JSON.stringify(bad));++ }++ const good = `/runs/run_${ids.ulid()}`;++ assert.strictEqual(sanitizeRunNext(good), good);++ const id = `run_${ids.ulid()}`;++ assert.strictEqual(items.runPage(id), `https://ai.openvibe.services/runs/${id}`);++});++++t.test('anonymous: the page redirects to the person sign-in, then returns to the same run', async () => {++ const id = await addRun({ status: 'succeeded', created: Date.now() - DAY, finished: Date.now() - 1000 });++ const r = await get(`/runs/${id}`);++ assert.strictEqual(r.status, 302);++ assert.strictEqual(r.headers.get('location'), `/auth/person/login?next=${encodeURIComponent(`/runs/${id}`)}`);++ assert.strictEqual((await get('/runs/run_nope')).status, 404, 'a malformed id never starts a sign-in');++});++++t.test('sign-in: PKCE S256 as client ai, a host-only person cookie, and back to the run page', async () => {++ const created = runIds.created = Date.now() - 3 * DAY;++ const id = runIds.ok = await addRun({ status: 'succeeded', created, started: created + DAY, finished: created + 2 * DAY,++ cost: 0.0012, provider: 'deepseek', model: 'deepseek-flash', output: { text: 'The answer is <script>alert(1)</script> & done.' } });++ const r = await signIn(ada, { next: `/runs/${id}` });++ assert.strictEqual(r.login.status, 302);++ assert.strictEqual(r.loc.origin + r.loc.pathname, 'https://openvibe.network/oauth/authorize');++ assert.strictEqual(r.loc.searchParams.get('client_id'), 'ai');++ assert.strictEqual(r.loc.searchParams.get('redirect_uri'), 'https://ai.test/auth/callback');++ assert.strictEqual(r.loc.searchParams.get('response_type'), 'code');++ assert.strictEqual(r.loc.searchParams.get('code_challenge_method'), 'S256');++ assert.match(r.loc.searchParams.get('code_challenge'), /^[A-Za-z0-9_-]{43}$/);++ assert.strictEqual(r.cb.status, 303, r.cb.text);++ assert.strictEqual(r.cb.headers.get('location'), `/runs/${id}`);++ for (const attr of ['Path=/', 'HttpOnly', 'Secure', 'SameSite=Lax']) 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 <= 7 * 24 * 3600, `max-age ${maxAge}`);++ const row = await h.db.prepare('SELECT * FROM person_sessions WHERE subject = ?').get(ada);++ assert.notStrictEqual(row.id_hash, r.cookie.split('=')[1], 'only a hash of the session id is stored');++ runIds.adaCookie = r.cookie;++});++++t.test('the receipt shows only that person\'s run: status, workflow, model, cost, time, output; never the input', async () => {++ const r = await get(`/runs/${runIds.ok}`, runIds.adaCookie);++ assert.strictEqual(r.status, 200, r.text.slice(0, 400));++ assert.match(r.headers.get('content-type'), /text\/html/);++ assert.ok(r.text.includes('succeeded') && r.text.includes('ai.generate') && r.text.includes('deepseek-flash'));++ assert.ok(r.text.includes('$0.001200'), 'the cost');++ assert.ok(r.text.includes(`<time datetime="${iso(runIds.created)}">`), 'the created time');++ assert.ok(r.text.includes('<script>alert(1)</script> & done.'), 'the output is escaped');++ assert.ok(!r.text.includes('<script>alert(1)</script>'), 'no raw output markup');++ assert.ok(!r.text.includes('PRIVATE-INPUT-DO-NOT-SHOW'), 'the input is never shown');++ assert.ok(r.text.includes('This page shows only your own run. Its input is not shown.'));++ assert.ok(!/<script/i.test(r.text), 'no script');++ assert.ok(r.text.includes('<meta name="robots" content="noindex,nofollow">'));++ assert.match(r.headers.get('content-security-policy') || '', /default-src 'none'/);++ assert.strictEqual(r.headers.get('x-robots-tag'), 'noindex, nofollow');++ assert.strictEqual(r.headers.get('referrer-policy'), 'no-referrer');++ assert.match(r.headers.get('cache-control') || '', /no-store/);++ assert.strictEqual((r.text.match(/<h1[ >]/g) || []).length, 1, 'one h1');++});++++t.test('a failed or running run: the error or the live state, with no output yet', async () => {++ const failed = runIds.failed = await addRun({ status: 'failed', created: Date.now() - 2 * DAY, started: Date.now() - 2 * DAY, finished: Date.now() - DAY,++ errorCode: 'run.provider_error', errorDetail: '<script>it broke</script>' });++ let r = await get(`/runs/${failed}`, runIds.adaCookie);++ assert.strictEqual(r.status, 200);++ assert.ok(r.text.includes('<code>run.provider_error</code>') || r.text.includes('run.provider_error'));++ assert.ok(r.text.includes('<script>it broke</script>') && !r.text.includes('<script>it broke</script>'));++ assert.ok(r.text.includes('No output: the run failed.'));++++ const running = await addRun({ status: 'running', created: Date.now() - 3600_000, started: Date.now() - 1800_000, provider: 'deepseek', model: 'deepseek-flash' });++ r = await get(`/runs/${running}`, runIds.adaCookie);++ assert.strictEqual(r.status, 200);++ assert.ok(r.text.includes('Running') && r.text.includes('reload this page'));++ assert.ok(r.text.includes('No output yet.'));++});++++t.test('another person\'s run is a 404; so is this person\'s run to another person', async () => {++ const theirs = await addRun({ subject: bo, status: 'succeeded', finished: Date.now() - 1000, output: { text: 'BO-PRIVATE-OUTPUT' } });++ let r = await get(`/runs/${theirs}`, runIds.adaCookie);++ assert.strictEqual(r.status, 404);++ assert.ok(!r.text.includes('BO-PRIVATE-OUTPUT'), 'no other person\'s output');++ const boSignIn = await signIn(bo, { username: 'bo', next: `/runs/${theirs}` });++ assert.strictEqual(boSignIn.cb.status, 303);++ r = await get(`/runs/${theirs}`, boSignIn.cookie);++ assert.strictEqual(r.status, 200);++ assert.ok(r.text.includes('BO-PRIVATE-OUTPUT'));++ assert.strictEqual((await get(`/runs/${runIds.ok}`, boSignIn.cookie)).status, 404);++});++++t.test('a service token or an expired token starts no person session; a bad state is refused', async () => {++ const noSubject = await signIn(null, { next: '/runs/run_01J8Z3Q4R5S6T7V8W9X0Y1Z2A1' });++ assert.strictEqual(noSubject.cb.status, 403);++ assert.strictEqual(noSubject.cookie, null);++ const expired = await signIn(ada, { expired: true, next: '/runs/run_01J8Z3Q4R5S6T7V8W9X0Y1Z2A1' });++ assert.ok(expired.cb.status >= 400);++ assert.strictEqual(expired.cookie, null);++ const login = await get('/auth/person/login?next=%2Fruns%2Frun_01J8Z3Q4R5S6T7V8W9X0Y1Z2A1');++ const flow = login.cookies.find((c) => c.startsWith('ovai_flow=')).split(';')[0];++ const code = authorize({ subject_id: ada });++ 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);++ assert.strictEqual((await get(`/api/v1/runs/${runIds.ok}`, runIds.adaCookie)).status, 401);++});++++t.test('boot leaves the person page out of search and the API index names it', async () => {++ const robots = await request(h.base, 'GET', '/robots.txt');++ assert.ok(robots.text.includes('Disallow: /runs') && robots.text.includes('Disallow: /console') && robots.text.includes('Disallow: /auth/'));++ const index = await request(h.base, 'GET', '/');++ assert.ok(index.text.includes('GET /runs/:id your run receipt'));++});++++t.test('stop the service', async () => { await h.stop(); });++t.run();
@@ -44,6 +44,8 @@ OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
@@ -44,6 +44,8 @@ OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
#AI_CONSOLE_SESSION_SECRET=
#AI_CONSOLE_SESSION_SECRET=
# Absolute console session lifetime in minutes (5-720)
# Absolute console session lifetime in minutes (5-720)
#AI_CONSOLE_SESSION_TTL_MIN=60
#AI_CONSOLE_SESSION_TTL_MIN=60
+# A person's run-receipt session lifetime in minutes (5-10080)
+#AI_PERSON_SESSION_TTL_MIN=1440
# Audience a Network user token must carry
# Audience a Network user token must carry
#AI_SSO_AUDIENCE=openvibe.network
#AI_SSO_AUDIENCE=openvibe.network
# ── Billing (plan T5 lane F) ──
# ── Billing (plan T5 lane F) ──
@@ -107,6 +107,7 @@ idempotent replay):
@@ -107,6 +107,7 @@ idempotent replay):
| `POST /api/v1/research` — a run of `research.gather` ([MegaSource](#megasource-research)) | `ai.run.create` (namespace `research.*`) or `ai.app.run` (free sources only) |
| `POST /api/v1/research` — a run of `research.gather` ([MegaSource](#megasource-research)) | `ai.run.create` (namespace `research.*`) or `ai.app.run` (free sources only) |
| `POST /api/v1/{chat,generate,summarize,classify,extract,embed,image}` (no `enrich`), `GET /runs/:id` (the app's own runs) | `ai.app.run` for a developer-app token ([Developer apps](#developer-apps)) |
| `POST /api/v1/{chat,generate,summarize,classify,extract,embed,image}` (no `enrich`), `GET /runs/:id` (the app's own runs) | `ai.app.run` for a developer-app token ([Developer apps](#developer-apps)) |
| `GET /api/v1/runs`, `GET /runs/:id` (+ citations, request-log metadata), `GET /runs/:id/citations` | `ai.run.read` (own runs) |
| `GET /api/v1/runs`, `GET /runs/:id` (+ citations, request-log metadata), `GET /runs/:id/citations` | `ai.run.read` (own runs) |
+| `GET /api/v1/actions?subject=usr_…` — one person's Action Center items (`common.action-list-result@1`, runs whose `on_behalf_of` is that person) | `ai.actions.read` (first-party service) |
| `GET /api/v1/workflows\|templates\|routes[/:key]` | `ai.run.create`, `ai.workflow.manage` or `ai.usage.read` |
| `GET /api/v1/workflows\|templates\|routes[/:key]` | `ai.run.create`, `ai.workflow.manage` or `ai.usage.read` |
| `GET /api/v1/routes?operation=generate` | the same read: every route's `offers` (`{provider, model}`), plus the operation's deduplicated `offers` |
| `GET /api/v1/routes?operation=generate` | the same read: every route's `offers` (`{provider, model}`), plus the operation's deduplicated `offers` |
| `POST /api/v1/{templates,workflows,routes}/:key/versions`, `…/versions/:v/status` | `ai.workflow.manage` |
| `POST /api/v1/{templates,workflows,routes}/:key/versions`, `…/versions/:v/status` | `ai.workflow.manage` |
@@ -115,7 +116,9 @@ idempotent replay):
@@ -115,7 +116,9 @@ idempotent replay):
| `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…`, `/auth/login\|callback`, `POST /auth/logout` | Network staff, signed in (see [Operator console](#operator-console)) |
-| `GET /robots.txt` | public: disallows `/console`, `/auth/`, `/api/` |
+| `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/` |
| `GET /stats` | public: p50/p95 latency, success rate and rate-card price per registered provider and model over the last 7 days (server-rendered, `openvibe-shared/cache-policy` HTML headers, nothing per caller) |
| `GET /stats` | public: p50/p95 latency, success rate and rate-card price per registered provider and model over the last 7 days (server-rendered, `openvibe-shared/cache-policy` HTML headers, nothing per caller) |
Browser reads of `GET /stats` and `GET /release.json` allow exact origins from `AI_CORS_ORIGINS`
Browser reads of `GET /stats` and `GET /release.json` allow exact origins from `AI_CORS_ORIGINS`
@@ -292,6 +295,22 @@ versions of routes, templates and workflows, models and provider records are sti
@@ -292,6 +295,22 @@ versions of routes, templates and workflows, models and provider records are sti
the admin API only. There is no drill mode in AI; the console starts nothing (no timer, no outbound
the admin API only. There is no drill mode in AI; the console starts nothing (no timer, no outbound
call) until someone signs in.
call) until someone signs in.
+## Run receipts
+
+`GET /runs/:id` is the person-facing page the Action Center's AI item links to (plan 2026-10-28
+§3.10). It shows one run's status, workflow, model, cost, created/started/finished times and the
+output or the error — never the stored input, the request log, the trace or another person's run.
+It is server-rendered with the console's classes and CSP (no script, no form, no external resource),
+`noindex`, `no-store`, `Referrer-Policy: no-referrer`.
+
+A person signs in through the same Network OAuth client and callback as the console, but
+`GET /auth/person/login` marks the flow as a person flow: the callback stores a read-only session
+(`__Host-ovai_person`, host-only, HttpOnly, Secure, SameSite=Lax, `AI_PERSON_SESSION_TTL_MIN` = 24 h;
+only its hash in `person_sessions`) and redirects only to the exact `/runs/run_<ULID>` page asked
+for. It grants no capability and never opens `/console`. The page shows a run only when the run's
+`on_behalf_of` is that session's `usr_…` subject; any other run is a 404. `GET /api/v1/actions` for
+the Action Center is separate: a first-party service token with `ai.actions.read` names the person.
+
### Provider keys added from the console (`vault:`)
### Provider keys added from the console (`vault:`)
A provider the owner adds from the console keeps its key in `provider_secrets` (migration `0010_provider_secrets.sql`,
A provider the owner adds from the console keeps its key in `provider_secrets` (migration `0010_provider_secrets.sql`,
@@ -43,6 +43,7 @@
@@ -43,6 +43,7 @@
"ai.run.* events through the SDK outbox",
"ai.run.* events through the SDK outbox",
"ai.preferences and ai.usage_summary user modules",
"ai.preferences and ai.usage_summary user modules",
"server-rendered operator console for Network staff",
"server-rendered operator console for Network staff",
+ "Action Center read (ai.actions.read) and the person-facing run receipt (GET /runs/:id, OpenVibe.Network person sign-in)",
"Live import",
"Live import",
"platform.usage-sample@1 readings to Billing with a per-subject provider free allowance; each run's reading state in explain and the free allowance on the staff console",
"platform.usage-sample@1 readings to Billing with a per-subject provider free allowance; each run's reading state in explain and the free allowance on the staff console",
"tier budgets (free, paid, staff) per subject and per project on openvibe-sdk/govern in Valkey, behind AI_GOVERN_TIERS (off by default); readings from govern's onUsage",
"tier budgets (free, paid, staff) per subject and per project on openvibe-sdk/govern in Valkey, behind AI_GOVERN_TIERS (off by default); readings from govern's onUsage",
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
+{
+ "id": "ai.actions.read",
+ "version": "1.0",
+ "owner": "ai",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Read one person's Action Center items from OpenVibe.AI (plan 2026-10-28 §3.10): AI runs made on the person's behalf (on_behalf_of), as common.action-list-result@1. GET /api/v1/actions?subject=usr_… with a service token; granted to OpenVibe.Network's aggregator only. Never another person's items, and only the fields of common.action-item@1.",
+ "permissions": [
+ "ai:read"
+ ],
+ "resourceConstraints": [
+ "none"
+ ],
+ "quotaClass": "standard",
+ "events": [],
+ "implementedBy": [
+ "GET /api/v1/actions"
+ ],
+ "inputSchema": "common.no-body@1",
+ "outputSchema": "common.action-list-result@1"
+}
@@ -20,6 +20,7 @@
@@ -20,6 +20,7 @@
"note": "the public home, /stats and the developer-app API at ai.openvibe.services (2026-10-07); the old ai.openvibe.network address was retired on 2026-10-10 (no vhost); services on the host call 127.0.0.1:4700"
"note": "the public home, /stats and the developer-app API at ai.openvibe.services (2026-10-07); the old ai.openvibe.network address was retired on 2026-10-10 (no vhost); services on the host call 127.0.0.1:4700"
},
},
"capabilities": [
"capabilities": [
+ "ai.actions.read",
"ai.app.run",
"ai.app.run",
"ai.credential.manage",
"ai.credential.manage",
"ai.provider.manage",
"ai.provider.manage",
new file mode 100644
new file mode 100644
@@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
+-- phase: expand
+-- Person sessions for the run page (plan 2026-10-28 §3.10): a signed-in person's receipt for one run.
+-- The cookie is a random 256-bit id; only its SHA-256 is stored. Staff console sessions stay in console_sessions.
+CREATE TABLE IF NOT EXISTS person_sessions (
+ id_hash text COLLATE "C" PRIMARY KEY,
+ subject text COLLATE "C" NOT NULL, -- the Network person (usr_…)
+ username text COLLATE "C",
+ created_at text COLLATE "C" NOT NULL,
+ expires_at text COLLATE "C" NOT NULL,
+ revoked_at text COLLATE "C",
+ ip_hash text COLLATE "C"
+);
@@ -13,7 +13,7 @@
@@ -13,7 +13,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.229.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -1265,9 +1265,9 @@
@@ -1265,9 +1265,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.222.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
- "integrity": "sha512-wdUQCilq9glRMwfxEswbIj3l0QQqKDXZAi2+CR5Mlh34rZjI1Q+7GlAoR0UtNwZAgtLwXhPxFyJ/aPk8lcDG1Q==",
+ "version": "0.229.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.229.0",
+ "integrity": "sha512-BYazi/dna8bgdM6+Go8t+D7lTyI9UaWYnJIx0hYEdD0v2ps87iTPONkjnCBadk5RUrBDtvcNoSONS3R+bwFG0w==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -25,7 +25,7 @@
@@ -25,7 +25,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.229.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
new file mode 100644
new file mode 100644
@@ -0,0 +1,83 @@
@@ -0,0 +1,83 @@
+'use strict';
+
+/**
+ * AI's Action Center items (plan 2026-10-28 §3.10, common.action-item@1): runs made on one person's behalf
+ * (on_behalf_of), mapped from the run's own status to the five Action Center buckets. bucketFor() is exported on its
+ * own so its state table can be tested.
+ */
+
+const PUBLIC_ORIGIN = 'https://ai.openvibe.services';
+const ENTITIES = Object.freeze({ amp: '&', lt: '<', gt: '>', quot: '"', '#39': "'", nbsp: ' ' });
+
+const STATUS_BUCKET = Object.freeze({
+ queued: 'running',
+ running: 'running',
+ waiting: 'waiting', // reserved for a workflow that needs the person; AI has none today
+ succeeded: 'done',
+ cached: 'done',
+ failed: 'failed',
+ cancelled: 'failed',
+});
+
+function bucketFor(status) {
+ return STATUS_BUCKET[status] || 'failed';
+}
+
+/**
+ * What the person may do from the list ([email protected]). The item opens the run receipt.
+ * Cancel and retry are not offered: Network has no AI decide route, and AI would need a delegated
+ * person principal to authorize them.
+ */
+function actionsFor() {
+ return ['open'];
+}
+
+/** Plain text (no HTML or Markdown), single line, at most `max` characters. */
+function plain(value, max = 400) {
+ let s = value == null ? '' : String(value);
+ s = s.replace(/```[\s\S]*?```/g, ' ').replace(/<\/?[^>]+>/g, ' ')
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
+ .replace(/&([a-zA-Z#0-9]+);/g, (m, k) => (Object.hasOwn(ENTITIES, k) ? ENTITIES[k] : m))
+ .replace(/[`*_~]+/g, '').replace(/^[#>\s-]+/gm, '')
+ .replace(/\s+/g, ' ').trim();
+ if (s.length > max) s = `${s.slice(0, Math.max(0, max - 1)).trimEnd()}…`;
+ return s;
+}
+
+/** The readable outcome a run's output carries, when it carries one. */
+function summaryOf(output) {
+ if (typeof output === 'string') return output;
+ if (!output || typeof output !== 'object' || Array.isArray(output)) return '';
+ for (const key of ['answer', 'text', 'summary', 'content']) if (typeof output[key] === 'string') return output[key];
+ return '';
+}
+
+function runPage(id) {
+ return `${PUBLIC_ORIGIN}/runs/${encodeURIComponent(id)}`;
+}
+
+function toItem(run, workflow = null) {
+ const bucket = bucketFor(run.status);
+ const detail = run.error ? (run.error.detail || run.error.code || '') : '';
+ const summary = detail || summaryOf(run.output);
+ return {
+ service: 'ai',
+ id: run.id,
+ kind: 'ai_run',
+ title: plain((workflow && workflow.name) || (run.workflow && run.workflow.key) || run.id, 160) || run.id,
+ summary: summary ? plain(summary, 400) : null,
+ bucket,
+ state: run.status,
+ agent: run.provenance && run.provenance.model ? plain(run.provenance.model, 80) : null,
+ cost_usd: run.usage && Number.isFinite(Number(run.usage.cost_usd)) ? Number(run.usage.cost_usd) : null,
+ steps: null, // AI keeps token/attempt counts, not tool-call steps
+ error: detail ? plain(detail, 400) : null,
+ created_at: run.created_at,
+ updated_at: run.finished_at || run.started_at || run.created_at,
+ href: runPage(run.id),
+ actions: actionsFor(bucket),
+ };
+}
+
+module.exports = { PUBLIC_ORIGIN, bucketFor, actionsFor, plain, summaryOf, runPage, toItem };
new file mode 100644
new file mode 100644
@@ -0,0 +1,39 @@
@@ -0,0 +1,39 @@
+'use strict';
+
+/**
+ * The Action Center read (plan 2026-10-28 §3.10): GET /api/v1/actions?subject=usr_…
+ *
+ * A Network service token for audience openvibe.ai holding ai.actions.read names one person and gets the runs whose
+ * on_behalf_of is that user, as common.action-list-result@1: everything queued/running, then done and failed runs from
+ * the last 30 days, newest updated first, at most 50. Never a run made for anyone else; a person's token, a token
+ * without the capability and a subject that is not a usr_… id are refused.
+ */
+const express = require('express');
+const { AiError, sendError } = require('../util');
+const { CAPS } = require('../auth');
+const items = require('../actions/items');
+
+const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+const DAY = 86_400_000;
+
+function actionsRouter({ runs, registry, auth, log = console }) {
+ const r = express.Router();
+ r.get('/api/v1/actions', auth.requireCap(CAPS.actionsRead), async (req, res) => {
+ try {
+ const subject = String(req.query.subject || '').trim();
+ if (!USER.test(subject)) throw new AiError(400, 'input.invalid', 'subject must be a usr_… subject id');
+ const list = await runs.listOnBehalfOf(subject, { since: Date.now() - 30 * DAY, limit: 50 });
+ const workflows = new Map();
+ const out = [];
+ for (const run of list) {
+ const key = `${run.workflow.key}@${run.workflow.version}`;
+ if (!workflows.has(key)) workflows.set(key, await registry.getWorkflow(run.workflow.key, run.workflow.version));
+ out.push(items.toItem(run, workflows.get(key)));
+ }
+ res.json({ items: out });
+ } catch (err) { sendError(res, err, req.ov, log); }
+ });
+ return r;
+}
+
+module.exports = { actionsRouter };
@@ -7,10 +7,13 @@ const { instrument } = require('openvibe-shared/metrics');
@@ -7,10 +7,13 @@ const { instrument } = require('openvibe-shared/metrics');
const { createRelease } = require('openvibe-shared/release');
const { createRelease } = require('openvibe-shared/release');
const { createAiReadiness, registerAiGauges } = require('./observability');
const { createAiReadiness, registerAiGauges } = require('./observability');
const { runsRouter } = require('./api/runs');
const { runsRouter } = require('./api/runs');
+const { actionsRouter } = require('./api/actions');
const { sendError } = require('./util');
const { sendError } = require('./util');
const { adminRouter } = require('./api/admin');
const { adminRouter } = require('./api/admin');
const { createOps } = require('./ops');
const { createOps } = require('./ops');
const { consoleRouter } = require('./console');
const { consoleRouter } = require('./console');
+const { createPersonSessions } = require('./person-session');
+const { runPageRouter } = require('./run-pages');
const views = require('./console/views');
const views = require('./console/views');
const cachePolicy = require('openvibe-shared/cache-policy');
const cachePolicy = require('openvibe-shared/cache-policy');
const ovServe = require('openvibe-shared/serve');
const ovServe = require('openvibe-shared/serve');
@@ -87,13 +90,17 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
@@ -87,13 +90,17 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
// Operator actions shared by the admin API and the operator console (one implementation, one audit row).
// Operator actions shared by the admin API and the operator console (one implementation, one audit row).
const ops = createOps({ db, registry, pool, quotas, cache, runs });
const ops = createOps({ db, registry, pool, quotas, cache, runs });
app.use(runsRouter({ runs, registry, auth, config, log }));
app.use(runsRouter({ runs, registry, auth, config, log }));
+ app.use(actionsRouter({ runs, registry, auth, log }));
// The OpenAI-compatible endpoint (POST /v1/chat/completions, GET /v1/models): runs of ai.complete.
// The OpenAI-compatible endpoint (POST /v1/chat/completions, GET /v1/models): runs of ai.complete.
app.use(require('./api/openai').openaiRouter({ runs, auth, config, log }));
app.use(require('./api/openai').openaiRouter({ runs, auth, config, log }));
app.use(require('./api/attribution-quotas').attributionQuotasRouter({ db, quotas, auth, clock, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));
app.use(require('./api/attribution-quotas').attributionQuotasRouter({ db, quotas, auth, clock, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));
if (credentials) app.use(require('./credentials').credentialsRouter({ credentials, auth, registry, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));
if (credentials) app.use(require('./credentials').credentialsRouter({ credentials, auth, registry, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));
app.use(adminRouter({ db, registry, pool, quotas, cache, runs, auth, ops, log }));
app.use(adminRouter({ db, registry, pool, quotas, cache, runs, auth, ops, log }));
// The operator console (/console, /auth/*): Network staff, server-rendered, no scripts (server/console).
// The operator console (/console, /auth/*): Network staff, server-rendered, no scripts (server/console).
- app.use(consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, env, clock, fetchImpl, log, vault }));
+ // Person sessions are separate, read-only and open one run receipt each (server/person-session.js).
+ const personSessions = createPersonSessions({ db, config, clock });
+ app.use(consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, env, clock, fetchImpl, log, vault, personSessions }));
+ app.use(runPageRouter({ runs, personSessions, log }));
// Public price and latency (no sign-in, no scripts): provider_stats_daily totals and the rate cards,
// Public price and latency (no sign-in, no scripts): provider_stats_daily totals and the rate cards,
// nothing per caller. Only registered providers are listed: a person's own key records its runs as
// nothing per caller. Only registered providers are listed: a person's own key records its runs as
@@ -136,7 +143,7 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
@@ -136,7 +143,7 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
// Discovery for the public home (ai.openvibe.services): the home and the price page are for search engines
// Discovery for the public home (ai.openvibe.services): the home and the price page are for search engines
// and assistants; the console, its sign-in and the API are not.
// and assistants; the console, its sign-in and the API are not.
app.get('/robots.txt', (_req, res) => {
app.get('/robots.txt', (_req, res) => {
- res.type('text/plain').send(`User-agent: *\nAllow: /$\nAllow: /stats\nAllow: /updates\nDisallow: /console\nDisallow: /auth/\nDisallow: /api/\n\nSitemap: ${config.baseUrl}/sitemap.xml\n`);
+ res.type('text/plain').send(`User-agent: *\nAllow: /$\nAllow: /stats\nAllow: /updates\nDisallow: /console\nDisallow: /runs\nDisallow: /auth/\nDisallow: /api/\n\nSitemap: ${config.baseUrl}/sitemap.xml\n`);
});
});
app.get('/sitemap.xml', async (_req, res, next) => {
app.get('/sitemap.xml', async (_req, res, next) => {
try {
try {
@@ -179,6 +186,7 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
@@ -179,6 +186,7 @@ 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 /stats price and latency per provider and model (public)',
'GET /stats price and latency per provider and model (public)',
+ 'GET /runs/:id your run receipt (OpenVibe.Network sign-in; your own runs only)',
'GET /console the operator console (OpenVibe.Network staff sign-in)',
'GET /console the operator console (OpenVibe.Network staff sign-in)',
'',
'',
'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',
@@ -34,6 +34,7 @@ const CAPS = Object.freeze({
@@ -34,6 +34,7 @@ const CAPS = Object.freeze({
credentialManage: 'ai.credential.manage',
credentialManage: 'ai.credential.manage',
quotaAttributionManage: 'ai.quota.attribution.manage',
quotaAttributionManage: 'ai.quota.attribution.manage',
appRun: 'ai.app.run',
appRun: 'ai.app.run',
+ actionsRead: 'ai.actions.read',
});
});
/**
/**
@@ -73,6 +73,8 @@ function load(env = process.env) {
@@ -73,6 +73,8 @@ function load(env = process.env) {
// Signs the sign-in flow cookie and keys the client-address hashes: 32+ random characters.
// Signs the sign-in flow cookie and keys the client-address hashes: 32+ random characters.
sessionSecretRef: 'env:AI_CONSOLE_SESSION_SECRET',
sessionSecretRef: 'env:AI_CONSOLE_SESSION_SECRET',
sessionTtlMin: Math.max(5, Math.min(12 * 60, int(env.AI_CONSOLE_SESSION_TTL_MIN, 60))),
sessionTtlMin: Math.max(5, Math.min(12 * 60, int(env.AI_CONSOLE_SESSION_TTL_MIN, 60))),
+ // A person's session for their run receipt (GET /runs/:id): a longer, read-only session.
+ personSessionTtlMin: Math.max(5, Math.min(7 * 24 * 60, int(env.AI_PERSON_SESSION_TTL_MIN, 24 * 60))),
redirectUri: `${baseUrl}/auth/callback`,
redirectUri: `${baseUrl}/auth/callback`,
// Secure (and __Host-) cookies whenever the console is served over https (always in production).
// Secure (and __Host-) cookies whenever the console is served over https (always in production).
cookieSecure: baseUrl.startsWith('https://'),
cookieSecure: baseUrl.startsWith('https://'),
@@ -4,6 +4,7 @@
@@ -4,6 +4,7 @@
* ai.openvibe.services under /console, for OpenVibe.Network staff.
* 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/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
* POST /auth/logout
* GET /console overview: providers, runs and failures (24 h), spend, queue, quotas
* GET /console overview: providers, runs and failures (24 h), spend, queue, quotas
* GET /console/providers providers (secret reference names + presence only) and models
* GET /console/providers providers (secret reference names + presence only) and models
@@ -75,6 +76,13 @@ function sanitizeNext(v) {
@@ -75,6 +76,13 @@ function sanitizeNext(v) {
return s;
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 NOTICES = {
const NOTICES = {
provider_status: 'Provider status changed.',
provider_status: 'Provider status changed.',
provider_added: 'Provider added. Point a route at it below, or on the route\'s page.',
provider_added: 'Provider added. Point a route at it below, or on the route\'s page.',
@@ -95,7 +103,7 @@ const DEF_KINDS = {
@@ -95,7 +103,7 @@ const DEF_KINDS = {
};
};
const DAY_MS = 86400000;
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 }) {
+function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, vault = null, personSessions = null }) {
const cc = config.console;
const cc = config.console;
const r = express.Router();
const r = express.Router();
const scoped = ['/console', '/auth'];
const scoped = ['/console', '/auth'];
@@ -208,20 +216,47 @@ function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, e
@@ -208,20 +216,47 @@ function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, e
res.redirect(302, sso.authorizeUrl(config, { state, challenge }));
res.redirect(302, sso.authorizeUrl(config, { state, challenge }));
});
});
+ // 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 state = random(32);
+ const { verifier, challenge } = sso.pkcePair();
+ sessions.setFlow(res, { s: state, v: verifier, n: sanitizeRunNext(req.query.next), kind: 'person' });
+ res.redirect(302, sso.authorizeUrl(config, { state, challenge }));
+ });
+
r.get('/auth/callback', wrap(async (req, res) => {
r.get('/auth/callback', wrap(async (req, res) => {
const flow = sessions.takeFlow(req, res);
const flow = sessions.takeFlow(req, res);
- if (req.query.error) return await send(res, 400, pages.signIn({ message: `Sign-in was not completed (${String(req.query.error).slice(0, 60)}).` }));
+ 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)) {
if (!flow || !req.query.code || !sameString(req.query.state, flow.s)) {
- return await send(res, 400, pages.signIn({ message: 'The sign-in could not be verified (expired or mismatched state). Please try again.' }));
+ 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 }));
}
}
let person;
let person;
try {
try {
person = await sso.exchange({ config, clientSecret: clientSecret(), code: req.query.code, verifier: flow.v, now: clock.now(), fetchImpl, log });
person = await sso.exchange({ config, clientSecret: clientSecret(), code: req.query.code, verifier: flow.v, now: clock.now(), fetchImpl, log });
} catch (e) {
} catch (e) {
log.warn(`[console] sign-in failed: ${e.message}`);
log.warn(`[console] sign-in failed: ${e.message}`);
- return await send(res, e.status && e.status < 500 ? 400 : 502, pages.signIn({ message: 'OpenVibe.Network did not confirm the sign-in. Please try again.' }));
+ 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);
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 || '/');
+ }
if (person.error) {
if (person.error) {
await audit('anonymous', 'console.sign_in.refused', 'console', 'sign_in', { reason: person.error, ip_hash: ipHash }, req);
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.' }));
return await send(res, 403, pages.message({ title: 'Not authorized', text: 'This console is only for OpenVibe.Network staff. Your sign-in was recorded.' }));
@@ -475,4 +510,4 @@ function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, e
@@ -475,4 +510,4 @@ function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, e
return r;
return r;
}
}
-module.exports = { consoleRouter, consoleCapabilities, sanitizeNext, STAFF_TO_AI };
+module.exports = { consoleRouter, consoleCapabilities, sanitizeNext, sanitizeRunNext, STAFF_TO_AI };
@@ -107,14 +107,19 @@ async function runFacets(db) {
@@ -107,14 +107,19 @@ async function runFacets(db) {
};
};
}
}
-/** A bounded, credential-blanked copy of a stored input/output for display. */
-function preview(value) {
+/**
+ * A bounded, credential-blanked copy of a stored input/output for display. The run receipt asks for
+ * more text than the console's 240 characters; the byte cap still bounds the page.
+ */
+function preview(value, { string = PREVIEW_STRING, bytes = PREVIEW_BYTES } = {}) {
+ const maxString = Math.max(1, Number(string) || PREVIEW_STRING);
+ const maxBytes = Math.max(256, Number(bytes) || PREVIEW_BYTES);
let cut = false;
let cut = false;
const walk = (v, depth) => {
const walk = (v, depth) => {
if (typeof v === 'string') {
if (typeof v === 'string') {
- if (v.length <= PREVIEW_STRING) return v;
+ if (v.length <= maxString) return v;
cut = true;
cut = true;
- return `${v.slice(0, PREVIEW_STRING)}… (${v.length - PREVIEW_STRING} more characters)`;
+ return `${v.slice(0, maxString)}… (${v.length - maxString} more characters)`;
}
}
if (v == null || typeof v !== 'object') return v;
if (v == null || typeof v !== 'object') return v;
if (depth >= PREVIEW_DEPTH) { cut = true; return '…'; }
if (depth >= PREVIEW_DEPTH) { cut = true; return '…'; }
@@ -131,7 +136,7 @@ function preview(value) {
@@ -131,7 +136,7 @@ function preview(value) {
};
};
if (value === null || value === undefined) return { text: null, cut: false };
if (value === null || value === undefined) return { text: null, cut: false };
let text = JSON.stringify(walk(value, 0), null, 2);
let text = JSON.stringify(walk(value, 0), null, 2);
- if (text.length > PREVIEW_BYTES) { text = `${text.slice(0, PREVIEW_BYTES)}\n… (truncated)`; cut = true; }
+ if (text.length > maxBytes) { text = `${text.slice(0, maxBytes)}\n… (truncated)`; cut = true; }
return { text, cut };
return { text, cut };
}
}
@@ -152,8 +152,8 @@ function signIn({ next, message }) {
@@ -152,8 +152,8 @@ function signIn({ next, message }) {
});
});
}
}
-function message({ title, text, staff, csrf, back = '/console' }) {
- return layout({ title, staff, csrf, body: html`<h1>${title}</h1><div class="card"><p>${text}</p><p><a href="${back}">Back</a></p></div>` });
+function message({ title, text, staff, csrf, back = '/console', site = 'operator console' }) {
+ return layout({ title, staff, csrf, site, body: html`<h1>${title}</h1><div class="card"><p>${text}</p><p><a href="${back}">Back</a></p></div>` });
}
}
const CAP_LABELS = {
const CAP_LABELS = {
@@ -381,7 +381,7 @@ const ref = (x) => (x ? (x.service ? `${x.service}:${x.type}:${x.id}` : `${x.typ
@@ -381,7 +381,7 @@ const ref = (x) => (x ? (x.service ? `${x.service}:${x.type}:${x.id}` : `${x.typ
function run({ staff, csrf, r, citations, requests, input, output, canCancel, notice, error }) {
function run({ staff, csrf, r, citations, requests, input, output, canCancel, notice, error }) {
const g = r.grounding;
const g = r.grounding;
const sampleIds = r.usage.usage_sample_ids || [];
const sampleIds = r.usage.usage_sample_ids || [];
- const open = r.status === 'queued' || r.status === 'running';
+ const open = r.status === 'queued' || r.status === 'running' || r.status === 'waiting';
return layout({
return layout({
title: `Run ${r.id}`, section: 'runs', staff, csrf, notice, error, body: html`<h1>Run <span class="mono">${r.id}</span> ${badge(r.status)}</h1>
title: `Run ${r.id}`, section: 'runs', staff, csrf, notice, error, body: html`<h1>Run <span class="mono">${r.id}</span> ${badge(r.status)}</h1>
<div class="cols"><div class="card"><dl>
<div class="cols"><div class="card"><dl>
@@ -534,6 +534,42 @@ ${table('Audit rows, newest first', ['#', 'At', 'Actor', 'Action', 'Target', 'De
@@ -534,6 +534,42 @@ ${table('Audit rows, newest first', ['#', 'At', 'Actor', 'Action', 'Target', 'De
});
});
}
}
+// ── Person: one run (GET /runs/:id, signed in) ───────────────
+/**
+ * A person's receipt for one run: status, workflow, model, cost, times, and the output or the error.
+ * It never shows the input, the request log, the trace or anyone else's run. The console's classes
+ * and CSS are reused; no script, no form and no external resource.
+ */
+function runReceipt({ viewer, r, output, outputCut }) {
+ const open = r.status === 'queued' || r.status === 'running' || r.status === 'waiting';
+ const provider = r.provenance && r.provenance.provider;
+ const model = r.provenance && r.provenance.model;
+ const usage = r.usage || {};
+ const cost = usage.cost_usd == null ? '—' : usd(usage.cost_usd);
+ return layout({
+ title: `Run ${r.id}`, site: 'your runs', body: html`<h1>Run <span class="mono">${r.id}</span> ${badge(r.status)}</h1>
+${viewer && viewer.username ? html`<p class="muted">Signed in as @${viewer.username}.</p>` : ''}
+${open ? html`<div class="banner" role="status"><strong>${r.status === 'queued' ? 'Waiting to start' : r.status === 'waiting' ? 'Waiting for you' : 'Running'}</strong> — reload this page for the latest.</div>` : ''}
+${r.error ? html`<div class="banner error" role="alert"><strong>${r.error.code || 'The run failed'}</strong><br>${clip(r.error.detail, 1000)}</div>` : ''}
+<div class="cols"><div class="card"><dl>
+<dt>Status</dt><dd>${badge(r.status)}</dd>
+<dt>Workflow</dt><dd><code>${r.workflow.key}</code> v${r.workflow.version}</dd>
+<dt>Model</dt><dd>${provider || model ? html`${provider ? html`<code>${provider}</code>` : '—'}${model ? html` / <code>${model}</code>` : ''}` : '—'}</dd>
+<dt>Cost</dt><dd>${cost}</dd>
+<dt>Created</dt><dd>${when(r.created_at)}</dd>
+<dt>Started</dt><dd>${when(r.started_at)}</dd>
+<dt>Finished</dt><dd>${when(r.finished_at)}</dd>
+${r.created_at && r.finished_at ? html`<dt>Duration</dt><dd>${span(r.created_at, r.finished_at)}</dd>` : ''}
+${r.provenance && r.provenance.cached_from ? html`<dt>From cache</dt><dd><a class="mono" href="/runs/${enc(r.provenance.cached_from)}">${r.provenance.cached_from}</a></dd>` : ''}
+${r.retry_of ? html`<dt>Retry of</dt><dd><a class="mono" href="/runs/${enc(r.retry_of)}">${r.retry_of}</a></dd>` : ''}
+</dl>${r.synthetic ? html`<p class="muted mt">Synthetic: produced by the stub provider.</p>` : ''}</div></div>
+<h2>Output</h2>
+${output != null ? html`<pre tabindex="0">${output}</pre>${outputCut ? html`<p class="muted">Shown bounded; the run has more.</p>` : ''}` : html`<div class="card muted"><p class="m0">${r.error ? 'No output: the run failed.' : open ? 'No output yet.' : 'This run has no output.'}</p></div>`}
+<p class="muted">This page shows only your own run. Its input is not shown.</p>
+<p><a href="/">OpenVibe.AI</a></p>`,
+ });
+}
+
// ── Public: price and latency (GET /stats, no sign-in) ───────
// ── Public: price and latency (GET /stats, no sign-in) ───────
const ms = (v) => (v == null ? '—' : `${n(v)} ms`);
const ms = (v) => (v == null ? '—' : `${n(v)} ms`);
function rate(ok, total) {
function rate(ok, total) {
@@ -571,4 +607,4 @@ ${table(`Providers and models, ${from} to ${to}`, ['Provider', 'Model', 'Capabil
@@ -571,4 +607,4 @@ ${table(`Providers and models, ${from} to ${to}`, ['Provider', 'Model', 'Capabil
});
});
}
}
-module.exports = { CSP, html, esc, safeUrl, pages: { signIn, message, overview, providers, providerNew, definitions, definition, runs, run, quotas, usage, cache, audit, stats } };
+module.exports = { CSP, html, esc, safeUrl, pages: { signIn, message, overview, providers, providerNew, definitions, definition, runs, run, runReceipt, quotas, usage, cache, audit, stats } };
new file mode 100644
new file mode 100644
@@ -0,0 +1,62 @@
@@ -0,0 +1,62 @@
+'use strict';
+/**
+ * Person sessions for the run receipt page (GET /runs/:id): a random 256-bit cookie id, stored as its
+ * SHA-256; like the console's sessions, the cookie itself is never written down. A person session is
+ * read-only and only opens runs whose on_behalf_of is its subject; it grants no capability and never
+ * opens the operator console.
+ *
+ * cookie `__Host-ovai_person` (plain `ovai_person` over http in development): host-only, Path=/,
+ * HttpOnly, SameSite=Lax, Secure when BASE_URL is https, Max-Age = AI_PERSON_SESSION_TTL_MIN.
+ */
+const crypto = require('crypto');
+const { iso } = require('./util');
+const { parseCookies } = require('./console/session');
+
+const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
+const random = (bytes = 32) => crypto.randomBytes(bytes).toString('base64url');
+
+function createPersonSessions({ db, config, clock = { now: () => Date.now() } }) {
+ const cc = config.console;
+ const now = () => clock.now();
+ const cookieSecure = Boolean(cc.cookieSecure);
+ const sessionCookie = cookieSecure ? '__Host-ovai_person' : 'ovai_person';
+ const ttlMs = Math.max(5, Number(cc.personSessionTtlMin) || 24 * 60) * 60 * 1000;
+
+ function appendCookie(res, value) {
+ const prev = res.getHeader('Set-Cookie');
+ res.setHeader('Set-Cookie', prev ? [].concat(prev, value) : [value]);
+ }
+ function cookieHeader(value, maxAgeSec) {
+ const parts = [`${sessionCookie}=${value}`, 'Path=/', 'HttpOnly', 'SameSite=Lax'];
+ if (cookieSecure) parts.push('Secure');
+ parts.push(`Max-Age=${Math.max(0, Math.floor(maxAgeSec))}`);
+ return parts.join('; ');
+ }
+
+ async function create(res, { subject, username }) {
+ const t = now();
+ await db.prepare('DELETE FROM person_sessions WHERE expires_at < ?').run(iso(t - 24 * 3600 * 1000));
+ const id = random(32);
+ await db.prepare(`INSERT INTO person_sessions (id_hash, subject, username, created_at, expires_at)
+ VALUES (?, ?, ?, ?, ?)`).run(sha256(id), subject, username || null, iso(t), iso(t + ttlMs));
+ appendCookie(res, cookieHeader(id, ttlMs / 1000));
+ return id;
+ }
+
+ /** The live person session of this request, or null (unknown, expired or revoked). */
+ async function read(req) {
+ const id = parseCookies(req)[sessionCookie];
+ if (!id || !/^[A-Za-z0-9_-]{43}$/.test(id)) return null;
+ const row = await db.prepare('SELECT * FROM person_sessions WHERE id_hash = ?').get(sha256(id));
+ if (!row || row.revoked_at || Date.parse(row.expires_at) <= now()) return null;
+ return { subject: row.subject, username: row.username, session: row };
+ }
+
+ function clear(res) {
+ appendCookie(res, cookieHeader('', 0));
+ }
+
+ return { create, read, clear, sessionCookie, ttlMs };
+}
+
+module.exports = { createPersonSessions, sha256, random };
new file mode 100644
new file mode 100644
@@ -0,0 +1,55 @@
@@ -0,0 +1,55 @@
+'use strict';
+/**
+ * A person's run receipt (plan 2026-10-28 §3.10): GET /runs/:id.
+ *
+ * The Action Center's AI item links here. A person signs in with OpenVibe.Network (GET
+ * /auth/person/login, the same OAuth client and callback as the console), and the page shows one run
+ * only when the run's on_behalf_of is that person. It never renders the input, the request log, the
+ * trace or anyone else's run, and it is read-only: no script, no form, no external resource.
+ */
+const express = require('express');
+const q = require('./console/queries');
+const { CSP, pages } = require('./console/views');
+
+const RUN_ID = /^run_[0-9A-HJKMNP-TV-Z]{26}$/;
+
+function runPageRouter({ runs, personSessions, log = console }) {
+ const r = express.Router();
+
+ function headers(res) {
+ 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');
+ }
+ function send(res, status, text) {
+ headers(res);
+ return res.status(status).type('html').send(text);
+ }
+ const missing = (res) => send(res, 404, pages.message({ title: 'Run not found', text: 'There is no run here that belongs to you.', back: '/', site: 'your runs' }));
+ const unavailable = (res) => send(res, 503, pages.message({ title: 'Run page unavailable', text: 'The run page sign-in is not configured on this host.', back: '/', site: 'your runs' }));
+
+ r.get('/runs/:id', async (req, res) => {
+ try {
+ const id = String(req.params.id);
+ if (!RUN_ID.test(id)) return missing(res);
+ if (!personSessions) return unavailable(res);
+ const viewer = await personSessions.read(req);
+ if (!viewer) return res.redirect(302, `/auth/person/login?next=${encodeURIComponent(`/runs/${id}`)}`);
+ const run = await runs.getReceipt(id);
+ const person = run && run.on_behalf_of;
+ if (!run || !person || person.type !== 'user' || person.id !== viewer.subject) return missing(res);
+ const output = q.preview(run.output, { string: 4000, bytes: 20000 });
+ return send(res, 200, pages.runReceipt({ viewer, r: run, output: output.text, outputCut: output.cut }));
+ } catch (err) {
+ log.error(`[run-page] ${req.method} ${req.path}: ${(err && err.stack) || err}`);
+ if (res.headersSent) return res.end();
+ return send(res, 500, pages.message({ title: 'Run page error', text: 'The run could not be read. Try again in a moment.', back: '/', site: 'your runs' }));
+ }
+ });
+
+ return r;
+}
+
+module.exports = { runPageRouter, RUN_ID };
@@ -509,6 +509,33 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -509,6 +509,33 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
return (await db.prepare(`SELECT * FROM runs ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY id DESC LIMIT ?`).all(...args, Math.min(200, Number(limit) || 50))).map(decode);
return (await db.prepare(`SELECT * FROM runs ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY id DESC LIMIT ?`).all(...args, Math.min(200, Number(limit) || 50))).map(decode);
}
}
+ // The columns a person's Action Center item and run receipt need. Input, its hash, the trace and the
+ // request/idempotency identifiers are deliberately absent: another person's input is never even read.
+ const PERSON_COLUMNS = `id, workflow_key, workflow_version, status, on_behalf_of, output, error_code, error_detail,
+ provider_key, model_key, cost_usd, tokens_in, tokens_out, attempts, created_at, started_at, finished_at,
+ synthetic, cached_from, retry_of, citations_count`;
+
+ /**
+ * A person's runs for the Action Center: on_behalf_of is that user. Everything still queued/running, then the
+ * terminal ones updated since `since`; newest updated first, at most limit (50 by the route).
+ */
+ async function listOnBehalfOf(subject, { since = null, limit = 50 } = {}) {
+ const where = ["on_behalf_of IS NOT NULL", "on_behalf_of::jsonb->>'type' = 'user'", "on_behalf_of::jsonb->>'id' = ?"];
+ const args = [subject];
+ if (since != null) {
+ where.push("(status IN ('queued', 'running', 'waiting') OR COALESCE(finished_at, started_at, created_at) >= ?)");
+ args.push(iso(since));
+ }
+ const rows = await db.prepare(`SELECT ${PERSON_COLUMNS} FROM runs WHERE ${where.join(' AND ')}
+ ORDER BY COALESCE(finished_at, started_at, created_at) DESC LIMIT ?`).all(...args, Math.min(50, Number(limit) || 50));
+ return rows.map(decode);
+ }
+
+ /** One run for its person's receipt page, without its input. The caller checks on_behalf_of. */
+ async function getReceipt(id) {
+ return decode(await db.prepare(`SELECT ${PERSON_COLUMNS} FROM runs WHERE id = ?`).get(id));
+ }
+
/** The run with its Billing readings in explain.usage_readings (server/usage-samples.js readingsFor); unchanged when it has none. */
/** The run with its Billing readings in explain.usage_readings (server/usage-samples.js readingsFor); unchanged when it has none. */
async function explained(run) {
async function explained(run) {
const ids = run && run.usage ? run.usage.usage_sample_ids : null;
const ids = run && run.usage ? run.usage.usage_sample_ids : null;
@@ -553,7 +580,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -553,7 +580,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
/** Queue depth for /metrics and /api/ready: runs waiting for a slot, and runs holding one. */
/** Queue depth for /metrics and /api/ready: runs waiting for a slot, and runs holding one. */
function stats() { return { queued: queue.length, running: active, inflight: inflight.size, max_concurrent: config.runs.maxConcurrent, max_queued: config.runs.maxQueued }; }
function stats() { return { queued: queue.length, running: active, inflight: inflight.size, max_concurrent: config.runs.maxConcurrent, max_queued: config.runs.maxQueued }; }
- return { create, wait, get, list, cancel, retry, citations, addCitations, requestsFor, explained, recoverInterrupted, prune, drain, stats, inflight, TERMINAL, decode };
+ return { create, wait, get, getReceipt, list, listOnBehalfOf, cancel, retry, citations, addCitations, requestsFor, explained, recoverInterrupted, prune, drain, stats, inflight, TERMINAL, decode };
}
}
module.exports = { createRuns, entityKey, subjectKey };
module.exports = { createRuns, entityKey, subjectKey };
new file mode 100644
new file mode 100644
@@ -0,0 +1,121 @@
@@ -0,0 +1,121 @@
+'use strict';
+/**
+ * AI's Action Center read (plan 2026-10-28 §3.10, common.action-list-result@1): a first-party service with
+ * ai.actions.read names one person and gets the runs made on their behalf (on_behalf_of), never anyone else's —
+ * running first-class, done and failed from the last 30 days only, newest updated first, at most 50, plain text,
+ * https links and the right actions. A person's token, a token without the capability, an app token and a bad subject
+ * are refused.
+ */
+const assert = require('assert');
+const { ids, validate } = require('openvibe-contracts');
+const { boot, request, token, suite } = require('./helpers');
+const items = require('../server/actions/items');
+
+const DAY = 86_400_000;
+const t = suite('actions');
+let h;
+let wf;
+let ada;
+let bo;
+
+const iso = (ms) => new Date(ms).toISOString();
+
+async function addRun({ id = `run_${ids.ulid()}`, subject = null, status = 'queued', created = Date.now(), started = null, finished = null,
+ output = null, errorCode = null, errorDetail = null, cost = 0, model = null, requester = 'network', onBehalf = undefined }) {
+ const obo = onBehalf === undefined ? (subject ? JSON.stringify({ type: 'user', id: subject }) : null) : onBehalf;
+ await h.db.prepare(`INSERT INTO runs (id, workflow_key, workflow_version, status, requester_type, requester_id, on_behalf_of,
+ input, input_hash, output, error_code, error_detail, cost_usd, provider_key, model_key, created_at, started_at, finished_at)
+ VALUES (?, ?, ?, ?, 'service', ?, ?, '{}', 'x', ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
+ .run(id, wf.key, wf.version, status, requester, obo, output == null ? null : JSON.stringify(output), errorCode, errorDetail, cost, model ? 'deepseek' : null, model, iso(created), started ? iso(started) : null, finished ? iso(finished) : null);
+ return id;
+}
+
+t.test('boot with the seeded ai.generate workflow', async () => {
+ h = await boot();
+ wf = await h.registry.getWorkflow('ai.generate');
+ ada = ids.newId('user');
+ bo = ids.newId('user');
+});
+
+t.test('the state → bucket table and the actions each bucket allows', async () => {
+ const table = [['queued', 'running'], ['running', 'running'], ['waiting', 'waiting'], ['succeeded', 'done'], ['cached', 'done'], ['failed', 'failed'], ['cancelled', 'failed']];
+ for (const [status, bucket] of table) assert.strictEqual(items.bucketFor(status), bucket, status);
+ assert.deepStrictEqual(items.actionsFor('running'), ['open']);
+ assert.deepStrictEqual(items.actionsFor('failed'), ['open']);
+ assert.deepStrictEqual(items.actionsFor('done'), ['open']);
+ assert.deepStrictEqual(items.actionsFor('waiting'), ['open']);
+ assert.strictEqual(items.plain('<b>All</b> **done** & shipped', 160), 'All done & shipped');
+ assert.ok(items.plain('x'.repeat(500), 400).length <= 400);
+});
+
+t.test('only that person\'s runs, newest first, with the buckets, actions, text and links common.action-item@1 allows', async () => {
+ const now = Date.now();
+ const queuedOld = await addRun({ subject: ada, status: 'queued', created: now - 40 * DAY });
+ const running = await addRun({ subject: ada, status: 'running', created: now - 3 * 3600_000, started: now - 2 * 3600_000, model: 'deepseek-flash' });
+ const done = await addRun({ subject: ada, status: 'succeeded', created: now - 3 * DAY, started: now - 2 * DAY, finished: now - DAY, cost: 0.0012, model: 'deepseek-flash', output: { text: '<b>All **done**</b> & shipped.' } });
+ const cached = await addRun({ subject: ada, status: 'cached', created: now - 4 * DAY, finished: now - 2 * DAY, cost: 0, output: { text: 'cached answer' } });
+ const failed = await addRun({ subject: ada, status: 'failed', created: now - 5 * DAY, finished: now - 3 * DAY, errorCode: 'run.provider_error', errorDetail: '<script>it broke</script>' });
+ const cancelled = await addRun({ subject: ada, status: 'cancelled', created: now - 6 * DAY, finished: now - 4 * DAY, errorCode: 'run.cancelled', errorDetail: 'cancelled by caller' });
+ const old = await addRun({ subject: ada, status: 'succeeded', created: now - 45 * DAY, finished: now - 40 * DAY, output: { text: 'ancient' } });
+ const theirs = await addRun({ subject: bo, status: 'succeeded', created: now - 60_000, finished: now - 30_000, output: { text: 'bo' } });
+ const notPersonal = await addRun({ status: 'running', created: now - 30_000, onBehalf: JSON.stringify({ type: 'app', id: 'app_01J8Z3Q4R5S6T7V8W9X0Y1Z2A1' }) });
+ const otherRequester = await addRun({ subject: ada, requester: 'actor', status: 'running', created: now - 15_000, model: 'deepseek-flash' });
+
+ const r = await request(h.base, 'GET', `/api/v1/actions?subject=${ada}`, { tok: token('network', ['ai.actions.read']) });
+ assert.strictEqual(r.status, 200, r.text);
+ const v = validate('common.action-list-result@1', r.body);
+ assert.ok(v.valid, JSON.stringify(v.errors));
+ const byId = new Map(r.body.items.map((i) => [i.id, i]));
+ assert.deepStrictEqual([byId.get(queuedOld).bucket, byId.get(queuedOld).actions], ['running', ['open']], 'an old queued run still runs');
+ assert.deepStrictEqual([byId.get(running).bucket, byId.get(running).actions], ['running', ['open']]);
+ assert.deepStrictEqual([byId.get(done).bucket, byId.get(done).cost_usd, byId.get(done).steps, byId.get(done).actions], ['done', 0.0012, null, ['open']]);
+ assert.strictEqual(byId.get(done).title, 'Generate text');
+ assert.strictEqual(byId.get(done).summary, 'All done & shipped.');
+ assert.strictEqual(byId.get(done).agent, 'deepseek-flash');
+ assert.strictEqual(byId.get(cached).bucket, 'done');
+ assert.deepStrictEqual([byId.get(failed).bucket, byId.get(failed).error, byId.get(failed).actions], ['failed', 'it broke', ['open']]);
+ assert.deepStrictEqual([byId.get(cancelled).bucket, byId.get(cancelled).actions], ['failed', ['open']]);
+ assert.ok(byId.has(otherRequester), 'the run is the person\'s though another service asked for it');
+ assert.ok(!byId.has(old), 'done and failed older than 30 days are gone');
+ assert.ok(!byId.has(theirs) && !byId.has(notPersonal), 'never another person\'s or a project\'s run');
+ for (const it of r.body.items) {
+ assert.strictEqual(it.href, `https://ai.openvibe.services/runs/${it.id}`);
+ assert.ok(it.title.length <= 160 && (!it.summary || it.summary.length <= 400) && (!it.error || it.error.length <= 400));
+ assert.ok(!/[<>*_`]/.test(it.title) && !/[<>*_`]/.test(it.summary || '') && !/[<>*_`]/.test(it.error || ''));
+ }
+ const times = r.body.items.map((i) => i.updated_at);
+ assert.deepStrictEqual(times, [...times].sort().reverse(), 'newest updated first');
+});
+
+t.test('at most 50 items, newest first', async () => {
+ const now = Date.now();
+ const mara = ids.newId('user');
+ const newest = `run_${ids.ulid()}`;
+ for (let i = 0; i < 55; i++) {
+ await addRun({ id: i === 0 ? newest : `run_${ids.ulid()}`, subject: mara, status: 'running', created: now - i * 60_000 });
+ }
+ const r = await request(h.base, 'GET', `/api/v1/actions?subject=${mara}`, { tok: token('network', ['ai.actions.read']) });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.body.items.length, 50);
+ assert.strictEqual(r.body.items[0].id, newest);
+});
+
+t.test('a person\'s token, a service without the capability, an app token and a bad subject are refused', async () => {
+ const person = await request(h.base, 'GET', `/api/v1/actions?subject=${ada}`, { tok: token('x', ['ai.actions.read'], { sub: `user:${ada}` }) });
+ assert.strictEqual(person.status, 401);
+ const noCap = await request(h.base, 'GET', `/api/v1/actions?subject=${ada}`, { tok: token('network', ['ai.run.read']) });
+ assert.strictEqual(noCap.status, 403);
+ assert.strictEqual(noCap.body.code, 'capability.denied');
+ const app = await request(h.base, 'GET', `/api/v1/actions?subject=${ada}`, { tok: token('x', ['ai.actions.read'], { sub: 'app:app_01J8Z3Q4R5S6T7V8W9X0Y1Z2A1', actorType: 'app', extra: { project_id: 'prj_01J8Z3Q4R5S6T7V8W9X0Y1Z2P1', env: 'production' } }) });
+ assert.strictEqual(app.status, 403);
+ const none = await request(h.base, 'GET', `/api/v1/actions?subject=${ada}`);
+ assert.strictEqual(none.status, 401);
+ const bad = await request(h.base, 'GET', '/api/v1/actions?subject=usr_nope', { tok: token('network', ['ai.actions.read']) });
+ assert.strictEqual(bad.status, 400);
+ assert.strictEqual(bad.body.code, 'input.invalid');
+ const missing = await request(h.base, 'GET', '/api/v1/actions', { tok: token('network', ['ai.actions.read']) });
+ assert.strictEqual(missing.status, 400);
+});
+
+t.test('stop the service', async () => { await h.stop(); });
+t.run();
new file mode 100644
new file mode 100644
@@ -0,0 +1,212 @@
@@ -0,0 +1,212 @@
+'use strict';
+/**
+ * The person-facing run receipt (plan 2026-10-28 §3.10, GET /runs/:id): a Network person signs in with
+ * PKCE, and sees one run only when its on_behalf_of is that person — status, workflow, model, cost,
+ * times, and the output or the error, never the input or anyone else's run. The Action Center link
+ * points here. The page is server-rendered, noindex, no-store, script-free and escaped.
+ */
+const assert = require('assert');
+const crypto = require('crypto');
+const { serviceAuth, ids } = require('openvibe-contracts');
+const { boot, request, suite, privateKey, ISSUER } = require('./helpers');
+const { sanitizeRunNext } = require('../server/console');
+const items = require('../server/actions/items');
+
+const t = suite('run-page');
+const NETWORK = 'http://network.test';
+const CLIENT_SECRET = `sentinel-client-${crypto.randomBytes(12).toString('hex')}`;
+const SESSION_SECRET = crypto.randomBytes(32).toString('hex');
+const DAY = 86_400_000;
+const iso = (ms) => new Date(ms).toISOString();
+
+let h;
+let wf;
+let ada;
+let bo;
+let runIds = {};
+
+// ── A fake Network: the token endpoint redeems the code for a person's access token ──
+const codes = new Map();
+function authorize({ subject_id, username = 'someone', expired = false }) {
+ const code = crypto.randomBytes(16).toString('hex');
+ codes.set(code, { subject_id, username, 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,
+ iss: ISSUER, aud: ['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`) {
+ const body = Object.fromEntries(new URLSearchParams(String(opts.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);
+ 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`) return json(200, { revoked: true });
+ return globalThis.fetch(url, opts);
+}
+
+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 signIn(subject, { username = 'ada', next = '/runs/run_01J8Z3Q4R5S6T7V8W9X0Y1Z2A1', expired = false } = {}) {
+ const login = await get(`/auth/person/login?next=${encodeURIComponent(next)}`);
+ assert.strictEqual(login.status, 302, login.text);
+ const loc = new URL(login.headers.get('location'));
+ const flow = login.cookies.find((c) => c.startsWith('ovai_flow='));
+ const code = authorize({ subject_id: subject, username, expired });
+ 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_person=') && !c.startsWith('__Host-ovai_person=;'));
+ return { login, loc, cb, setCookie: sess || null, cookie: sess ? sess.split(';')[0] : null };
+}
+
+async function addRun({ id = `run_${ids.ulid()}`, subject = ada, status = 'succeeded', created = Date.now(), started = null, finished = null,
+ output = null, errorCode = null, errorDetail = null, cost = 0, model = null, provider = null, input = { prompt: 'PRIVATE-INPUT-DO-NOT-SHOW' } }) {
+ await h.db.prepare(`INSERT INTO runs (id, workflow_key, workflow_version, status, requester_type, requester_id, on_behalf_of,
+ input, input_hash, output, error_code, error_detail, cost_usd, provider_key, model_key, created_at, started_at, finished_at)
+ VALUES (?, ?, ?, ?, 'service', 'network', ?, ?, 'x', ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
+ .run(id, wf.key, wf.version, status, JSON.stringify({ type: 'user', id: subject }), JSON.stringify(input), output == null ? null : JSON.stringify(output),
+ errorCode, errorDetail, cost, provider, model, iso(created), started ? iso(started) : null, finished ? iso(finished) : null);
+ return id;
+}
+
+t.test('boot with the seeded ai.generate workflow', async () => {
+ 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_PERSON_SESSION_TTL_MIN: '1440', AI_USER_MODULES: 'off',
+ },
+ fetchImpl: fakeFetch,
+ });
+ wf = await h.registry.getWorkflow('ai.generate');
+ ada = ids.newId('user');
+ bo = ids.newId('user');
+});
+
+t.test('the next is exactly one run page: open-redirect shapes go home', () => {
+ for (const bad of ['//evil.com', '/\\evil.com', '/\t/runs/run_x', 'https://evil.com', '/auth/login', '/console', '/runs/', '/runs/run_nope', '', null]) {
+ assert.strictEqual(sanitizeRunNext(bad), '/', JSON.stringify(bad));
+ }
+ const good = `/runs/run_${ids.ulid()}`;
+ assert.strictEqual(sanitizeRunNext(good), good);
+ const id = `run_${ids.ulid()}`;
+ assert.strictEqual(items.runPage(id), `https://ai.openvibe.services/runs/${id}`);
+});
+
+t.test('anonymous: the page redirects to the person sign-in, then returns to the same run', async () => {
+ const id = await addRun({ status: 'succeeded', created: Date.now() - DAY, finished: Date.now() - 1000 });
+ const r = await get(`/runs/${id}`);
+ assert.strictEqual(r.status, 302);
+ assert.strictEqual(r.headers.get('location'), `/auth/person/login?next=${encodeURIComponent(`/runs/${id}`)}`);
+ assert.strictEqual((await get('/runs/run_nope')).status, 404, 'a malformed id never starts a sign-in');
+});
+
+t.test('sign-in: PKCE S256 as client ai, a host-only person cookie, and back to the run page', async () => {
+ const created = runIds.created = Date.now() - 3 * DAY;
+ const id = runIds.ok = await addRun({ status: 'succeeded', created, started: created + DAY, finished: created + 2 * DAY,
+ cost: 0.0012, provider: 'deepseek', model: 'deepseek-flash', output: { text: 'The answer is <script>alert(1)</script> & done.' } });
+ const r = await signIn(ada, { next: `/runs/${id}` });
+ assert.strictEqual(r.login.status, 302);
+ assert.strictEqual(r.loc.origin + r.loc.pathname, 'https://openvibe.network/oauth/authorize');
+ assert.strictEqual(r.loc.searchParams.get('client_id'), 'ai');
+ assert.strictEqual(r.loc.searchParams.get('redirect_uri'), 'https://ai.test/auth/callback');
+ assert.strictEqual(r.loc.searchParams.get('response_type'), 'code');
+ assert.strictEqual(r.loc.searchParams.get('code_challenge_method'), 'S256');
+ assert.match(r.loc.searchParams.get('code_challenge'), /^[A-Za-z0-9_-]{43}$/);
+ assert.strictEqual(r.cb.status, 303, r.cb.text);
+ assert.strictEqual(r.cb.headers.get('location'), `/runs/${id}`);
+ for (const attr of ['Path=/', 'HttpOnly', 'Secure', 'SameSite=Lax']) 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 <= 7 * 24 * 3600, `max-age ${maxAge}`);
+ const row = await h.db.prepare('SELECT * FROM person_sessions WHERE subject = ?').get(ada);
+ assert.notStrictEqual(row.id_hash, r.cookie.split('=')[1], 'only a hash of the session id is stored');
+ runIds.adaCookie = r.cookie;
+});
+
+t.test('the receipt shows only that person\'s run: status, workflow, model, cost, time, output; never the input', async () => {
+ const r = await get(`/runs/${runIds.ok}`, runIds.adaCookie);
+ assert.strictEqual(r.status, 200, r.text.slice(0, 400));
+ assert.match(r.headers.get('content-type'), /text\/html/);
+ assert.ok(r.text.includes('succeeded') && r.text.includes('ai.generate') && r.text.includes('deepseek-flash'));
+ assert.ok(r.text.includes('$0.001200'), 'the cost');
+ assert.ok(r.text.includes(`<time datetime="${iso(runIds.created)}">`), 'the created time');
+ assert.ok(r.text.includes('<script>alert(1)</script> & done.'), 'the output is escaped');
+ assert.ok(!r.text.includes('<script>alert(1)</script>'), 'no raw output markup');
+ assert.ok(!r.text.includes('PRIVATE-INPUT-DO-NOT-SHOW'), 'the input is never shown');
+ assert.ok(r.text.includes('This page shows only your own run. Its input is not shown.'));
+ assert.ok(!/<script/i.test(r.text), 'no script');
+ assert.ok(r.text.includes('<meta name="robots" content="noindex,nofollow">'));
+ assert.match(r.headers.get('content-security-policy') || '', /default-src 'none'/);
+ assert.strictEqual(r.headers.get('x-robots-tag'), 'noindex, nofollow');
+ assert.strictEqual(r.headers.get('referrer-policy'), 'no-referrer');
+ assert.match(r.headers.get('cache-control') || '', /no-store/);
+ assert.strictEqual((r.text.match(/<h1[ >]/g) || []).length, 1, 'one h1');
+});
+
+t.test('a failed or running run: the error or the live state, with no output yet', async () => {
+ const failed = runIds.failed = await addRun({ status: 'failed', created: Date.now() - 2 * DAY, started: Date.now() - 2 * DAY, finished: Date.now() - DAY,
+ errorCode: 'run.provider_error', errorDetail: '<script>it broke</script>' });
+ let r = await get(`/runs/${failed}`, runIds.adaCookie);
+ assert.strictEqual(r.status, 200);
+ assert.ok(r.text.includes('<code>run.provider_error</code>') || r.text.includes('run.provider_error'));
+ assert.ok(r.text.includes('<script>it broke</script>') && !r.text.includes('<script>it broke</script>'));
+ assert.ok(r.text.includes('No output: the run failed.'));
+
+ const running = await addRun({ status: 'running', created: Date.now() - 3600_000, started: Date.now() - 1800_000, provider: 'deepseek', model: 'deepseek-flash' });
+ r = await get(`/runs/${running}`, runIds.adaCookie);
+ assert.strictEqual(r.status, 200);
+ assert.ok(r.text.includes('Running') && r.text.includes('reload this page'));
+ assert.ok(r.text.includes('No output yet.'));
+});
+
+t.test('another person\'s run is a 404; so is this person\'s run to another person', async () => {
+ const theirs = await addRun({ subject: bo, status: 'succeeded', finished: Date.now() - 1000, output: { text: 'BO-PRIVATE-OUTPUT' } });
+ let r = await get(`/runs/${theirs}`, runIds.adaCookie);
+ assert.strictEqual(r.status, 404);
+ assert.ok(!r.text.includes('BO-PRIVATE-OUTPUT'), 'no other person\'s output');
+ const boSignIn = await signIn(bo, { username: 'bo', next: `/runs/${theirs}` });
+ assert.strictEqual(boSignIn.cb.status, 303);
+ r = await get(`/runs/${theirs}`, boSignIn.cookie);
+ assert.strictEqual(r.status, 200);
+ assert.ok(r.text.includes('BO-PRIVATE-OUTPUT'));
+ assert.strictEqual((await get(`/runs/${runIds.ok}`, boSignIn.cookie)).status, 404);
+});
+
+t.test('a service token or an expired token starts no person session; a bad state is refused', async () => {
+ const noSubject = await signIn(null, { next: '/runs/run_01J8Z3Q4R5S6T7V8W9X0Y1Z2A1' });
+ assert.strictEqual(noSubject.cb.status, 403);
+ assert.strictEqual(noSubject.cookie, null);
+ const expired = await signIn(ada, { expired: true, next: '/runs/run_01J8Z3Q4R5S6T7V8W9X0Y1Z2A1' });
+ assert.ok(expired.cb.status >= 400);
+ assert.strictEqual(expired.cookie, null);
+ const login = await get('/auth/person/login?next=%2Fruns%2Frun_01J8Z3Q4R5S6T7V8W9X0Y1Z2A1');
+ const flow = login.cookies.find((c) => c.startsWith('ovai_flow=')).split(';')[0];
+ const code = authorize({ subject_id: ada });
+ 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);
+ assert.strictEqual((await get(`/api/v1/runs/${runIds.ok}`, runIds.adaCookie)).status, 401);
+});
+
+t.test('boot leaves the person page out of search and the API index names it', async () => {
+ const robots = await request(h.base, 'GET', '/robots.txt');
+ assert.ok(robots.text.includes('Disallow: /runs') && robots.text.includes('Disallow: /console') && robots.text.includes('Disallow: /auth/'));
+ const index = await request(h.base, 'GET', '/');
+ assert.ok(index.text.includes('GET /runs/:id your run receipt'));
+});
+
+t.test('stop the service', async () => { await h.stop(); });
+t.run();