openvibers/openvibe.ai Commits

AI's runs in the Action Center, and a page for one run (#186)

OP OpenVibers committed 16 days ago · 17a576040c · 21 files, +765 −23

Changes

ddiff --git a/.env.example b/.env.example
iindex 54e6e92..4dfe8f7 100644
---- a/.env.example
++++ b/.env.example
@@@ -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) ──
ddiff --git a/README.md b/README.md
iindex c3759da..b1f410e 100644
---- a/README.md
++++ b/README.md
@@@ -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 sti
the admin API only. There is no drill mode in AI; the console starts nothing (no timer, no outbound
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:`)
A provider the owner adds from the console keeps its key in `provider_secrets` (migration `0010_provider_secrets.sql`,
ddiff --git a/STATUS.json b/STATUS.json
iindex 26b226d..d222bc2 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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",
ddiff --git a/docs/capabilities-proposal/ai.actions.read.json b/docs/capabilities-proposal/ai.actions.read.json
nnew file mode 100644
iindex 0000000..b1fbd77
---- /dev/null
++++ b/docs/capabilities-proposal/ai.actions.read.json
@@@ -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"
++}
ddiff --git a/docs/capabilities-proposal/service-manifest.ai.json b/docs/capabilities-proposal/service-manifest.ai.json
iindex 2640615..8b33614 100644
---- a/docs/capabilities-proposal/service-manifest.ai.json
++++ b/docs/capabilities-proposal/service-manifest.ai.json
@@@ -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",
ddiff --git a/migrations/0012_person_sessions.sql b/migrations/0012_person_sessions.sql
nnew file mode 100644
iindex 0000000..d1c2d44
---- /dev/null
++++ b/migrations/0012_person_sessions.sql
@@@ -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"
++);
ddiff --git a/package-lock.json b/package-lock.json
iindex d0522f8..c37d52a 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex f08e7d6..e2fea63 100644
---- a/package.json
++++ b/package.json
@@@ -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"
ddiff --git a/server/actions/items.js b/server/actions/items.js
nnew file mode 100644
iindex 0000000..3658f3a
---- /dev/null
++++ b/server/actions/items.js
@@@ -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 };
ddiff --git a/server/api/actions.js b/server/api/actions.js
nnew file mode 100644
iindex 0000000..05ae210
---- /dev/null
++++ b/server/api/actions.js
@@@ -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 };
ddiff --git a/server/app.js b/server/app.js
iindex e5915d3..ac14fe0 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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',
ddiff --git a/server/auth.js b/server/auth.js
iindex 80e7383..054d7af 100644
---- a/server/auth.js
++++ b/server/auth.js
@@@ -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',
});
/**
ddiff --git a/server/config.js b/server/config.js
iindex 06e443a..d4e1591 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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://'),
ddiff --git a/server/console/index.js b/server/console/index.js
iindex 81b583a..14caee1 100644
---- a/server/console/index.js
++++ b/server/console/index.js
@@@ -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, e
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) => {
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, e
return r;
}
--module.exports = { consoleRouter, consoleCapabilities, sanitizeNext, STAFF_TO_AI };
++module.exports = { consoleRouter, consoleCapabilities, sanitizeNext, sanitizeRunNext, STAFF_TO_AI };
ddiff --git a/server/console/queries.js b/server/console/queries.js
iindex 465cff8..82bc8a3 100644
---- a/server/console/queries.js
++++ b/server/console/queries.js
@@@ -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 };
}
ddiff --git a/server/console/views.js b/server/console/views.js
iindex 731c970..ba33cde 100644
---- a/server/console/views.js
++++ b/server/console/views.js
@@@ -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.typ
function 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 } };
ddiff --git a/server/person-session.js b/server/person-session.js
nnew file mode 100644
iindex 0000000..573c424
---- /dev/null
++++ b/server/person-session.js
@@@ -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 };
ddiff --git a/server/run-pages.js b/server/run-pages.js
nnew file mode 100644
iindex 0000000..a3c02a2
---- /dev/null
++++ b/server/run-pages.js
@@@ -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 };
ddiff --git a/server/runs.js b/server/runs.js
iindex d71bce3..7c4a05f 100644
---- a/server/runs.js
++++ b/server/runs.js
@@@ -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);
}
++ // 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 };
ddiff --git a/test/actions.test.js b/test/actions.test.js
nnew file mode 100644
iindex 0000000..44318cd
---- /dev/null
++++ b/test/actions.test.js
@@@ -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** &amp; 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> &amp; 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();
ddiff --git a/test/run-page.test.js b/test/run-page.test.js
nnew file mode 100644
iindex 0000000..04e5b6c
---- /dev/null
++++ b/test/run-page.test.js
@@@ -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('&lt;script&gt;alert(1)&lt;/script&gt; &amp; 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('&lt;script&gt;it broke&lt;/script&gt;') && !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();