openvibers/openvibe.report Commits
D11 owner settings: report.ai_exchange (#98)
Changes
@@@ -39,6 +39,11 @@ OV_OAUTH_CLIENT_SECRET=# rotation, 32+ characters each; unset makes the route answer 503.REPORT_EVENTS_SECRET=++# AI Exchange analytics (plan G-P9), off by default. On: consume Billing's billing.credit.settled events into++# aggregates by day/model/provider access, and open the staff-only /ai/exchange page and GET /api/v1/ai/exchange.++# Off: a settlement delivery is ignored before anything is written.++#REPORT_AI_EXCHANGE=0++# The ingest and shadow jobs (on by default; REPORT_JOBS=0 turns both off).#REPORT_JOBS=1#REPORT_JOBS_ON_BOOT=1@@@ -20,6 +20,9 @@ OpenVibe.Report is the analytics authority in shadow:PostgreSQL tables (migrations/).- It **computes** the same numbers Network answers, site-wide, per site and per channel, by UTC day.- It **quotes money from OpenVibe.Billing** through Billing's read API; it never derives a price, fee or amount.++- It **shadows the AI Exchange** behind `REPORT_AI_EXCHANGE` (off): Billing's `billing.credit.settled@1`++ settlements are summed by UTC day, resolved exact model id and provider access. The raw settlement is not stored,++ and Report never sets a rate, a price or a balance.- It **shadows**: a job compares the same window from Network and from Report's tables and records every differencein `report_shadow_diffs`.@@@ -35,6 +38,7 @@ OpenVibe.Live in both modes.| **OpenVibe.Live** — after-show reports | `GET /api/recap/:streamId`, `GET /api/recap/channel/:username` | `/after-show/:streamId`, `/c/:username` || **OpenVibe.Bench** | `GET /api/v1/kinds`, `/api/v1/boards/:kind?type=elo\|pareto\|cost`, `/api/v1/models/:model` | `/ai`, `/ai/:kind`, `/ai/models/:model` || **OpenVibe.Billing** | `GET /api/v1/stats`, `GET /api/v1/transactions?subject=…` | the revenue read for a channel; network money counts |++| **OpenVibe.Billing** — settlements, through Events | `billing.credit.settled@1` on `POST /internal/events` (only with `REPORT_AI_EXCHANGE=1`) | the AI Exchange aggregates and the staff page/API |A source that does not answer is null, after one short log line; the page (or API route) becomes a 404. The servicenever invents a zero and never throws a source error at the reader.@@@ -44,8 +48,14 @@ never invents a zero and never throws a source error at the reader.- **The analytics tables** in `migrations/0001_initial.sql`: `report_inbox` (event ids already handled),`report_events`, `report_streams`, `report_frame_days`, `report_metric_days`, `report_metric_windows`,`report_metric_json`, `report_dimensions`, `report_shadow_runs` and `report_shadow_diffs`.++- **The AI meter** in `migrations/0002_ai_usage.sql` and `0003_ai_usage_surface.sql`: `report_ai_usage` (raw calls,++ kept 90 days) and `report_ai_usage_days`; the consumer is `server/analytics/ai-usage.js`.++- **The AI Exchange aggregates** in `migrations/0005_ai_exchange.sql`: `report_ai_exchange_days` (day, model,++ provider access, requests, sandbox requests, the four token counts, exact nano-USD retail/shortfall/supply/margin),++ `report_ai_exchange_seen` (settlement ids already counted) and `report_ai_exchange_rejected` (invalid payloads).++ The consumer is `server/analytics/ai-exchange.js`.- **The computation** in `server/analytics/`: `ingest.js` (idempotent writes), `query.js` (Network's stats shape and-- a channel's creator analytics), `shadow.js` (the comparison).++ a channel's creator analytics), `shadow.js` (the comparison), `ai-exchange.js` (the Exchange aggregates).- **The jobs** in `server/jobs/analytics.js`: ingest every five minutes (7, 30 and 90-day windows) and shadow everyfifteen minutes (7 days by default).@@@ -76,6 +86,7 @@ All server-rendered in `server/views/index.js`; every dynamic value goes through| `/ai` | benchmark kinds grouped by room, with leaders and game counts || `/ai/:kind` | Elo, Pareto quality against cost per solved, and cost per solved tables || `/ai/models/:model` | a model's ratings and entries across kinds |++| `/ai/exchange` | **staff only**, with `REPORT_AI_EXCHANGE=1`: Billing's settlements by day, model and access, a daily chart and the exact amounts. Needs a signed-in person whose staff role holds `staff.site.view` |A bad path parameter (slug, stream id, username) or a source that answered nothing renders `views.notFound()` withstatus 404. Pages are `Cache-Control: public, max-age=120`.@@@ -98,6 +109,14 @@ GET /api/v1/c/:usernameA bad parameter or a missing source answer is a 404 `application/problem+json` body built with`openvibe-contracts` `http.sendProblem`. The public API is read-only; other methods get 405.++The AI Exchange JSON read is the staff exception (with `REPORT_AI_EXCHANGE=1`): a Network service token with the++same `report.ai_usage.read` capability the AI meter uses, never cached, no person or project in the answer. The++HTML page is gated by the signed-in person's staff role (`staff.site.view`) instead, like the other staff pages.++++```++GET /api/v1/ai/exchange?from=YYYY-MM-DD&to=YYYY-MM-DD the Exchange aggregates, staff only++```++### Internal (loopback only)```@@@ -121,7 +140,13 @@ nginx refuses `/internal/` from outside; the app also checks the socket is loopbkeyed by the answer's content hash. Polling the same numbers again is a no-op.- Every delivery is claimed in `report_inbox` (openvibe-sdk/events `createPgInbox`) in the same transaction as itswrites.--- Billing events are deliberately not ingested: money comes from Billing's read API.++- `billing.credit.settled` (with `REPORT_AI_EXCHANGE=1`) → `report_ai_exchange_days`, keyed by day, model and++ provider access, and `report_ai_exchange_seen` by settlement id. The payload is validated against++ `billing.credit.settled@1`; an invalid one is counted in `report_ai_exchange_rejected` and never aggregated.++ Replaying the same settlement under a new event id counts once. A sandbox settlement only increments++ `sandbox_requests`. The raw settlement is never stored. With the flag off, the delivery is ignored before anything++ is written.++- Billing's other money still comes from Billing's read API, never from an event.## Configuration@@@ -146,6 +171,7 @@ Every value comes from the environment (production: `/etc/openvibe/report.env`).| `OV_OAUTH_CLIENT_ID` / `OV_OAUTH_CLIENT_SECRET` | `report` / unset | Report's client-credentials token for `openvibe.billing` || `REPORT_BILLING_TOKEN` | unset | an explicit Billing bearer token for an operator run || `REPORT_EVENTS_SECRET` | unset | the HMAC secret for `POST /internal/events` (32+ chars, comma list for rotation) |++| `REPORT_AI_EXCHANGE` | `0` | `1` consumes Billing's `billing.credit.settled` events into the AI Exchange aggregates and opens `/ai/exchange` and `GET /api/v1/ai/exchange`; off consumes nothing || `REPORT_JOBS` | `1` | `0` turns the ingest and shadow jobs off || `REPORT_JOBS_ON_BOOT` | `1` | run both jobs once shortly after boot || `REPORT_INGEST_INTERVAL_MS` | `300000` | how often the ingest job runs |@@@ -167,7 +193,8 @@ Most tests stub both sources and pass `store: null`, so they need no database. T`test/helpers/db.js` (`createTestDb` from openvibe-sdk/testing) with a migrated PGlite, or the PostgreSQL containerswith `REPORT_TEST_STORE=pg`. They cover ingest idempotency, every metric against a fixture's expected numbers, theshadow job's difference and clean run, `GET /internal/shadow`, the source flag, the Billing revenue read with a stub,--and the existing pages/API/health/crawl behaviour.++the AI Exchange aggregation/validation/replay/BigInt/sandbox/staff-gate/flag-off cases, and the existing++pages/API/health/crawl behaviour.`node --check` is run over `server/`, `test/` and `public/` by CI (`.github/workflows/ci.yml`, the sharedOpenVibe.Shared test workflow).@@@ -200,12 +227,13 @@ When `DATABASE_URL` is configured, the database check is included and is require## SecurityEvery source text is escaped before it reaches HTML. Path parameters are checked against fixed patterns before any--request. Report holds no accounts and no cookies. Its database has public channel facts and counts only; event--payloads with money or personal data are not stored. `/internal/` is loopback-only, and `POST /internal/events`--verifies the Events v2 HMAC. See [SECURITY.md](SECURITY.md).++request. Report's own sign-in serves `/you` and the staff page, which reads the signed-in person's staff role. Its++database has public channel facts, the AI meter and aggregate AI Exchange numbers; a raw settlement (a person, a++project, money) is never stored. `/internal/` is loopback-only, and `POST /internal/events` verifies the Events v2++HMAC. See [SECURITY.md](SECURITY.md).<!-- versions:start -->--- openvibe-contracts: v0.275.0++- openvibe-contracts: v0.287.0- openvibe-sdk: v0.82.0- openvibe-shared: v4.109.2- openvibe-publishing: v1.5.0nnew file mode 100644@@@ -0,0 +1,43 @@++-- phase: expand++-- AI Exchange analytics (plan G-P9): Billing's credit settlements, summed by UTC day, exact model id and provider++-- access. Aggregates only: no person, no project, no prompt and no answer is stored here. Report reads the++-- authorities (Billing settles and names the model in the event); it never sets a rate, a price or a balance.++++-- One row per day, resolved model id and provider access. Money is integer nano-USD (D1) in BIGINT: what was++-- collected (retail), what is owed but not collected (shortfall), what the route cost (supply) and the margin++-- (retail minus supply). A sandbox settlement is not money: it only increments sandbox_requests.++CREATE TABLE report_ai_exchange_days (++ day date NOT NULL,++ model text COLLATE "C" NOT NULL, -- the exact id after deployment_id's first ":", or 'unknown'++ provider_access text COLLATE "C" NOT NULL, -- OWN_DEPLOYMENT | AUTHORIZED_WHOLESALE | CUSTOMER_BYOK | RESTRICTED | UNVERIFIED | unknown++ requests bigint NOT NULL DEFAULT 0,++ sandbox_requests bigint NOT NULL DEFAULT 0, -- usd_credit_sandbox settlements: counted apart, never money++ tokens_input bigint NOT NULL DEFAULT 0,++ tokens_cached_read bigint NOT NULL DEFAULT 0,++ tokens_cache_write bigint NOT NULL DEFAULT 0,++ tokens_output bigint NOT NULL DEFAULT 0,++ retail_nano_usd bigint NOT NULL DEFAULT 0, -- charge_nano_usd, what was collected++ shortfall_nano_usd bigint NOT NULL DEFAULT 0, -- owed, not collected++ supply_nano_usd bigint NOT NULL DEFAULT 0,++ margin_nano_usd bigint GENERATED ALWAYS AS (retail_nano_usd - supply_nano_usd) STORED,++ updated_at timestamptz NOT NULL DEFAULT now(),++ PRIMARY KEY (day, model, provider_access)++);++CREATE INDEX report_ai_exchange_days_day ON report_ai_exchange_days (day);++++-- The settlement ids already counted, so a redelivered settlement (even under a new event id) counts once. It carries++-- no subject, no project and no amount: just the id and the day it was counted on.++CREATE TABLE report_ai_exchange_seen (++ settlement_id text COLLATE "C" PRIMARY KEY,++ day date NOT NULL,++ received_at timestamptz NOT NULL DEFAULT now()++);++++-- A payload that does not match billing.credit.settled@1 is counted here, by the day it claims, and never aggregated.++CREATE TABLE report_ai_exchange_rejected (++ day date NOT NULL,++ reason text COLLATE "C" NOT NULL,++ events bigint NOT NULL DEFAULT 0,++ updated_at timestamptz NOT NULL DEFAULT now(),++ PRIMARY KEY (day, reason)++);nnew file mode 100644@@@ -0,0 +1,28 @@++-- phase: expand++-- D11 owner settings: openvibe-shared/config (config_snapshots, config_keys). Applied migrations are immutable.++CREATE TABLE IF NOT EXISTS config_snapshots (++ namespace text COLLATE "C" NOT NULL,++ revision bigint NOT NULL,++ service text COLLATE "C" NOT NULL,++ previous_revision bigint,++ state text COLLATE "C" NOT NULL CHECK (state IN ('proposed', 'active', 'superseded', 'rejected', 'rolled_back')),++ values_json text COLLATE "C" NOT NULL, -- the full values; secret-class values are envelopes (never their plaintext)++ classification_json text COLLATE "C" NOT NULL,++ values_checksum text COLLATE "C" NOT NULL, -- sha256 of the values as served (secrets as markers)++ created_at text COLLATE "C" NOT NULL,++ created_by text COLLATE "C" NOT NULL,++ activated_at text COLLATE "C",++ activated_by text COLLATE "C",++ reason text COLLATE "C",++ error text COLLATE "C",++ copied_from bigint,++ good bigint NOT NULL DEFAULT 0, -- 1 once it activated successfully (last-known-good)++ PRIMARY KEY (namespace, revision)++);++CREATE UNIQUE INDEX IF NOT EXISTS config_snapshots_one_active ON config_snapshots (namespace) WHERE state = 'active';++-- The fingerprint key of each namespace. Never served, never logged.++CREATE TABLE IF NOT EXISTS config_keys (++ namespace text COLLATE "C" PRIMARY KEY,++ hmac_key bytea NOT NULL, -- the 32-byte fingerprint key, or that key wrapped under the current KEK++ created_at text COLLATE "C" NOT NULL++);@@@ -13,10 +13,10 @@"express": "^4.21.2","express-rate-limit": "^7.5.0","helmet": "^8.1.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.275.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.288.0","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.5.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.109.2",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.110.0","pg": "^8.23.1"},"devDependencies": {@@@ -1057,9 +1057,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.275.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.275.0",-- "integrity": "sha512-s8D3Upj5lfCGSnTVXKEWyzDEG5b7QnY/O2tAPieaZgFhzM4rnZyboGCZv3qthVOZ1ibxx/ZTEtS9nxZ/AVL+Hg==",++ "version": "0.288.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.288.0",++ "integrity": "sha512-AKRP1yXACYNu7okpf4kCDq4ds0PhsoNvD92VlvBWBCjcSlNimSTQhLWIvSa3D31/kQYz91fodo06ZnwuTanOqg==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -1119,9 +1119,9 @@}},"node_modules/openvibe-shared": {-- "version": "4.109.2",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.109.2",-- "integrity": "sha512-t9K1yHr6GOJsfamqmgERRTTtiWYWAVmjWBEm4ykTcYjEenbTiqQTwX6CJsZW4lYnqS1IlqEvn+0jG4vXmHSJ3Q==",++ "version": "4.110.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.110.0",++ "integrity": "sha512-ZNOSraSgE89jhLTI//lFJALvKCiBJIBwf+V0aCkiIk0VCrPPAytsfsgOgCINU8kXDVdCEnuWWfcA9hZUc54QVA==","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0",@@@ -19,10 +19,10 @@"express": "^4.21.2","express-rate-limit": "^7.5.0","helmet": "^8.1.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.275.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.288.0","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.5.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.109.2",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.110.0","pg": "^8.23.1"},"engines": {nnew file mode 100644@@@ -0,0 +1,86 @@++'use strict';++/**++ * The `report.ai_exchange` namespace (D11 owner settings): the switch the owner changes from openvibe.network's++ * AI tab. REPORT_AI_EXCHANGE stays the pin: while it is set it wins, is read-only in the summary and a write++ * answers 409 config.pinned_in_env.++ *++ * /internal/config is openvibe-shared/config's adminRoutes, guarded by report.config.manage (Network's service++ * token only). actor is the owner Network names in X-OV-Subject; X-OV-Fresh: 1 gates a secret or x-danger write++ * (this namespace has neither). Every activation writes one audit line with the actor and the keys, never a value.++ */++const express = require('express');++const shared = require('openvibe-shared/config');++const { http, ids } = require('openvibe-contracts');++const { createServiceAuth } = require('./auth');++++const SERVICE = 'report';++const NAMESPACE = 'report.ai_exchange';++const CAPABILITY = 'report.config.manage';++++const SCHEMA = {++ $schema: 'https://json-schema.org/draft/2020-12/schema',++ title: 'AI Exchange analytics',++ description: 'Consume Billing\'s billing.credit.settled events into the AI Exchange aggregates, and open the staff page and API.',++ type: 'object',++ additionalProperties: false,++ required: ['enabled'],++ properties: {++ enabled: {++ type: 'boolean', title: 'AI Exchange analytics', 'x-group': 'AI Exchange', 'x-env': 'REPORT_AI_EXCHANGE',++ description: 'Record each settlement as aggregates by day, model and provider access (never the raw settlement) and serve /ai/exchange and GET /api/v1/ai/exchange to staff.',++ },++ },++};++const DEFAULTS = Object.freeze({ enabled: false });++++function actor(req) {++ const named = String((req.headers && req.headers['x-ov-subject']) || '').trim();++ return ids.isSubjectId('user', named) ? { type: 'user', id: named } : null;++}++++const requireFresh = (req) => String((req.headers && req.headers['x-ov-fresh']) || '') === '1';++++function capabilityGuard({ serviceAuth, log }) {++ return async function guard(req, res, next) {++ try {++ const r = await serviceAuth.authenticate(req);++ if (!r.ok) return http.sendProblem(res, r.status, r.code, { detail: r.detail, ctx: req.ov });++ const sub = String((r.claims && r.claims.sub) || '');++ if (!sub.startsWith('svc:')) return http.sendProblem(res, 403, 'capability.denied', { detail: `${CAPABILITY} is first-party; an app may not change Report configuration`, ctx: req.ov });++ if (!serviceAuth.hasCap(r.claims, CAPABILITY)) return http.sendProblem(res, 403, 'capability.denied', { detail: `${CAPABILITY} not granted`, ctx: req.ov });++ req.configClaims = r.claims;++ return next();++ } catch (err) {++ log.error(`[config] report.ai_exchange guard failed: ${(err && err.message) || err}`);++ return http.sendProblem(res, 500, 'internal.error', { detail: 'the configuration request failed', ctx: req.ov });++ }++ };++}++++async function create({ db, config, env = process.env, log = console, now = () => Date.now(), onActivate = null }) {++ // REPORT_AI_EXCHANGE has always accepted anything but 0/false/no/off; the shared store's boolean pin reads true/1.++ const storeEnv = { ...env };++ const rawReportFlag = String(storeEnv.REPORT_AI_EXCHANGE || '');++ if (rawReportFlag && !/^(0|false|no|off)$/i.test(rawReportFlag)) storeEnv.REPORT_AI_EXCHANGE = '1';++ const store = await shared.createConfigStore({++ db, service: SERVICE, namespace: NAMESPACE, schema: SCHEMA, defaults: DEFAULTS, env: storeEnv, log,++ onActivate: async (values) => { if (onActivate) await onActivate(values); },++ onChange: ({ namespace, keys, actor: who, revision }) => {++ log.info(`[config] ${SERVICE} ${namespace} revision ${revision} by ${who && who.id ? `${who.type}:${who.id}` : 'unknown'}: ${keys.join(', ')}`);++ },++ });++ if (env.OV_CONFIG_KEKS) {++ try { await store.reseal(); } catch (err) { log.warn(`[config] ${NAMESPACE}: reseal failed: ${(err && err.message) || err}`); }++ }++ const serviceAuth = createServiceAuth({ config, now });++ const router = shared.adminRoutes([store], {++ basePath: '/internal/config', requireAdmin: capabilityGuard({ serviceAuth, log }), actor, requireFresh, log,++ });++ return {++ store, router,++ get: (key) => store.get(key),++ mount(app) { app.use('/internal/config', express.json({ limit: '128kb' })); router.mount(app); },++ };++}++++module.exports = { create, SCHEMA, NAMESPACE, CAPABILITY };nnew file mode 100644@@@ -0,0 +1,242 @@++'use strict';++/**++ * AI Exchange analytics (plan G-P9): the settlements OpenVibe.Billing posts, summed by UTC day, resolved exact model++ * id and provider access.++ *++ * The event is `billing.credit.settled` (OpenVibe.Contracts v0.287.0, payload `billing.credit.settled@1`). Report++ * reads the authorities and keeps only aggregates: requests, sandbox requests, the four token counts, the retail++ * charge, the shortfall, the supply cost and the margin (retail minus supply). A settlement is counted once by its++ * settlement id, so a redelivered event — even under a new event id — changes nothing. No person, no project, no++ * prompt and no answer is written here, and Report never sets a rate, a price or a balance.++ *++ * The payload is validated against the contract before it is counted: an invalid one is counted as rejected and never++ * aggregated. Sandbox settlements (`sandbox: true`) are not money: they only increment `sandbox_requests`.++ */++++const contracts = require('openvibe-contracts');++++const TOPIC = 'billing.credit.settled';++const CONTRACT = 'billing.credit.settled@1';++const DAY = /^\d{4}-\d{2}-\d{2}$/;++const DAY_MS = 86400000;++const DEFAULT_RANGE_DAYS = 30;++const MAX_RANGE_DAYS = 400;++++const isSettlementTopic = (topic) => String(topic || '') === TOPIC;++++/** The UTC day of an ISO timestamp, or null. */++function dayOf(value) {++ if (value == null || value === '') return null;++ if (typeof value === 'string' && DAY.test(value)) return value;++ const t = value instanceof Date ? value.getTime() : (typeof value === 'number' ? value : Date.parse(String(value)));++ return Number.isFinite(t) ? new Date(t).toISOString().slice(0, 10) : null;++}++const addDays = (day, n) => new Date(Date.parse(`${day}T00:00:00Z`) + n * DAY_MS).toISOString().slice(0, 10);++const daysBetween = (a, b) => Math.round((Date.parse(`${b}T00:00:00Z`) - Date.parse(`${a}T00:00:00Z`)) / DAY_MS);++++/** A DB bigint (BigInt in PGlite, a string in node-postgres) as a decimal string; money never passes through Number. */++const big = (v) => {++ if (v == null) return null;++ if (typeof v === 'bigint') return v.toString();++ if (typeof v === 'string' && /^-?[0-9]+$/.test(v)) return v;++ if (Number.isSafeInteger(v)) return String(v);++ throw new TypeError('a bigint column did not arrive as an exact integer');++};++const whole = (v) => Number(v || 0);++++/** Does this payload match `billing.credit.settled@1`? Unknown ids and malformed values are invalid, never thrown. */++function validSettlement(payload) {++ try {++ const r = contracts.validate(CONTRACT, payload);++ return Boolean(r && r.valid === true);++ } catch { return false; }++}++++/**++ * The model is the part of `deployment_id` after the first ":" ("own:qwen/qwen3-4b:q4_k_m@3f2a1c9" →++ * "qwen/qwen3-4b:q4_k_m@3f2a1c9"). A null deployment id is unknown; so is a value with no model part.++ */++function modelOf(deploymentId) {++ if (deploymentId == null) return 'unknown';++ const s = String(deploymentId);++ const i = s.indexOf(':');++ const model = i >= 0 ? s.slice(i + 1).trim() : '';++ return model ? model.slice(0, 256) : 'unknown';++}++++const changed = (r) => Number(r && (r.changes != null ? r.changes : r.rowCount)) || 0;++++/** Count an invalid settlement by the day it claims, so it is visible and still never aggregated. */++async function reject(t, day, reason = 'invalid') {++ await t.prepare(`INSERT INTO report_ai_exchange_rejected (day, reason, events) VALUES (?::date, ?, 1)++ ON CONFLICT (day, reason) DO UPDATE SET events = report_ai_exchange_rejected.events + 1, updated_at = now()`)++ .run(day, reason);++}++++/**++ * Count one settlement inside the delivery's own transaction. Validation comes first: an invalid payload is counted++ * as rejected and nothing else is written. Then the settlement id is claimed; a replay returns++ * `settlement:duplicate`. A sandbox settlement only increments `sandbox_requests`.++ */++async function recordSettlement(t, payload, { occurredAt = null, now = Date.now() } = {}) {++ const p = payload && typeof payload === 'object' ? payload : {};++ const at = dayOf(p.settled_at) || dayOf(occurredAt) || dayOf(now) || new Date(now).toISOString().slice(0, 10);++ if (!validSettlement(p)) {++ await reject(t, at);++ return 'settlement:rejected';++ }++ const claim = await t.prepare('INSERT INTO report_ai_exchange_seen (settlement_id, day) VALUES (?, ?::date) ON CONFLICT (settlement_id) DO NOTHING')++ .run(p.settlement_id, at);++ if (!changed(claim)) return 'settlement:duplicate';++ const model = modelOf(p.deployment_id);++ const access = p.provider_access || 'unknown';++ if (p.sandbox === true) {++ await t.prepare(`INSERT INTO report_ai_exchange_days (day, model, provider_access, sandbox_requests)++ VALUES (?::date, ?, ?, 1)++ ON CONFLICT (day, model, provider_access) DO UPDATE SET++ sandbox_requests = report_ai_exchange_days.sandbox_requests + 1,++ updated_at = now()`)++ .run(at, model, access);++ return 'settlement:recorded';++ }++ await t.prepare(`INSERT INTO report_ai_exchange_days++ (day, model, provider_access, requests, tokens_input, tokens_cached_read, tokens_cache_write, tokens_output,++ retail_nano_usd, shortfall_nano_usd, supply_nano_usd)++ VALUES (?::date, ?, ?, 1, ?::bigint, ?::bigint, ?::bigint, ?::bigint, ?::bigint, ?::bigint, ?::bigint)++ ON CONFLICT (day, model, provider_access) DO UPDATE SET++ requests = report_ai_exchange_days.requests + 1,++ tokens_input = report_ai_exchange_days.tokens_input + excluded.tokens_input,++ tokens_cached_read = report_ai_exchange_days.tokens_cached_read + excluded.tokens_cached_read,++ tokens_cache_write = report_ai_exchange_days.tokens_cache_write + excluded.tokens_cache_write,++ tokens_output = report_ai_exchange_days.tokens_output + excluded.tokens_output,++ retail_nano_usd = report_ai_exchange_days.retail_nano_usd + excluded.retail_nano_usd,++ shortfall_nano_usd = report_ai_exchange_days.shortfall_nano_usd + excluded.shortfall_nano_usd,++ supply_nano_usd = report_ai_exchange_days.supply_nano_usd + excluded.supply_nano_usd,++ updated_at = now()`)++ .run(at, model, access, String(p.usage.input_tokens), String(p.usage.cached_read_tokens),++ String(p.usage.cache_write_tokens), String(p.usage.output_tokens), p.charge_nano_usd,++ p.shortfall_nano_usd, p.supply_cost_nano_usd);++ return 'settlement:recorded';++}++++/** A day range for an answer: the last 30 days by default, 400 at most. An impossible pair throws a RangeError. */++function range({ from = null, to = null, now = Date.now() } = {}) {++ const today = dayOf(now);++ const toDay = to == null || to === '' ? today : dayOf(String(to).slice(0, 10));++ if (!toDay) throw new RangeError('to must be a day (YYYY-MM-DD)');++ let fromDay = from == null || from === '' ? addDays(toDay, -(DEFAULT_RANGE_DAYS - 1)) : dayOf(String(from).slice(0, 10));++ if (!fromDay) throw new RangeError('from must be a day (YYYY-MM-DD)');++ if (fromDay > toDay) throw new RangeError('from must be on or before to');++ const earliest = addDays(toDay, -(MAX_RANGE_DAYS - 1));++ if (fromDay < earliest) fromDay = earliest;++ return { from: fromDay, to: toDay, days: daysBetween(fromDay, toDay) + 1 };++}++++/** The rows of a range, exact. Money leaves as decimal strings; counts as numbers. */++async function rows(store, { from, to }) {++ // Every bigint is selected as text: the SDK's int8 parser refuses a value beyond 2^53 rather than rounding it,++ // and money must never pass through a float.++ const out = await store.db.prepare(`SELECT day::text AS day, model, provider_access, requests::text AS requests,++ sandbox_requests::text AS sandbox_requests,++ tokens_input::text AS tokens_input, tokens_cached_read::text AS tokens_cached_read,++ tokens_cache_write::text AS tokens_cache_write, tokens_output::text AS tokens_output,++ retail_nano_usd::text AS retail_nano_usd, shortfall_nano_usd::text AS shortfall_nano_usd,++ supply_nano_usd::text AS supply_nano_usd, margin_nano_usd::text AS margin_nano_usd++ FROM report_ai_exchange_days WHERE day >= ?::date AND day <= ?::date++ ORDER BY day, model, provider_access`).all(from, to);++ return out.map((r) => ({++ day: String(r.day).slice(0, 10),++ model: String(r.model || 'unknown'),++ provider_access: String(r.provider_access || 'unknown'),++ requests: whole(r.requests),++ sandbox_requests: whole(r.sandbox_requests),++ tokens: {++ input: whole(r.tokens_input),++ cached_read: whole(r.tokens_cached_read),++ cache_write: whole(r.tokens_cache_write),++ output: whole(r.tokens_output),++ },++ retail_nano_usd: big(r.retail_nano_usd),++ shortfall_nano_usd: big(r.shortfall_nano_usd),++ supply_nano_usd: big(r.supply_nano_usd),++ margin_nano_usd: big(r.margin_nano_usd),++ }));++}++++/** The invalid settlements of the range, by day. */++async function rejectedOf(store, { from, to }) {++ const list = await store.db.prepare(`SELECT day::text AS day, SUM(events)::text AS events++ FROM report_ai_exchange_rejected WHERE day >= ?::date AND day <= ?::date++ GROUP BY day ORDER BY day`).all(from, to);++ const by_day = list.map((r) => ({ day: String(r.day).slice(0, 10), events: whole(r.events) }));++ return { count: by_day.reduce((n, r) => n + r.events, 0), by_day };++}++++/** Add rows into one total, using BigInt for money. */++function totalOf(list) {++ const t = { requests: 0, sandbox_requests: 0, tokens: { input: 0, cached_read: 0, cache_write: 0, output: 0 }, retail: 0n, shortfall: 0n, supply: 0n };++ for (const r of list) {++ t.requests += r.requests;++ t.sandbox_requests += r.sandbox_requests;++ for (const k of Object.keys(t.tokens)) t.tokens[k] += r.tokens[k];++ t.retail += BigInt(r.retail_nano_usd);++ t.shortfall += BigInt(r.shortfall_nano_usd);++ t.supply += BigInt(r.supply_nano_usd);++ }++ return {++ requests: t.requests,++ sandbox_requests: t.sandbox_requests,++ tokens: { ...t.tokens },++ retail_nano_usd: t.retail.toString(),++ shortfall_nano_usd: t.shortfall.toString(),++ supply_nano_usd: t.supply.toString(),++ margin_nano_usd: (t.retail - t.supply).toString(),++ };++}++++/** The day totals for the chart, from the same rows. */++function dailyOf(list) {++ const map = new Map();++ for (const r of list) {++ let d = map.get(r.day);++ if (!d) { d = { day: r.day, requests: 0, sandbox_requests: 0, retail: 0n, shortfall: 0n, supply: 0n }; map.set(r.day, d); }++ d.requests += r.requests;++ d.sandbox_requests += r.sandbox_requests;++ d.retail += BigInt(r.retail_nano_usd);++ d.shortfall += BigInt(r.shortfall_nano_usd);++ d.supply += BigInt(r.supply_nano_usd);++ }++ return [...map.values()].sort((a, b) => (a.day < b.day ? -1 : 1)).map((d) => ({++ day: d.day,++ requests: d.requests,++ sandbox_requests: d.sandbox_requests,++ retail_nano_usd: d.retail.toString(),++ shortfall_nano_usd: d.shortfall.toString(),++ supply_nano_usd: d.supply.toString(),++ margin_nano_usd: (d.retail - d.supply).toString(),++ }));++}++++/** One answer: the range, the rows, the day totals, the totals and the rejected count. */++async function analytics(store, opts = {}) {++ const r = range(opts);++ const list = await rows(store, r);++ return { range: r, rows: list, daily: dailyOf(list), totals: totalOf(list), rejected: await rejectedOf(store, r) };++}++++/** The Exchange ledger as one object: the event consumer and the read routes share it. */++function createAiExchange({ store, now = () => Date.now(), log = console } = {}) {++ if (!store) return null;++ return {++ store,++ topic: TOPIC,++ record: (t, payload, opts = {}) => recordSettlement(t, payload, { now: now(), ...opts }),++ analytics: (opts = {}) => analytics(store, { now: now(), ...opts }),++ log,++ };++}++++module.exports = {++ TOPIC, CONTRACT, isSettlementTopic, validSettlement, modelOf, recordSettlement, range, rows, rejectedOf,++ totalOf, dailyOf, analytics, createAiExchange,++};@@@ -14,8 +14,10 @@const query = require('./query');const ingest = require('./ingest');--function createAnalytics({ config = {}, store = null, network = null, billing = null, now = () => Date.now(), log = console } = {}) {++function createAnalytics({ config = {}, store = null, network = null, billing = null, now = () => Date.now(), log = console, aiExchange = false } = {}) {const source = config.source === 'report' ? 'report' : 'network';++ // The AI Exchange switch may be a function: the owner can change it live (D11).++ const exchangeOn = () => Boolean(typeof aiExchange === 'function' ? aiExchange() : aiExchange);function warn(what, err) {try { (log.warn || log.error || console.warn).call(log, `[OpenVibe.Report] ${what} failed: ${String((err && err.message) || err).replace(/\s+/g, ' ').slice(0, 200)}`); } catch { /* logging must never fail a page */ }@@@ -40,7 +42,7 @@ function createAnalytics({ config = {}, store = null, network = null, billing =source, store, billing, network,stats, channel, metrics,reportStats: (days, opts = {}) => (store ? query.reportStats(store, days, { now, ...opts }) : null),-- recordEvent: (event, opts = {}) => (store ? ingest.recordEvent(store, event, { now, ...opts }) : Promise.resolve({ duplicate: false, outcome: 'no_database' })),++ recordEvent: (event, opts = {}) => (store ? ingest.recordEvent(store, event, { now, aiExchange: exchangeOn(), ...opts }) : Promise.resolve({ duplicate: false, outcome: 'no_database' })),ingestStats: (payload, opts = {}) => (store ? ingest.ingestStats(store, payload, { now, ...opts }) : Promise.resolve({ ok: false, reason: 'no_database' })),ingestNetwork: (opts = {}) => (store && network ? ingest.ingestNetwork(store, network, { now, ...opts }) : Promise.resolve({ ok: false, reason: 'no_database' })),log,@@@ -11,16 +11,21 @@* writes: a redelivered event id changes nothing. A stats snapshot is keyed by its own content hash, so polling* the same numbers again is a no-op while new numbers replace the stored ones.*-- * Billing events are consciously not ingested: Report reads money from Billing's API, never from an event.++ * Billing's money is read from Billing's API, never derived here. The one exception is the AI Exchange's++ * settlement aggregates (plan G-P9), behind REPORT_AI_EXCHANGE: the raw settlement is not stored, only its++ * aggregates (day, model, provider access) and its settlement id for idempotency. With the flag off, a settlement++ * delivery is ignored before anything is written.*/const crypto = require('crypto');const { createPgInbox } = require('openvibe-sdk/events');const { normaliseDays } = require('../data/source');++const { isSettlementTopic } = require('./ai-exchange');const INBOX = 'report-events';const STATS_INBOX = 'network-stats';const DAY = 86_400_000;--const ALLOWED_TOPICS = new Set(['live.stream.ended', 'live.stream.started', 'network.frame.hit', 'network.frame.day', 'frame.hit', 'ai.call.recorded']);++const ALLOWED_TOPICS = new Set(['live.stream.ended', 'live.stream.started', 'network.frame.hit', 'network.frame.day', 'frame.hit', 'ai.call.recorded',++ 'billing.credit.settled']);const hashOf = (value) => crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex');const num = (v) => (v == null || v === '' || !Number.isFinite(Number(v)) ? null : Number(v));@@@ -268,7 +273,7 @@ async function recordStream(t, event, payload, occurredAt, source) {}/** One Events envelope → the tables. Returns { duplicate, outcome }. */--async function recordEvent(store, event, { now = () => Date.now() } = {}) {++async function recordEvent(store, event, { now = () => Date.now(), aiExchange = false } = {}) {if (!store || !store.db) throw new TypeError('recordEvent needs an open Report store');const e = event && typeof event === 'object' ? event : {};const eventId = String(e.event_id || e.id || '');@@@ -279,6 +284,8 @@ async function recordEvent(store, event, { now = () => Date.now() } = {}) {const rawOccurred = e.occurred_at || e.timestamp || (e.payload && (e.payload.ended_at || e.payload.at)) || new Date(now()).toISOString();const occurredMs = Date.parse(String(rawOccurred));const occurredAt = Number.isFinite(occurredMs) ? new Date(occurredMs).toISOString() : null;++ // The flag is off: nothing about a settlement is read or written, not even its event id.++ if (isSettlementTopic(topic) && !aiExchange) return { duplicate: false, outcome: 'ignored:flag' };if (!ALLOWED_TOPICS.has(topic)) {// Today's billing and traffic events are not analytics facts Report may derive: a seen id changes nothing.const inbox = createPgInbox(store.db, { table: 'report_inbox', now });@@@ -288,13 +295,19 @@ async function recordEvent(store, event, { now = () => Date.now() } = {}) {const payload = e.payload && typeof e.payload === 'object' ? e.payload : {};const inbox = createPgInbox(store.db, { table: 'report_inbox', now });const r = await inbox.once(INBOX, eventId, async (t) => {-- await t.prepare(`INSERT INTO report_events (event_id, source, topic, occurred_at, payload) VALUES (?, ?, ?, ?, ?::jsonb) ON CONFLICT (event_id) DO NOTHING`)-- .run(eventId, source, topic, occurredAt, JSON.stringify(payload));++ // A settlement carries a person, a project and money. Report may keep its aggregates and its idempotency++ // key, never the raw payload: no per-person rows and no prompt or answer text.++ if (!isSettlementTopic(topic)) {++ await t.prepare(`INSERT INTO report_events (event_id, source, topic, occurred_at, payload) VALUES (?, ?, ?, ?, ?::jsonb) ON CONFLICT (event_id) DO NOTHING`)++ .run(eventId, source, topic, occurredAt, JSON.stringify(payload));++ }await t.prepare('UPDATE report_inbox SET source = ?, topic = ?, payload_hash = ? WHERE consumer = ? AND event_id = ?').run(source, topic, hashOf(payload), INBOX, eventId);if (topic === 'live.stream.ended') return await recordStream(t, e, payload, occurredAt, source);// The AI meter (L40/L41): one record per AI call, stored once by the system and the run (or turn).if (topic === 'ai.call.recorded') return await require('./ai-usage').recordUsage(t, payload, { occurredAt, source });++ // AI Exchange (G-P9): Billing's settlement, counted once by settlement id.++ if (isSettlementTopic(topic)) return await require('./ai-exchange').recordSettlement(t, payload, { occurredAt, now: now() });if (topic === 'network.frame.hit' || topic === 'network.frame.day' || topic === 'frame.hit') return await recordFrame(t, topic, payload, occurredAt, source);return 'event:recorded';});@@@ -33,6 +33,8 @@ const { createBillingClient } = require('./data/billing');const { createBenchClient } = require('./data/bench');const { createAnalytics } = require('./analytics');const { createAiUsage } = require('./analytics/ai-usage');++const { createAiExchange } = require('./analytics/ai-exchange');++const createAiExchangeConfig = require('./ai-exchange-config').create;const { createSearchIndex } = require('./search-index');const { createShadow } = require('./analytics/shadow');const { createApi } = require('./http/api');@@@ -60,9 +62,15 @@ async function createApp(opts = {}) {const live = opts.live || createLiveClient({ config, fetchImpl, log, now });const billing = opts.billing || createBillingClient({ config, fetchImpl, log, now });const bench = opts.bench || createBenchClient({ config, fetchImpl, log, now });-- const analytics = opts.analytics || createAnalytics({ config, store, network, billing, now, log });++ // D11 owner settings (report.ai_exchange): the switch is read through the store, env pins winning. Report can++ // run without a database (a network-only front): then there is no store and the env value is all there is.++ const exchangeConfig = store ? await createAiExchangeConfig({ db: store.db, config, env: opts.env || process.env, now, log, onActivate: opts.onConfigActivate }) : null;++ if (exchangeConfig) Object.defineProperty(config.aiExchange, 'enabled', { enumerable: true, configurable: true, get: () => exchangeConfig.get('enabled') });++ const analytics = opts.analytics || createAnalytics({ config, store, network, billing, now, log, aiExchange: () => Boolean(config.aiExchange && config.aiExchange.enabled) });// The AI ledger (plan L40/L41): the records of every AI call on the network and their roll-ups.const aiUsage = opts.aiUsage || createAiUsage({ store, now, log });++ // AI Exchange analytics (G-P9, flag off): Billing's settlements, aggregated. No database, no ledger.++ const aiExchange = opts.aiExchange !== undefined ? opts.aiExchange : (store ? createAiExchange({ store, now, log }) : null);const shadow = opts.shadow || (store ? createShadow({ store, network, now, log }) : null);// The Rundown topic pages in OpenVibe.Search (started by server/index.js, never under REPORT_DRILL).const searchIndex = opts.searchIndex || (store ? createSearchIndex({ config, store, live, log }) : null);@@@ -75,8 +83,9 @@ async function createApp(opts = {}) {secureCookies: /^https:/.test(config.baseUrl), fetch: fetchImpl, log,});-- const ctx = { config, log, fetchImpl, now, store, network, live, billing, bench, analytics, shadow, sso, aiUsage, searchIndex };++ const ctx = { config, log, fetchImpl, now, store, network, live, billing, bench, analytics, shadow, sso, aiUsage, aiExchange, searchIndex, exchangeConfig };const app = express();++ if (exchangeConfig) exchangeConfig.mount(app);// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts// (openvibe-contracts); the operations they mark internal-only are left out.app.get('/openapi.json', (_req, res) => {@@@ -189,7 +198,7 @@ async function createApp(opts = {}) {legacyHeaders: false,handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }),}));-- app.use('/api/v1', createApi({ network, live, analytics, aiUsage, config, now, log }));++ app.use('/api/v1', createApi({ network, live, analytics, aiUsage, aiExchange, config, now, log }));app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));// ── Crawl artifacts, then the pages ─────────────────────@@@ -78,6 +78,13 @@ function load(env = process.env) {// Drill mode: the jobs that would change numbers stay off while a drill runs.drill: env.REPORT_DRILL === '1' || env.REPORT_DRILL === 'true',++ // AI Exchange analytics (plan G-P9, behind REPORT_AI_EXCHANGE, off). When on, Report consumes Billing's++ // billing.credit.settled events into aggregates by day, model and provider access; off, a settlement++ // delivery is ignored before anything is written.++ aiExchange: {++ enabled: bool(env.REPORT_AI_EXCHANGE, false),++ },++// OpenVibe.Events → Report: live.stream.ended and the frame beacon. The secret signs a delivery (comma// separated for rotation, 32+ characters each); unset makes POST /internal/events answer 503.events: {@@@ -29,7 +29,7 @@ const designHealth = require('../data/design-health');const CACHE = 'public, max-age=120';--function createApi({ network, live, analytics = null, aiUsage = null, config = {}, now = () => Date.now(), log = console }) {++function createApi({ network, live, analytics = null, aiUsage = null, aiExchange = null, config = {}, now = () => Date.now(), log = console }) {const r = express.Router();const serviceAuth = createServiceAuth({ config, now });@@@ -170,6 +170,27 @@ function createApi({ network, live, analytics = null, aiUsage = null, config = {}});++ // The AI Exchange (plan G-P9): Billing's settlements, summed by day, resolved model and provider access. Only++ // when REPORT_AI_EXCHANGE is on, and only for the staff gate the AI meter already uses. Aggregates only: no++ // person, no project, no prompt. Report never sets a rate, a price or a balance.++ r.get('/ai/exchange', async (req, res) => {++ if (!config.aiExchange || !config.aiExchange.enabled) return notFound(req, res, 'route.not_found', 'No such API route.');++ if (!aiExchange) return problem(req, res, 503, 'report.no_database', 'Report has no database in this process.');++ const v = await serviceAuth.authenticate(req);++ if (!v.ok) return problem(req, res, v.status, v.code, v.detail);++ if (!serviceAuth.hasCap(v.claims, CAPS.aiUsageRead)) {++ return problem(req, res, 403, 'capability.denied', 'report.ai_usage.read is not granted to this token.');++ }++ try {++ const body = await aiExchange.analytics({ from: req.query.from || null, to: req.query.to || null });++ return res.set('Cache-Control', 'no-store').json(body);++ } catch (err) {++ if (err instanceof RangeError) return problem(req, res, 422, 'ai.invalid', err.message);++ log.error('[OpenVibe.Report] the AI Exchange aggregates failed:', err && err.message ? String(err.message).slice(0, 200) : err);++ return problem(req, res, 500, 'report.ai_exchange_failed', 'The AI Exchange aggregates could not be read.');++ }++ });++r.use((req, res) => notFound(req, res, 'route.not_found', 'No such API route.'));return r;}@@@ -19,6 +19,7 @@* Markdown twin render the very same page; `agent: true` in those views adds the discovery links.*/const express = require('express');++const contracts = require('openvibe-contracts');const views = require('../views');const { normaliseDays, isSlug, isStreamId, isUsername, streamIdOf } = require('../data/source');const { twinPath } = require('../agent-pages');@@@ -123,6 +124,24 @@ function createPageRoutes(ctx) {return send(req, res, page.html, 200, 'no-store');});++ // The AI Exchange's daily aggregates (plan G-P9), staff-only, behind REPORT_AI_EXCHANGE. The gate is the signed-in++ // person's staff role (`staff.site.view`, the capability that covers analytics and money state), the same++ // openvibe-contracts staff map other staff pages use. The page is private and never cached.++ r.get('/ai/exchange', async (req, res) => {++ if (!ctx.config.aiExchange || !ctx.config.aiExchange.enabled) return notFound(req, res);++ if (!req.user) return send(req, res, views.staffOnly({ signedIn: false }), 401, 'private, no-store');++ if (!contracts.staff.can(req.user, 'staff.site.view')) return send(req, res, views.staffOnly({ signedIn: true }), 403, 'private, no-store');++ if (!ctx.aiExchange) return contracts.http.sendProblem(res, 503, 'report.no_database', { detail: 'Report has no database in this process.', ctx: req.ov });++ try {++ const body = await ctx.aiExchange.analytics({ from: req.query.from || null, to: req.query.to || null });++ return send(req, res, views.aiExchange(body), 200, 'private, no-store');++ } catch (err) {++ if (err instanceof RangeError) return contracts.http.sendProblem(res, 422, 'ai.invalid', { detail: err.message, ctx: req.ov });++ ctx.log.error('[OpenVibe.Report] the AI Exchange page failed:', err && err.message ? String(err.message).slice(0, 200) : err);++ return contracts.http.sendProblem(res, 500, 'report.ai_exchange_failed', { detail: 'The AI Exchange aggregates could not be read.', ctx: req.ov });++ }++ });++r.get('/ai/:kind', async (req, res) => {const page = await publicPages.aiBoard(req.params.kind, bench.normaliseTab(req.query.tab));if (!page) return notFound(req, res);@@@ -613,8 +613,84 @@ function aiModel({ model, summary }) {return layout({ title: `${model}: AI model`, description: `${model} across OpenVibe benchmark jobs: ratings, games and cost.`, body, active: '/ai', path: modelPath(model), agent: true });}++// ── The AI Exchange (plan G-P9): Billing's settlements, summed by day, model and access ────────────────────────++// Money is integer nano-USD and stays exact: this formatter works in BigInt, never in floats, and the title carries++// the exact nano-USD the authority sent.++function usdNano(v) {++ if (v == null) return '\u2014';++ let n;++ try { n = BigInt(String(v)); } catch { return '\u2014'; }++ const neg = n < 0n;++ const abs = neg ? -n : n;++ const whole = abs / 1_000_000_000n;++ const frac = (abs % 1_000_000_000n).toString().padStart(9, '0').replace(/0+$/, '');++ const digits = frac.length < 2 ? `${frac}00`.slice(0, 2) : frac;++ const group = whole.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');++ return `${neg ? '-' : ''}$${group}.${digits}`;++}++const nanoCell = (v) => (v == null ? '\u2014' : `<span title="${esc(String(v))} nano-USD from Billing">${esc(usdNano(v))}</span>`);++++function aiExchange({ range = {}, rows = [], daily = [], totals = {}, rejected = {} } = {}) {++ const t = totals || {};++ const tokens = t.tokens || {};++ const tokenTotal = (n(tokens.input) || 0) + (n(tokens.cached_read) || 0) + (n(tokens.cache_write) || 0) + (n(tokens.output) || 0);++ const rejectedCount = Number(rejected && rejected.count) || 0;++ const tiles = `<div class="rp-tiles rp-tiles-sm">++${tile('Settlements', exact(t.requests))}++${tile('Sandbox', exact(t.sandbox_requests), 'not money')}++${tile('Tokens', esc(bigNum(tokenTotal)))}++${tile('Retail', esc(usdNano(t.retail_nano_usd)))}++${tile('Shortfall', esc(usdNano(t.shortfall_nano_usd)), 'owed, not collected')}++${tile('Supply', esc(usdNano(t.supply_nano_usd)))}++${tile('Margin', esc(usdNano(t.margin_nano_usd)), 'retail minus supply')}++${rejectedCount ? tile('Rejected', exact(rejectedCount), 'invalid payloads') : ''}++</div>`;++ const table = rows.length ? kit.table({++ caption: 'By day, model and provider access',++ columns: [++ { key: 'day', label: 'Day', primary: true },++ { key: 'model', label: 'Model' },++ { key: 'access', label: 'Access' },++ { key: 'requests', label: 'Requests', num: true, fmt: 'int' },++ { key: 'sandbox_requests', label: 'Sandbox', num: true, fmt: 'int' },++ { key: 'input', label: 'Input', num: true, fmt: 'int' },++ { key: 'cached_read', label: 'Cache read', num: true, fmt: 'int' },++ { key: 'cache_write', label: 'Cache write', num: true, fmt: 'int' },++ { key: 'output', label: 'Output', num: true, fmt: 'int' },++ { key: 'retail', label: 'Retail', num: true, html: true },++ { key: 'shortfall', label: 'Shortfall', num: true, html: true },++ { key: 'supply', label: 'Supply', num: true, html: true },++ { key: 'margin', label: 'Margin', num: true, html: true },++ ],++ rows: rows.map((r) => ({++ day: r.day, model: r.model, access: r.provider_access, requests: r.requests, sandbox_requests: r.sandbox_requests,++ input: r.tokens.input, cached_read: r.tokens.cached_read, cache_write: r.tokens.cache_write, output: r.tokens.output,++ retail: nanoCell(r.retail_nano_usd), shortfall: nanoCell(r.shortfall_nano_usd), supply: nanoCell(r.supply_nano_usd),++ margin: nanoCell(r.margin_nano_usd),++ })),++ }) : `<p class="rp-fine">No settlements in this range. The table fills as Billing posts settlements; with ${esc('REPORT_AI_EXCHANGE')} off, nothing is consumed.</p>`;++ const head = kit.head({ title: 'AI Exchange', sub: 'What Billing settled, by day, model and provider access', sticky: false });++ const body = head++ + `<p class="rp-lede">Aggregates only: no person, no project, no prompt. Report reads the authorities and never sets a rate, a price or a balance. Retail is what was collected, shortfall is what is owed but not collected, supply is what the route cost, and the margin is retail minus supply. Sandbox settlements are counted apart and are never money.</p>++<p class="rp-fine">${esc(range.from || '')} to ${esc(range.to || '')}${range.days ? ` \u00b7 ${esc(range.days)} days` : ''}. Money is exact integer nano-USD from Billing; the exact amount is in each cell's title.</p>++${tiles}++${daily.length > 1 ? `<section aria-labelledby="rp-ex-chart"><h2 id="rp-ex-chart">Settlements a day</h2>${bars(daily.map((d) => ({ day: d.day, n: d.requests })), 'Settlements a day', { h: 110 })}</section>` : ''}++<section aria-labelledby="rp-ex-table"><h2 id="rp-ex-table">The daily table</h2>${table}</section>++<p class="rp-meta"><a href="/api/v1/ai/exchange">As JSON</a></p>`;++ return layout({ title: 'AI Exchange analytics', description: 'Billing settlements aggregated by day, model and provider access.', robots: 'noindex, nofollow', body, active: '/ai', path: '/ai/exchange' });++}++++/** The page a person sees when the staff gate refuses them: signed out asks for a sign-in, signed in says why. */++function staffOnly({ signedIn = false } = {}) {++ const text = signedIn++ ? 'Your OpenVibe.Network account does not hold staff.site.view. This page reads the network\'s AI Exchange analytics; ask an admin if you need it.'++ : 'This page is for OpenVibe.Network staff. Sign in with an account that holds staff.site.view.';++ const action = signedIn ? '' : '<p class="rp-meta"><a href="/auth/login?next=/ai/exchange">Sign in</a></p>';++ return layout({ title: 'Staff only', description: 'Staff only.', robots: 'noindex, nofollow', body: `<header class="rp-page-h"><p class="rp-kicker">OpenVibe.Report</p><h1>Staff only</h1><p class="rp-lede">${esc(text)}</p>${action}</header>` });++}++function notFound() {return layout({ title: 'Not found', robots: 'noindex, follow', body: `<header class="rp-page-h"><h1>Nothing here</h1><p class="rp-lede">That page does not exist, or its source has nothing for it. <a href="/">Back to Report</a>.</p></header>` });}--module.exports = { home, stats, rundown, topic, episode, speaker, recap, channel, you, youSignedOut, developers, aiIndex, aiBoard, aiModel, paretoRows, paretoChart, notFound, layout, setRelease, configure };++module.exports = { home, stats, rundown, topic, episode, speaker, recap, channel, you, youSignedOut, developers, aiIndex, aiBoard, aiModel, aiExchange, staffOnly, usdNano, paretoRows, paretoChart, notFound, layout, setRelease, configure };nnew file mode 100644@@@ -0,0 +1,267 @@++'use strict';++/**++ * The AI Exchange in Report (plan G-P9): the consumer of Billing's `billing.credit.settled` event, the aggregates and++ * the staff-only page + service-token API. Aggregates only; exact BigInt money; a settlement id counts once; sandbox++ * is not money; invalid payloads are rejected; the flag off consumes nothing.++ */++const assert = require('assert');++const crypto = require('crypto');++const contracts = require('openvibe-contracts');++const { signDeliveryV2 } = require('openvibe-sdk/events');++const { serviceAuth } = require('openvibe-contracts');++const { createSsoClient } = require('openvibe-sdk/sso');++const { boot, check, done } = require('./helpers/boot');++++const SECRET = 'test-report-events-secret-32-characters-long';++const ISSUER = 'https://openvibe.network';++const NOW = Date.parse('2026-11-04T12:00:00.000Z');++const MODEL_A = 'qwen/qwen3_4b:q4_k_m@3f2a1c9';++const MODEL_B = 'qwen/qwen3_coder_30b_a3b_instruct';++const MODEL_C = 'meta/llama_3_1_8b:q4_k_m@a1b2c3d';++const PERSON = 'usr_01JTESTPERSON000000000000';++const B32 = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';++++/** A valid ULID-shaped id for the fixtures (26 Crockford base32 characters). */++function ulid(n) {++ let s = '';++ for (let i = 0; i < 26; i += 1) { s = B32[n % 32] + s; n = Math.floor(n / 32); }++ return s;++}++++const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {++ modulusLength: 2048,++ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },++ publicKeyEncoding: { type: 'spki', format: 'pem' },++});++++const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');++/** A person's access token, the way OpenVibe.Network signs one (real time: the SSO verifier reads the clock). */++function personToken(claims) {++ const now = Math.floor(Date.now() / 1000);++ const head = b64({ alg: 'RS256', typ: 'JWT', kid: 'test' });++ const body = b64({ iss: ISSUER, aud: ['openvibe.network', 'openvibe.report'], iat: now, exp: now + 3600, ...claims });++ const sig = crypto.sign('RSA-SHA256', Buffer.from(`${head}.${body}`), privateKey).toString('base64url');++ return `${head}.${body}.${sig}`;++}++const STAFF_PERSON = personToken({ sub: '1', subject_id: PERSON, username: 'staffer', display_name: 'Staffer', role: 'admin' });++const PLAIN_PERSON = personToken({ sub: '2', subject_id: 'usr_01JTESTPERSON000000000002', username: 'visitor', display_name: 'Visitor', role: 'user' });++++/** A Network service token for the JSON endpoint (the existing report.ai_usage.read gate). */++function serviceToken(cap) {++ const now = Math.floor(NOW / 1000);++ return serviceAuth.signServiceToken({++ iss: ISSUER, sub: 'svc:operator', actor_type: 'service', aud: ['openvibe.report'], cap, ns: ['*'],++ iat: now, exp: now + 300, jti: 'tok_aiexchange_' + String(now).slice(-6),++ }, privateKey);++}++const SERVICE_STAFF = serviceToken(['report.ai_usage.read']);++const SERVICE_WRONG = serviceToken(['report.other.read']);++++const sso = createSsoClient({++ site: 'report', baseUrl: 'https://openvibe.report', clientId: 'report', clientSecret: 'x',++ networkUrl: ISSUER, publicKey,++ log: { warn() {}, info() {}, log() {}, error() {} },++});++++let seq = 0;++/** One `billing.credit.settled` event in the v0.287.0 payload shape. */++function settlementEvent(over = {}) {++ seq += 1;++ const { event_id, event_type, occurred_at, ...fields } = over;++ const payload = {++ settlement_id: `stl_${ulid(seq)}`,++ hold_id: `hld_${ulid(seq)}`,++ service: 'ai',++ settled_at: '2026-11-04T12:00:05.000Z',++ deployment_id: 'own:qwen/qwen3_4b:q4_k_m@3f2a1c9',++ provider_access: 'OWN_DEPLOYMENT',++ rate_version: 'rr-test-001',++ usage: { input_tokens: 1000, cached_read_tokens: 0, cache_write_tokens: 0, output_tokens: 500 },++ charge_nano_usd: '18000',++ supply_cost_nano_usd: '9000',++ shortfall_nano_usd: '0',++ released_nano_usd: '82000000',++ byok: false,++ sandbox: false,++ ...fields,++ };++ return {++ event_id: event_id || `evt_${ulid(seq)}`,++ event_type: event_type || 'billing.credit.settled',++ source: 'billing',++ occurred_at: occurred_at || payload.settled_at,++ payload,++ };++}++++function delivery(event) {++ const raw = JSON.stringify({ event });++ const ts = Math.floor(NOW / 1000);++ return { raw, headers: { 'content-type': 'application/json', 'x-openvibe-timestamp': String(ts), 'x-openvibe-signature-v2': signDeliveryV2(raw, SECRET, ts) } };++}++++(async () => {++ const t = await boot({ db: true, now: () => NOW, sso, env: { REPORT_AI_EXCHANGE: '1', REPORT_EVENTS_SECRET: SECRET, OV_NETWORK_PUBLIC_KEY: publicKey } });++ const db = t.ctx.store.db;++ const send = async (event) => {++ const d = delivery(event);++ return await t.get('/internal/events', { method: 'POST', headers: d.headers, body: d.raw });++ };++ try {++ await check('a v0.287.0 fixture aggregates by day, model and provider access, with exact money', async () => {++ assert.ok(contracts.validate('billing.credit.settled@1', settlementEvent().payload).valid, 'the test fixture matches the contract');++ const first = await send(settlementEvent());++ assert.strictEqual(first.status, 200, first.text);++ assert.strictEqual(first.json().outcome, 'settlement:recorded', first.text);++++ const second = await send(settlementEvent({++ charge_nano_usd: '41000', supply_cost_nano_usd: '21000', shortfall_nano_usd: '7',++ usage: { input_tokens: 2000, cached_read_tokens: 100, cache_write_tokens: 50, output_tokens: 100 },++ }));++ assert.strictEqual(second.json().outcome, 'settlement:recorded', second.text);++++ const third = await send(settlementEvent({++ deployment_id: `deepinfra:${MODEL_B}`, provider_access: 'AUTHORIZED_WHOLESALE',++ charge_nano_usd: '300', supply_cost_nano_usd: '100',++ usage: { input_tokens: 10, cached_read_tokens: 0, cache_write_tokens: 0, output_tokens: 20 },++ }));++ assert.strictEqual(third.json().outcome, 'settlement:recorded', third.text);++++ // The v0.287.0 byok-sandbox fixture: counted apart, never money.++ const sandbox = await send(settlementEvent({++ settled_at: '2026-11-05T12:00:07.000Z', deployment_id: null, provider_access: null, byok: true, sandbox: true,++ usage: { input_tokens: 0, cached_read_tokens: 0, cache_write_tokens: 0, output_tokens: 0 },++ charge_nano_usd: '0', supply_cost_nano_usd: '0', shortfall_nano_usd: '0',++ }));++ assert.strictEqual(sandbox.json().outcome, 'settlement:recorded', sandbox.text);++++ const r = await t.get('/api/v1/ai/exchange?from=2026-11-04&to=2026-11-05', { headers: { authorization: 'Bearer ' + SERVICE_STAFF } });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.headers.get('cache-control'), 'no-store');++ const body = r.json();++ assert.deepStrictEqual(body.range, { from: '2026-11-04', to: '2026-11-05', days: 2 });++ assert.strictEqual(body.rejected.count, 0, 'nothing invalid yet');++ const a = body.rows.find((x) => x.day === '2026-11-04' && x.model === MODEL_A && x.provider_access === 'OWN_DEPLOYMENT');++ assert.ok(a, 'the main bucket, keyed by the model after the deployment id\'s first colon');++ assert.strictEqual(a.requests, 2);++ assert.strictEqual(a.sandbox_requests, 0);++ assert.deepStrictEqual(a.tokens, { input: 3000, cached_read: 100, cache_write: 50, output: 600 });++ assert.strictEqual(a.retail_nano_usd, '59000');++ assert.strictEqual(a.shortfall_nano_usd, '7');++ assert.strictEqual(a.supply_nano_usd, '30000');++ assert.strictEqual(a.margin_nano_usd, '29000');++ const b = body.rows.find((x) => x.model === MODEL_B);++ assert.strictEqual(b.provider_access, 'AUTHORIZED_WHOLESALE');++ assert.strictEqual(b.margin_nano_usd, '200');++ const sandboxRow = body.rows.find((x) => x.day === '2026-11-05' && x.model === 'unknown' && x.provider_access === 'unknown');++ assert.ok(sandboxRow, 'the sandbox row is counted apart');++ assert.strictEqual(sandboxRow.sandbox_requests, 1);++ assert.strictEqual(sandboxRow.requests, 0, 'sandbox is not a money request');++ assert.strictEqual(sandboxRow.retail_nano_usd, '0');++ assert.strictEqual(sandboxRow.tokens.input, 0);++ assert.strictEqual(body.totals.requests, 3);++ assert.strictEqual(body.totals.sandbox_requests, 1);++ assert.strictEqual(body.totals.retail_nano_usd, '59300');++ assert.strictEqual(body.totals.shortfall_nano_usd, '7');++ assert.strictEqual(body.totals.supply_nano_usd, '30100');++ assert.strictEqual(body.totals.margin_nano_usd, '29200');++ assert.strictEqual(body.daily.length, 2, 'the chart has a day series');++ const text = JSON.stringify(body);++ for (const leak of ['usr_', 'prj_', 'subject']) assert.ok(!text.includes(leak), 'the aggregate names nobody: ' + leak);++ const cols = await db.many("SELECT column_name FROM information_schema.columns WHERE table_name = 'report_ai_exchange_days'");++ const names = cols.map((c) => c.column_name);++ for (const banned of ['subject', 'project_id', 'prompt', 'output_text']) assert.ok(!names.includes(banned), 'no column ' + banned);++ assert.strictEqual(await db.value("SELECT COUNT(*)::int AS n FROM report_events WHERE topic = 'billing.credit.settled'"), 0, 'the raw settlement is not stored');++ });++++ await check('an invalid payload is counted as rejected, never aggregated', async () => {++ const rows = await db.value('SELECT COUNT(*)::int AS n FROM report_ai_exchange_days');++ const seen = await db.value('SELECT COUNT(*)::int AS n FROM report_ai_exchange_seen');++ const bad = settlementEvent({ charge_nano_usd: 11 });++ const r = await send(bad);++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json().outcome, 'settlement:rejected', 'a money number is invalid');++ assert.strictEqual(await db.value('SELECT COUNT(*)::int AS n FROM report_ai_exchange_days'), rows, 'nothing aggregated');++ assert.strictEqual(await db.value('SELECT COUNT(*)::int AS n FROM report_ai_exchange_seen'), seen, 'the invalid settlement id is not claimed');++ assert.strictEqual(await db.value('SELECT SUM(events)::int AS n FROM report_ai_exchange_rejected'), 1);++ const api = await t.get('/api/v1/ai/exchange?from=2026-11-04&to=2026-11-04', { headers: { authorization: 'Bearer ' + SERVICE_STAFF } });++ assert.strictEqual(api.json().rejected.count, 1);++ assert.deepStrictEqual(api.json().rejected.by_day, [{ day: '2026-11-04', events: 1 }]);++ });++++ await check('a replayed settlement counts once, by its settlement id, even under a new event id', async () => {++ const before = await db.value("SELECT requests::text AS n FROM report_ai_exchange_days WHERE model = $1 AND provider_access = 'OWN_DEPLOYMENT'", [MODEL_A]);++ const event = settlementEvent({ settlement_id: `stl_${ulid(900)}`, hold_id: `hld_${ulid(900)}` });++ assert.strictEqual((await send(event)).json().outcome, 'settlement:recorded');++ const same = await send(event);++ assert.strictEqual(same.status, 200, same.text);++ assert.strictEqual(same.json().duplicate, true, 'the same event id is a duplicate');++ const otherEvent = await send({ ...event, event_id: `evt_${ulid(901)}` });++ assert.strictEqual(otherEvent.json().outcome, 'settlement:duplicate', 'the same settlement under a new event id is a duplicate');++ const after = await db.value("SELECT requests::text AS n FROM report_ai_exchange_days WHERE model = $1 AND provider_access = 'OWN_DEPLOYMENT'", [MODEL_A]);++ assert.strictEqual(Number(after) - Number(before), 1, 'the replay added exactly the new settlement');++ });++++ await check('money stays exact beyond 2^53, as decimal strings', async () => {++ await send(settlementEvent({++ settled_at: '2026-11-06T01:00:00.000Z', deployment_id: `own:${MODEL_C}`, charge_nano_usd: '9007199254740993',++ supply_cost_nano_usd: '1', shortfall_nano_usd: '2',++ usage: { input_tokens: 1, cached_read_tokens: 0, cache_write_tokens: 0, output_tokens: 1 },++ }));++ await send(settlementEvent({++ settled_at: '2026-11-06T02:00:00.000Z', deployment_id: `own:${MODEL_C}`, charge_nano_usd: '1',++ supply_cost_nano_usd: '1', shortfall_nano_usd: '0',++ usage: { input_tokens: 0, cached_read_tokens: 0, cache_write_tokens: 0, output_tokens: 0 },++ }));++ const r = await t.get('/api/v1/ai/exchange?from=2026-11-06&to=2026-11-06', { headers: { authorization: 'Bearer ' + SERVICE_STAFF } });++ const row = r.json().rows.find((x) => x.model === MODEL_C);++ assert.strictEqual(row.retail_nano_usd, '9007199254740994');++ assert.strictEqual(row.supply_nano_usd, '2');++ assert.strictEqual(row.shortfall_nano_usd, '2');++ assert.strictEqual(row.margin_nano_usd, '9007199254740992');++ assert.strictEqual(typeof row.retail_nano_usd, 'string', 'money is a decimal string, never a float');++ });++++ await check('the page is for signed-in staff; the JSON endpoint keeps its service-token gate', async () => {++ const anonymous = await t.get('/ai/exchange');++ assert.strictEqual(anonymous.status, 401, 'no session');++ assert.match(anonymous.text, /Staff only/);++ const plain = await t.get('/ai/exchange', { headers: { cookie: `ov_token=${PLAIN_PERSON}` } });++ assert.strictEqual(plain.status, 403, 'a signed-in non-staff person');++ const bearerOnly = await t.get('/ai/exchange', { headers: { authorization: 'Bearer ' + SERVICE_STAFF } });++ assert.strictEqual(bearerOnly.status, 401, 'the page does not accept the service token');++ const staff = await t.get('/ai/exchange?from=2026-11-04&to=2026-11-05', { headers: { cookie: `ov_token=${STAFF_PERSON}` } });++ assert.strictEqual(staff.status, 200, staff.text.slice(0, 300));++ assert.strictEqual(staff.headers.get('cache-control'), 'private, no-store');++ assert.match(staff.text, /AI Exchange/);++ assert.match(staff.text, /qwen\/qwen3_4b/);++ assert.match(staff.text, /<svg/);++ assert.match(staff.text, /\$0\.0000002/, 'the exact margin of the second model is printed');++ assert.match(staff.text, /200 nano-USD from Billing/, 'the exact nano amount is in the title');++++ const noToken = await t.get('/api/v1/ai/exchange');++ assert.strictEqual(noToken.status, 401, 'the endpoint without a service token');++ const wrong = await t.get('/api/v1/ai/exchange', { headers: { authorization: 'Bearer ' + SERVICE_WRONG } });++ assert.strictEqual(wrong.status, 403, 'the endpoint with the wrong capability');++ });++ } finally { await t.close(); }++++ // ── The flag is off: a settlement delivery is consumed by nothing, not even its event id ──++ const off = await boot({ db: true, now: () => NOW, sso, env: { REPORT_EVENTS_SECRET: SECRET } });++ try {++ await check('flag off consumes nothing and exposes no page or endpoint', async () => {++ const d = delivery(settlementEvent({ event_id: 'evt_AITEST0000000000000100' }));++ const r = await off.get('/internal/events', { method: 'POST', headers: d.headers, body: d.raw });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json().outcome, 'ignored:flag');++ for (const table of ['report_ai_exchange_days', 'report_ai_exchange_seen', 'report_ai_exchange_rejected', 'report_events', 'report_inbox']) {++ assert.strictEqual(await off.ctx.store.db.value(`SELECT COUNT(*)::int AS n FROM ${table}`), 0, table + ' stays empty');++ }++ const api = await off.get('/api/v1/ai/exchange', { headers: { authorization: 'Bearer ' + SERVICE_STAFF } });++ assert.strictEqual(api.status, 404, 'the endpoint does not exist while the flag is off');++ const page = await off.get('/ai/exchange', { headers: { cookie: `ov_token=${STAFF_PERSON}` } });++ assert.strictEqual(page.status, 404, 'the page does not exist while the flag is off');++ });++ } finally { await off.close(); }++ done();++})().catch((e) => { console.error(e); process.exit(1); });@@@ -161,6 +161,7 @@ async function boot(opts = {}) {const store = opts.store || (testdatabase ? createStore(testdatabase.db, { now: opts.now }) : null);const built = await createApp({config,++ env,store,fetchImpl,log,nnew file mode 100644@@@ -0,0 +1,104 @@++'use strict';++/**++ * D11 owner settings: the report.ai_exchange namespace and /internal/config.++ * Env pins win; an owner change applies live (the ingest consumer and the staff page); report.config.manage++ * is required; no KEK still works; a secret-class value never leaves a summary, snapshot or history.++ */++const assert = require('assert');++const crypto = require('crypto');++const { serviceAuth } = require('openvibe-contracts');++const { boot, check, done } = require('./helpers/boot');++++const ISSUER = 'https://openvibe.network';++const OWNER = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';++const SECRET = 'sk_report_d11_toppsecret_123456';++const CAP = ['report.config.manage', 'report.ai_usage.read'];++const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {++ modulusLength: 2048,++ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },++ publicKeyEncoding: { type: 'spki', format: 'pem' },++});++const now = Math.floor(Date.now() / 1000);++const token = () => serviceAuth.signServiceToken({++ iss: ISSUER, sub: 'svc:network', actor_type: 'service', aud: ['openvibe.report'], cap: CAP,++ iat: now, exp: now + 300, jti: 'tok_report_config',++}, privateKey);++const bearer = () => ({ authorization: 'Bearer ' + token() });++++(async () => {++ const t = await boot({ db: true, env: { REPORT_AI_EXCHANGE: '1', OV_NETWORK_PUBLIC_KEY: publicKey } });++ try {++ await check('the summary: schema, the env pin, sealed:false without a KEK', async () => {++ const r = await t.get('/internal/config/report.ai_exchange', { headers: bearer() });++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));++ assert.strictEqual(r.json().namespace, 'report.ai_exchange');++ assert.strictEqual(r.json().sealed, false);++ assert.strictEqual(r.json().values.enabled, true);++ assert.deepStrictEqual(r.json().pins.enabled, { env: 'REPORT_AI_EXCHANGE', pinned: true });++ assert.ok(!r.text.includes(SECRET));++ });++++ await check('a service token without report.config.manage is refused', async () => {++ const wrong = serviceAuth.signServiceToken({++ iss: ISSUER, sub: 'svc:operator', actor_type: 'service', aud: ['openvibe.report'], cap: ['report.ai_usage.read'],++ iat: now, exp: now + 300, jti: 'tok_report_wrong',++ }, privateKey);++ const r = await t.get('/internal/config/report.ai_exchange', { headers: { authorization: 'Bearer ' + wrong } });++ assert.strictEqual(r.status, 403);++ assert.strictEqual(r.json().code, 'capability.denied');++ });++++ await check('an env-pinned key is read-only', async () => {++ const r = await t.get('/internal/config/report.ai_exchange', {++ method: 'POST', headers: { ...bearer(), 'x-ov-subject': OWNER }, json: { values: { enabled: false }, reason: 'off' },++ });++ assert.strictEqual(r.status, 409, r.text.slice(0, 300));++ assert.strictEqual(r.json().code, 'config.pinned_in_env');++ });++ } finally { await t.close(); }++++ const live = await boot({ db: true, env: { OV_NETWORK_PUBLIC_KEY: publicKey } });++ try {++ await check('an owner change applies live: the staff page and the JSON API open', async () => {++ assert.strictEqual((await live.get('/api/v1/ai/exchange?from=2026-11-04&to=2026-11-05', { headers: bearer() })).status, 404);++ const r = await live.get('/internal/config/report.ai_exchange', {++ method: 'POST', headers: { ...bearer(), 'x-ov-subject': OWNER }, json: { values: { enabled: true }, reason: 'owner turned it on' },++ });++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));++ assert.strictEqual(r.json().revision, 2);++ assert.strictEqual(live.config.aiExchange.enabled, true);++ const got = await live.get('/api/v1/ai/exchange?from=2026-11-04&to=2026-11-05', { headers: bearer() });++ assert.strictEqual(got.status, 200, got.text.slice(0, 300));++ assert.ok(live.logs().includes('by user:' + OWNER));++ assert.ok(!live.logs().includes(SECRET));++ });++++ await check('with a KEK the store is sealed and boot reseals', async () => {++ const sealed = await boot({ db: true, env: { OV_NETWORK_PUBLIC_KEY: publicKey, OV_CONFIG_KEKS: 'k1:MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=', OV_CONFIG_KEK_CURRENT: 'k1' } });++ try {++ const r = await sealed.get('/internal/config/report.ai_exchange', { headers: bearer() });++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));++ assert.strictEqual(r.json().sealed, true);++ assert.strictEqual(r.json().kek, 'k1');++ } finally { await sealed.close(); }++ });++++ await check('a secret is write-only and never appears in a summary, snapshot or history', async () => {++ const shared = require('openvibe-shared/config');++ const extra = await shared.createConfigStore({++ db: live.ctx.store.db, service: 'report', namespace: 'report.test_secrets',++ schema: { type: 'object', additionalProperties: false, properties: { api_key: { type: 'string' } } },++ classify: () => 'secret',++ });++ const marker = await extra.setSecret('api_key', SECRET, { actor: { type: 'user', id: OWNER }, reason: 'test' });++ assert.strictEqual(marker.redacted, true);++ assert.ok(!JSON.stringify(marker).includes(SECRET));++ const summary = await extra.summary();++ assert.strictEqual(summary.values.api_key.redacted, true);++ assert.ok(!JSON.stringify(summary).includes(SECRET));++ assert.ok(!JSON.stringify(await extra.history()).includes(SECRET));++ assert.ok(!JSON.stringify(await extra.snapshot(1)).includes(SECRET));++ });++ } finally { await live.close(); }++ done();++})().catch((e) => { console.error(e); process.exit(1); });
@@ -39,6 +39,11 @@ OV_OAUTH_CLIENT_SECRET=
@@ -39,6 +39,11 @@ OV_OAUTH_CLIENT_SECRET=
# rotation, 32+ characters each; unset makes the route answer 503.
# rotation, 32+ characters each; unset makes the route answer 503.
REPORT_EVENTS_SECRET=
REPORT_EVENTS_SECRET=
+# AI Exchange analytics (plan G-P9), off by default. On: consume Billing's billing.credit.settled events into
+# aggregates by day/model/provider access, and open the staff-only /ai/exchange page and GET /api/v1/ai/exchange.
+# Off: a settlement delivery is ignored before anything is written.
+#REPORT_AI_EXCHANGE=0
+
# The ingest and shadow jobs (on by default; REPORT_JOBS=0 turns both off).
# The ingest and shadow jobs (on by default; REPORT_JOBS=0 turns both off).
#REPORT_JOBS=1
#REPORT_JOBS=1
#REPORT_JOBS_ON_BOOT=1
#REPORT_JOBS_ON_BOOT=1
@@ -20,6 +20,9 @@ OpenVibe.Report is the analytics authority in shadow:
@@ -20,6 +20,9 @@ OpenVibe.Report is the analytics authority in shadow:
PostgreSQL tables (migrations/).
PostgreSQL tables (migrations/).
- It **computes** the same numbers Network answers, site-wide, per site and per channel, by UTC day.
- It **computes** the same numbers Network answers, site-wide, per site and per channel, by UTC day.
- It **quotes money from OpenVibe.Billing** through Billing's read API; it never derives a price, fee or amount.
- It **quotes money from OpenVibe.Billing** through Billing's read API; it never derives a price, fee or amount.
+- It **shadows the AI Exchange** behind `REPORT_AI_EXCHANGE` (off): Billing's `billing.credit.settled@1`
+ settlements are summed by UTC day, resolved exact model id and provider access. The raw settlement is not stored,
+ and Report never sets a rate, a price or a balance.
- It **shadows**: a job compares the same window from Network and from Report's tables and records every difference
- It **shadows**: a job compares the same window from Network and from Report's tables and records every difference
in `report_shadow_diffs`.
in `report_shadow_diffs`.
@@ -35,6 +38,7 @@ OpenVibe.Live in both modes.
@@ -35,6 +38,7 @@ OpenVibe.Live in both modes.
| **OpenVibe.Live** — after-show reports | `GET /api/recap/:streamId`, `GET /api/recap/channel/:username` | `/after-show/:streamId`, `/c/:username` |
| **OpenVibe.Live** — after-show reports | `GET /api/recap/:streamId`, `GET /api/recap/channel/:username` | `/after-show/:streamId`, `/c/:username` |
| **OpenVibe.Bench** | `GET /api/v1/kinds`, `/api/v1/boards/:kind?type=elo\|pareto\|cost`, `/api/v1/models/:model` | `/ai`, `/ai/:kind`, `/ai/models/:model` |
| **OpenVibe.Bench** | `GET /api/v1/kinds`, `/api/v1/boards/:kind?type=elo\|pareto\|cost`, `/api/v1/models/:model` | `/ai`, `/ai/:kind`, `/ai/models/:model` |
| **OpenVibe.Billing** | `GET /api/v1/stats`, `GET /api/v1/transactions?subject=…` | the revenue read for a channel; network money counts |
| **OpenVibe.Billing** | `GET /api/v1/stats`, `GET /api/v1/transactions?subject=…` | the revenue read for a channel; network money counts |
+| **OpenVibe.Billing** — settlements, through Events | `billing.credit.settled@1` on `POST /internal/events` (only with `REPORT_AI_EXCHANGE=1`) | the AI Exchange aggregates and the staff page/API |
A source that does not answer is null, after one short log line; the page (or API route) becomes a 404. The service
A source that does not answer is null, after one short log line; the page (or API route) becomes a 404. The service
never invents a zero and never throws a source error at the reader.
never invents a zero and never throws a source error at the reader.
@@ -44,8 +48,14 @@ never invents a zero and never throws a source error at the reader.
@@ -44,8 +48,14 @@ never invents a zero and never throws a source error at the reader.
- **The analytics tables** in `migrations/0001_initial.sql`: `report_inbox` (event ids already handled),
- **The analytics tables** in `migrations/0001_initial.sql`: `report_inbox` (event ids already handled),
`report_events`, `report_streams`, `report_frame_days`, `report_metric_days`, `report_metric_windows`,
`report_events`, `report_streams`, `report_frame_days`, `report_metric_days`, `report_metric_windows`,
`report_metric_json`, `report_dimensions`, `report_shadow_runs` and `report_shadow_diffs`.
`report_metric_json`, `report_dimensions`, `report_shadow_runs` and `report_shadow_diffs`.
+- **The AI meter** in `migrations/0002_ai_usage.sql` and `0003_ai_usage_surface.sql`: `report_ai_usage` (raw calls,
+ kept 90 days) and `report_ai_usage_days`; the consumer is `server/analytics/ai-usage.js`.
+- **The AI Exchange aggregates** in `migrations/0005_ai_exchange.sql`: `report_ai_exchange_days` (day, model,
+ provider access, requests, sandbox requests, the four token counts, exact nano-USD retail/shortfall/supply/margin),
+ `report_ai_exchange_seen` (settlement ids already counted) and `report_ai_exchange_rejected` (invalid payloads).
+ The consumer is `server/analytics/ai-exchange.js`.
- **The computation** in `server/analytics/`: `ingest.js` (idempotent writes), `query.js` (Network's stats shape and
- **The computation** in `server/analytics/`: `ingest.js` (idempotent writes), `query.js` (Network's stats shape and
- a channel's creator analytics), `shadow.js` (the comparison).
+ a channel's creator analytics), `shadow.js` (the comparison), `ai-exchange.js` (the Exchange aggregates).
- **The jobs** in `server/jobs/analytics.js`: ingest every five minutes (7, 30 and 90-day windows) and shadow every
- **The jobs** in `server/jobs/analytics.js`: ingest every five minutes (7, 30 and 90-day windows) and shadow every
fifteen minutes (7 days by default).
fifteen minutes (7 days by default).
@@ -76,6 +86,7 @@ All server-rendered in `server/views/index.js`; every dynamic value goes through
@@ -76,6 +86,7 @@ All server-rendered in `server/views/index.js`; every dynamic value goes through
| `/ai` | benchmark kinds grouped by room, with leaders and game counts |
| `/ai` | benchmark kinds grouped by room, with leaders and game counts |
| `/ai/:kind` | Elo, Pareto quality against cost per solved, and cost per solved tables |
| `/ai/:kind` | Elo, Pareto quality against cost per solved, and cost per solved tables |
| `/ai/models/:model` | a model's ratings and entries across kinds |
| `/ai/models/:model` | a model's ratings and entries across kinds |
+| `/ai/exchange` | **staff only**, with `REPORT_AI_EXCHANGE=1`: Billing's settlements by day, model and access, a daily chart and the exact amounts. Needs a signed-in person whose staff role holds `staff.site.view` |
A bad path parameter (slug, stream id, username) or a source that answered nothing renders `views.notFound()` with
A bad path parameter (slug, stream id, username) or a source that answered nothing renders `views.notFound()` with
status 404. Pages are `Cache-Control: public, max-age=120`.
status 404. Pages are `Cache-Control: public, max-age=120`.
@@ -98,6 +109,14 @@ GET /api/v1/c/:username
@@ -98,6 +109,14 @@ GET /api/v1/c/:username
A bad parameter or a missing source answer is a 404 `application/problem+json` body built with
A bad parameter or a missing source answer is a 404 `application/problem+json` body built with
`openvibe-contracts` `http.sendProblem`. The public API is read-only; other methods get 405.
`openvibe-contracts` `http.sendProblem`. The public API is read-only; other methods get 405.
+The AI Exchange JSON read is the staff exception (with `REPORT_AI_EXCHANGE=1`): a Network service token with the
+same `report.ai_usage.read` capability the AI meter uses, never cached, no person or project in the answer. The
+HTML page is gated by the signed-in person's staff role (`staff.site.view`) instead, like the other staff pages.
+
+```
+GET /api/v1/ai/exchange?from=YYYY-MM-DD&to=YYYY-MM-DD the Exchange aggregates, staff only
+```
+
### Internal (loopback only)
### Internal (loopback only)
```
```
@@ -121,7 +140,13 @@ nginx refuses `/internal/` from outside; the app also checks the socket is loopb
@@ -121,7 +140,13 @@ nginx refuses `/internal/` from outside; the app also checks the socket is loopb
keyed by the answer's content hash. Polling the same numbers again is a no-op.
keyed by the answer's content hash. Polling the same numbers again is a no-op.
- Every delivery is claimed in `report_inbox` (openvibe-sdk/events `createPgInbox`) in the same transaction as its
- Every delivery is claimed in `report_inbox` (openvibe-sdk/events `createPgInbox`) in the same transaction as its
writes.
writes.
-- Billing events are deliberately not ingested: money comes from Billing's read API.
+- `billing.credit.settled` (with `REPORT_AI_EXCHANGE=1`) → `report_ai_exchange_days`, keyed by day, model and
+ provider access, and `report_ai_exchange_seen` by settlement id. The payload is validated against
+ `billing.credit.settled@1`; an invalid one is counted in `report_ai_exchange_rejected` and never aggregated.
+ Replaying the same settlement under a new event id counts once. A sandbox settlement only increments
+ `sandbox_requests`. The raw settlement is never stored. With the flag off, the delivery is ignored before anything
+ is written.
+- Billing's other money still comes from Billing's read API, never from an event.
## Configuration
## Configuration
@@ -146,6 +171,7 @@ Every value comes from the environment (production: `/etc/openvibe/report.env`).
@@ -146,6 +171,7 @@ Every value comes from the environment (production: `/etc/openvibe/report.env`).
| `OV_OAUTH_CLIENT_ID` / `OV_OAUTH_CLIENT_SECRET` | `report` / unset | Report's client-credentials token for `openvibe.billing` |
| `OV_OAUTH_CLIENT_ID` / `OV_OAUTH_CLIENT_SECRET` | `report` / unset | Report's client-credentials token for `openvibe.billing` |
| `REPORT_BILLING_TOKEN` | unset | an explicit Billing bearer token for an operator run |
| `REPORT_BILLING_TOKEN` | unset | an explicit Billing bearer token for an operator run |
| `REPORT_EVENTS_SECRET` | unset | the HMAC secret for `POST /internal/events` (32+ chars, comma list for rotation) |
| `REPORT_EVENTS_SECRET` | unset | the HMAC secret for `POST /internal/events` (32+ chars, comma list for rotation) |
+| `REPORT_AI_EXCHANGE` | `0` | `1` consumes Billing's `billing.credit.settled` events into the AI Exchange aggregates and opens `/ai/exchange` and `GET /api/v1/ai/exchange`; off consumes nothing |
| `REPORT_JOBS` | `1` | `0` turns the ingest and shadow jobs off |
| `REPORT_JOBS` | `1` | `0` turns the ingest and shadow jobs off |
| `REPORT_JOBS_ON_BOOT` | `1` | run both jobs once shortly after boot |
| `REPORT_JOBS_ON_BOOT` | `1` | run both jobs once shortly after boot |
| `REPORT_INGEST_INTERVAL_MS` | `300000` | how often the ingest job runs |
| `REPORT_INGEST_INTERVAL_MS` | `300000` | how often the ingest job runs |
@@ -167,7 +193,8 @@ Most tests stub both sources and pass `store: null`, so they need no database. T
@@ -167,7 +193,8 @@ Most tests stub both sources and pass `store: null`, so they need no database. T
`test/helpers/db.js` (`createTestDb` from openvibe-sdk/testing) with a migrated PGlite, or the PostgreSQL containers
`test/helpers/db.js` (`createTestDb` from openvibe-sdk/testing) with a migrated PGlite, or the PostgreSQL containers
with `REPORT_TEST_STORE=pg`. They cover ingest idempotency, every metric against a fixture's expected numbers, the
with `REPORT_TEST_STORE=pg`. They cover ingest idempotency, every metric against a fixture's expected numbers, the
shadow job's difference and clean run, `GET /internal/shadow`, the source flag, the Billing revenue read with a stub,
shadow job's difference and clean run, `GET /internal/shadow`, the source flag, the Billing revenue read with a stub,
-and the existing pages/API/health/crawl behaviour.
+the AI Exchange aggregation/validation/replay/BigInt/sandbox/staff-gate/flag-off cases, and the existing
+pages/API/health/crawl behaviour.
`node --check` is run over `server/`, `test/` and `public/` by CI (`.github/workflows/ci.yml`, the shared
`node --check` is run over `server/`, `test/` and `public/` by CI (`.github/workflows/ci.yml`, the shared
OpenVibe.Shared test workflow).
OpenVibe.Shared test workflow).
@@ -200,12 +227,13 @@ When `DATABASE_URL` is configured, the database check is included and is require
@@ -200,12 +227,13 @@ When `DATABASE_URL` is configured, the database check is included and is require
## Security
## Security
Every source text is escaped before it reaches HTML. Path parameters are checked against fixed patterns before any
Every source text is escaped before it reaches HTML. Path parameters are checked against fixed patterns before any
-request. Report holds no accounts and no cookies. Its database has public channel facts and counts only; event
-payloads with money or personal data are not stored. `/internal/` is loopback-only, and `POST /internal/events`
-verifies the Events v2 HMAC. See [SECURITY.md](SECURITY.md).
+request. Report's own sign-in serves `/you` and the staff page, which reads the signed-in person's staff role. Its
+database has public channel facts, the AI meter and aggregate AI Exchange numbers; a raw settlement (a person, a
+project, money) is never stored. `/internal/` is loopback-only, and `POST /internal/events` verifies the Events v2
+HMAC. See [SECURITY.md](SECURITY.md).
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.275.0
+- openvibe-contracts: v0.287.0
- openvibe-sdk: v0.82.0
- openvibe-sdk: v0.82.0
- openvibe-shared: v4.109.2
- openvibe-shared: v4.109.2
- openvibe-publishing: v1.5.0
- openvibe-publishing: v1.5.0
new file mode 100644
new file mode 100644
@@ -0,0 +1,43 @@
@@ -0,0 +1,43 @@
+-- phase: expand
+-- AI Exchange analytics (plan G-P9): Billing's credit settlements, summed by UTC day, exact model id and provider
+-- access. Aggregates only: no person, no project, no prompt and no answer is stored here. Report reads the
+-- authorities (Billing settles and names the model in the event); it never sets a rate, a price or a balance.
+
+-- One row per day, resolved model id and provider access. Money is integer nano-USD (D1) in BIGINT: what was
+-- collected (retail), what is owed but not collected (shortfall), what the route cost (supply) and the margin
+-- (retail minus supply). A sandbox settlement is not money: it only increments sandbox_requests.
+CREATE TABLE report_ai_exchange_days (
+ day date NOT NULL,
+ model text COLLATE "C" NOT NULL, -- the exact id after deployment_id's first ":", or 'unknown'
+ provider_access text COLLATE "C" NOT NULL, -- OWN_DEPLOYMENT | AUTHORIZED_WHOLESALE | CUSTOMER_BYOK | RESTRICTED | UNVERIFIED | unknown
+ requests bigint NOT NULL DEFAULT 0,
+ sandbox_requests bigint NOT NULL DEFAULT 0, -- usd_credit_sandbox settlements: counted apart, never money
+ tokens_input bigint NOT NULL DEFAULT 0,
+ tokens_cached_read bigint NOT NULL DEFAULT 0,
+ tokens_cache_write bigint NOT NULL DEFAULT 0,
+ tokens_output bigint NOT NULL DEFAULT 0,
+ retail_nano_usd bigint NOT NULL DEFAULT 0, -- charge_nano_usd, what was collected
+ shortfall_nano_usd bigint NOT NULL DEFAULT 0, -- owed, not collected
+ supply_nano_usd bigint NOT NULL DEFAULT 0,
+ margin_nano_usd bigint GENERATED ALWAYS AS (retail_nano_usd - supply_nano_usd) STORED,
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ PRIMARY KEY (day, model, provider_access)
+);
+CREATE INDEX report_ai_exchange_days_day ON report_ai_exchange_days (day);
+
+-- The settlement ids already counted, so a redelivered settlement (even under a new event id) counts once. It carries
+-- no subject, no project and no amount: just the id and the day it was counted on.
+CREATE TABLE report_ai_exchange_seen (
+ settlement_id text COLLATE "C" PRIMARY KEY,
+ day date NOT NULL,
+ received_at timestamptz NOT NULL DEFAULT now()
+);
+
+-- A payload that does not match billing.credit.settled@1 is counted here, by the day it claims, and never aggregated.
+CREATE TABLE report_ai_exchange_rejected (
+ day date NOT NULL,
+ reason text COLLATE "C" NOT NULL,
+ events bigint NOT NULL DEFAULT 0,
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ PRIMARY KEY (day, reason)
+);
new file mode 100644
new file mode 100644
@@ -0,0 +1,28 @@
@@ -0,0 +1,28 @@
+-- phase: expand
+-- D11 owner settings: openvibe-shared/config (config_snapshots, config_keys). Applied migrations are immutable.
+CREATE TABLE IF NOT EXISTS config_snapshots (
+ namespace text COLLATE "C" NOT NULL,
+ revision bigint NOT NULL,
+ service text COLLATE "C" NOT NULL,
+ previous_revision bigint,
+ state text COLLATE "C" NOT NULL CHECK (state IN ('proposed', 'active', 'superseded', 'rejected', 'rolled_back')),
+ values_json text COLLATE "C" NOT NULL, -- the full values; secret-class values are envelopes (never their plaintext)
+ classification_json text COLLATE "C" NOT NULL,
+ values_checksum text COLLATE "C" NOT NULL, -- sha256 of the values as served (secrets as markers)
+ created_at text COLLATE "C" NOT NULL,
+ created_by text COLLATE "C" NOT NULL,
+ activated_at text COLLATE "C",
+ activated_by text COLLATE "C",
+ reason text COLLATE "C",
+ error text COLLATE "C",
+ copied_from bigint,
+ good bigint NOT NULL DEFAULT 0, -- 1 once it activated successfully (last-known-good)
+ PRIMARY KEY (namespace, revision)
+);
+CREATE UNIQUE INDEX IF NOT EXISTS config_snapshots_one_active ON config_snapshots (namespace) WHERE state = 'active';
+-- The fingerprint key of each namespace. Never served, never logged.
+CREATE TABLE IF NOT EXISTS config_keys (
+ namespace text COLLATE "C" PRIMARY KEY,
+ hmac_key bytea NOT NULL, -- the 32-byte fingerprint key, or that key wrapped under the current KEK
+ created_at text COLLATE "C" NOT NULL
+);
@@ -13,10 +13,10 @@
@@ -13,10 +13,10 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.275.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.288.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.5.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.5.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.109.2",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.110.0",
"pg": "^8.23.1"
"pg": "^8.23.1"
},
},
"devDependencies": {
"devDependencies": {
@@ -1057,9 +1057,9 @@
@@ -1057,9 +1057,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.275.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.275.0",
- "integrity": "sha512-s8D3Upj5lfCGSnTVXKEWyzDEG5b7QnY/O2tAPieaZgFhzM4rnZyboGCZv3qthVOZ1ibxx/ZTEtS9nxZ/AVL+Hg==",
+ "version": "0.288.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.288.0",
+ "integrity": "sha512-AKRP1yXACYNu7okpf4kCDq4ds0PhsoNvD92VlvBWBCjcSlNimSTQhLWIvSa3D31/kQYz91fodo06ZnwuTanOqg==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -1119,9 +1119,9 @@
@@ -1119,9 +1119,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "4.109.2",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.109.2",
- "integrity": "sha512-t9K1yHr6GOJsfamqmgERRTTtiWYWAVmjWBEm4ykTcYjEenbTiqQTwX6CJsZW4lYnqS1IlqEvn+0jG4vXmHSJ3Q==",
+ "version": "4.110.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.110.0",
+ "integrity": "sha512-ZNOSraSgE89jhLTI//lFJALvKCiBJIBwf+V0aCkiIk0VCrPPAytsfsgOgCINU8kXDVdCEnuWWfcA9hZUc54QVA==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -19,10 +19,10 @@
@@ -19,10 +19,10 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.275.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.288.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.5.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.5.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.109.2",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.110.0",
"pg": "^8.23.1"
"pg": "^8.23.1"
},
},
"engines": {
"engines": {
new file mode 100644
new file mode 100644
@@ -0,0 +1,86 @@
@@ -0,0 +1,86 @@
+'use strict';
+/**
+ * The `report.ai_exchange` namespace (D11 owner settings): the switch the owner changes from openvibe.network's
+ * AI tab. REPORT_AI_EXCHANGE stays the pin: while it is set it wins, is read-only in the summary and a write
+ * answers 409 config.pinned_in_env.
+ *
+ * /internal/config is openvibe-shared/config's adminRoutes, guarded by report.config.manage (Network's service
+ * token only). actor is the owner Network names in X-OV-Subject; X-OV-Fresh: 1 gates a secret or x-danger write
+ * (this namespace has neither). Every activation writes one audit line with the actor and the keys, never a value.
+ */
+const express = require('express');
+const shared = require('openvibe-shared/config');
+const { http, ids } = require('openvibe-contracts');
+const { createServiceAuth } = require('./auth');
+
+const SERVICE = 'report';
+const NAMESPACE = 'report.ai_exchange';
+const CAPABILITY = 'report.config.manage';
+
+const SCHEMA = {
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
+ title: 'AI Exchange analytics',
+ description: 'Consume Billing\'s billing.credit.settled events into the AI Exchange aggregates, and open the staff page and API.',
+ type: 'object',
+ additionalProperties: false,
+ required: ['enabled'],
+ properties: {
+ enabled: {
+ type: 'boolean', title: 'AI Exchange analytics', 'x-group': 'AI Exchange', 'x-env': 'REPORT_AI_EXCHANGE',
+ description: 'Record each settlement as aggregates by day, model and provider access (never the raw settlement) and serve /ai/exchange and GET /api/v1/ai/exchange to staff.',
+ },
+ },
+};
+const DEFAULTS = Object.freeze({ enabled: false });
+
+function actor(req) {
+ const named = String((req.headers && req.headers['x-ov-subject']) || '').trim();
+ return ids.isSubjectId('user', named) ? { type: 'user', id: named } : null;
+}
+
+const requireFresh = (req) => String((req.headers && req.headers['x-ov-fresh']) || '') === '1';
+
+function capabilityGuard({ serviceAuth, log }) {
+ return async function guard(req, res, next) {
+ try {
+ const r = await serviceAuth.authenticate(req);
+ if (!r.ok) return http.sendProblem(res, r.status, r.code, { detail: r.detail, ctx: req.ov });
+ const sub = String((r.claims && r.claims.sub) || '');
+ if (!sub.startsWith('svc:')) return http.sendProblem(res, 403, 'capability.denied', { detail: `${CAPABILITY} is first-party; an app may not change Report configuration`, ctx: req.ov });
+ if (!serviceAuth.hasCap(r.claims, CAPABILITY)) return http.sendProblem(res, 403, 'capability.denied', { detail: `${CAPABILITY} not granted`, ctx: req.ov });
+ req.configClaims = r.claims;
+ return next();
+ } catch (err) {
+ log.error(`[config] report.ai_exchange guard failed: ${(err && err.message) || err}`);
+ return http.sendProblem(res, 500, 'internal.error', { detail: 'the configuration request failed', ctx: req.ov });
+ }
+ };
+}
+
+async function create({ db, config, env = process.env, log = console, now = () => Date.now(), onActivate = null }) {
+ // REPORT_AI_EXCHANGE has always accepted anything but 0/false/no/off; the shared store's boolean pin reads true/1.
+ const storeEnv = { ...env };
+ const rawReportFlag = String(storeEnv.REPORT_AI_EXCHANGE || '');
+ if (rawReportFlag && !/^(0|false|no|off)$/i.test(rawReportFlag)) storeEnv.REPORT_AI_EXCHANGE = '1';
+ const store = await shared.createConfigStore({
+ db, service: SERVICE, namespace: NAMESPACE, schema: SCHEMA, defaults: DEFAULTS, env: storeEnv, log,
+ onActivate: async (values) => { if (onActivate) await onActivate(values); },
+ onChange: ({ namespace, keys, actor: who, revision }) => {
+ log.info(`[config] ${SERVICE} ${namespace} revision ${revision} by ${who && who.id ? `${who.type}:${who.id}` : 'unknown'}: ${keys.join(', ')}`);
+ },
+ });
+ if (env.OV_CONFIG_KEKS) {
+ try { await store.reseal(); } catch (err) { log.warn(`[config] ${NAMESPACE}: reseal failed: ${(err && err.message) || err}`); }
+ }
+ const serviceAuth = createServiceAuth({ config, now });
+ const router = shared.adminRoutes([store], {
+ basePath: '/internal/config', requireAdmin: capabilityGuard({ serviceAuth, log }), actor, requireFresh, log,
+ });
+ return {
+ store, router,
+ get: (key) => store.get(key),
+ mount(app) { app.use('/internal/config', express.json({ limit: '128kb' })); router.mount(app); },
+ };
+}
+
+module.exports = { create, SCHEMA, NAMESPACE, CAPABILITY };
new file mode 100644
new file mode 100644
@@ -0,0 +1,242 @@
@@ -0,0 +1,242 @@
+'use strict';
+/**
+ * AI Exchange analytics (plan G-P9): the settlements OpenVibe.Billing posts, summed by UTC day, resolved exact model
+ * id and provider access.
+ *
+ * The event is `billing.credit.settled` (OpenVibe.Contracts v0.287.0, payload `billing.credit.settled@1`). Report
+ * reads the authorities and keeps only aggregates: requests, sandbox requests, the four token counts, the retail
+ * charge, the shortfall, the supply cost and the margin (retail minus supply). A settlement is counted once by its
+ * settlement id, so a redelivered event — even under a new event id — changes nothing. No person, no project, no
+ * prompt and no answer is written here, and Report never sets a rate, a price or a balance.
+ *
+ * The payload is validated against the contract before it is counted: an invalid one is counted as rejected and never
+ * aggregated. Sandbox settlements (`sandbox: true`) are not money: they only increment `sandbox_requests`.
+ */
+
+const contracts = require('openvibe-contracts');
+
+const TOPIC = 'billing.credit.settled';
+const CONTRACT = 'billing.credit.settled@1';
+const DAY = /^\d{4}-\d{2}-\d{2}$/;
+const DAY_MS = 86400000;
+const DEFAULT_RANGE_DAYS = 30;
+const MAX_RANGE_DAYS = 400;
+
+const isSettlementTopic = (topic) => String(topic || '') === TOPIC;
+
+/** The UTC day of an ISO timestamp, or null. */
+function dayOf(value) {
+ if (value == null || value === '') return null;
+ if (typeof value === 'string' && DAY.test(value)) return value;
+ const t = value instanceof Date ? value.getTime() : (typeof value === 'number' ? value : Date.parse(String(value)));
+ return Number.isFinite(t) ? new Date(t).toISOString().slice(0, 10) : null;
+}
+const addDays = (day, n) => new Date(Date.parse(`${day}T00:00:00Z`) + n * DAY_MS).toISOString().slice(0, 10);
+const daysBetween = (a, b) => Math.round((Date.parse(`${b}T00:00:00Z`) - Date.parse(`${a}T00:00:00Z`)) / DAY_MS);
+
+/** A DB bigint (BigInt in PGlite, a string in node-postgres) as a decimal string; money never passes through Number. */
+const big = (v) => {
+ if (v == null) return null;
+ if (typeof v === 'bigint') return v.toString();
+ if (typeof v === 'string' && /^-?[0-9]+$/.test(v)) return v;
+ if (Number.isSafeInteger(v)) return String(v);
+ throw new TypeError('a bigint column did not arrive as an exact integer');
+};
+const whole = (v) => Number(v || 0);
+
+/** Does this payload match `billing.credit.settled@1`? Unknown ids and malformed values are invalid, never thrown. */
+function validSettlement(payload) {
+ try {
+ const r = contracts.validate(CONTRACT, payload);
+ return Boolean(r && r.valid === true);
+ } catch { return false; }
+}
+
+/**
+ * The model is the part of `deployment_id` after the first ":" ("own:qwen/qwen3-4b:q4_k_m@3f2a1c9" →
+ * "qwen/qwen3-4b:q4_k_m@3f2a1c9"). A null deployment id is unknown; so is a value with no model part.
+ */
+function modelOf(deploymentId) {
+ if (deploymentId == null) return 'unknown';
+ const s = String(deploymentId);
+ const i = s.indexOf(':');
+ const model = i >= 0 ? s.slice(i + 1).trim() : '';
+ return model ? model.slice(0, 256) : 'unknown';
+}
+
+const changed = (r) => Number(r && (r.changes != null ? r.changes : r.rowCount)) || 0;
+
+/** Count an invalid settlement by the day it claims, so it is visible and still never aggregated. */
+async function reject(t, day, reason = 'invalid') {
+ await t.prepare(`INSERT INTO report_ai_exchange_rejected (day, reason, events) VALUES (?::date, ?, 1)
+ ON CONFLICT (day, reason) DO UPDATE SET events = report_ai_exchange_rejected.events + 1, updated_at = now()`)
+ .run(day, reason);
+}
+
+/**
+ * Count one settlement inside the delivery's own transaction. Validation comes first: an invalid payload is counted
+ * as rejected and nothing else is written. Then the settlement id is claimed; a replay returns
+ * `settlement:duplicate`. A sandbox settlement only increments `sandbox_requests`.
+ */
+async function recordSettlement(t, payload, { occurredAt = null, now = Date.now() } = {}) {
+ const p = payload && typeof payload === 'object' ? payload : {};
+ const at = dayOf(p.settled_at) || dayOf(occurredAt) || dayOf(now) || new Date(now).toISOString().slice(0, 10);
+ if (!validSettlement(p)) {
+ await reject(t, at);
+ return 'settlement:rejected';
+ }
+ const claim = await t.prepare('INSERT INTO report_ai_exchange_seen (settlement_id, day) VALUES (?, ?::date) ON CONFLICT (settlement_id) DO NOTHING')
+ .run(p.settlement_id, at);
+ if (!changed(claim)) return 'settlement:duplicate';
+ const model = modelOf(p.deployment_id);
+ const access = p.provider_access || 'unknown';
+ if (p.sandbox === true) {
+ await t.prepare(`INSERT INTO report_ai_exchange_days (day, model, provider_access, sandbox_requests)
+ VALUES (?::date, ?, ?, 1)
+ ON CONFLICT (day, model, provider_access) DO UPDATE SET
+ sandbox_requests = report_ai_exchange_days.sandbox_requests + 1,
+ updated_at = now()`)
+ .run(at, model, access);
+ return 'settlement:recorded';
+ }
+ await t.prepare(`INSERT INTO report_ai_exchange_days
+ (day, model, provider_access, requests, tokens_input, tokens_cached_read, tokens_cache_write, tokens_output,
+ retail_nano_usd, shortfall_nano_usd, supply_nano_usd)
+ VALUES (?::date, ?, ?, 1, ?::bigint, ?::bigint, ?::bigint, ?::bigint, ?::bigint, ?::bigint, ?::bigint)
+ ON CONFLICT (day, model, provider_access) DO UPDATE SET
+ requests = report_ai_exchange_days.requests + 1,
+ tokens_input = report_ai_exchange_days.tokens_input + excluded.tokens_input,
+ tokens_cached_read = report_ai_exchange_days.tokens_cached_read + excluded.tokens_cached_read,
+ tokens_cache_write = report_ai_exchange_days.tokens_cache_write + excluded.tokens_cache_write,
+ tokens_output = report_ai_exchange_days.tokens_output + excluded.tokens_output,
+ retail_nano_usd = report_ai_exchange_days.retail_nano_usd + excluded.retail_nano_usd,
+ shortfall_nano_usd = report_ai_exchange_days.shortfall_nano_usd + excluded.shortfall_nano_usd,
+ supply_nano_usd = report_ai_exchange_days.supply_nano_usd + excluded.supply_nano_usd,
+ updated_at = now()`)
+ .run(at, model, access, String(p.usage.input_tokens), String(p.usage.cached_read_tokens),
+ String(p.usage.cache_write_tokens), String(p.usage.output_tokens), p.charge_nano_usd,
+ p.shortfall_nano_usd, p.supply_cost_nano_usd);
+ return 'settlement:recorded';
+}
+
+/** A day range for an answer: the last 30 days by default, 400 at most. An impossible pair throws a RangeError. */
+function range({ from = null, to = null, now = Date.now() } = {}) {
+ const today = dayOf(now);
+ const toDay = to == null || to === '' ? today : dayOf(String(to).slice(0, 10));
+ if (!toDay) throw new RangeError('to must be a day (YYYY-MM-DD)');
+ let fromDay = from == null || from === '' ? addDays(toDay, -(DEFAULT_RANGE_DAYS - 1)) : dayOf(String(from).slice(0, 10));
+ if (!fromDay) throw new RangeError('from must be a day (YYYY-MM-DD)');
+ if (fromDay > toDay) throw new RangeError('from must be on or before to');
+ const earliest = addDays(toDay, -(MAX_RANGE_DAYS - 1));
+ if (fromDay < earliest) fromDay = earliest;
+ return { from: fromDay, to: toDay, days: daysBetween(fromDay, toDay) + 1 };
+}
+
+/** The rows of a range, exact. Money leaves as decimal strings; counts as numbers. */
+async function rows(store, { from, to }) {
+ // Every bigint is selected as text: the SDK's int8 parser refuses a value beyond 2^53 rather than rounding it,
+ // and money must never pass through a float.
+ const out = await store.db.prepare(`SELECT day::text AS day, model, provider_access, requests::text AS requests,
+ sandbox_requests::text AS sandbox_requests,
+ tokens_input::text AS tokens_input, tokens_cached_read::text AS tokens_cached_read,
+ tokens_cache_write::text AS tokens_cache_write, tokens_output::text AS tokens_output,
+ retail_nano_usd::text AS retail_nano_usd, shortfall_nano_usd::text AS shortfall_nano_usd,
+ supply_nano_usd::text AS supply_nano_usd, margin_nano_usd::text AS margin_nano_usd
+ FROM report_ai_exchange_days WHERE day >= ?::date AND day <= ?::date
+ ORDER BY day, model, provider_access`).all(from, to);
+ return out.map((r) => ({
+ day: String(r.day).slice(0, 10),
+ model: String(r.model || 'unknown'),
+ provider_access: String(r.provider_access || 'unknown'),
+ requests: whole(r.requests),
+ sandbox_requests: whole(r.sandbox_requests),
+ tokens: {
+ input: whole(r.tokens_input),
+ cached_read: whole(r.tokens_cached_read),
+ cache_write: whole(r.tokens_cache_write),
+ output: whole(r.tokens_output),
+ },
+ retail_nano_usd: big(r.retail_nano_usd),
+ shortfall_nano_usd: big(r.shortfall_nano_usd),
+ supply_nano_usd: big(r.supply_nano_usd),
+ margin_nano_usd: big(r.margin_nano_usd),
+ }));
+}
+
+/** The invalid settlements of the range, by day. */
+async function rejectedOf(store, { from, to }) {
+ const list = await store.db.prepare(`SELECT day::text AS day, SUM(events)::text AS events
+ FROM report_ai_exchange_rejected WHERE day >= ?::date AND day <= ?::date
+ GROUP BY day ORDER BY day`).all(from, to);
+ const by_day = list.map((r) => ({ day: String(r.day).slice(0, 10), events: whole(r.events) }));
+ return { count: by_day.reduce((n, r) => n + r.events, 0), by_day };
+}
+
+/** Add rows into one total, using BigInt for money. */
+function totalOf(list) {
+ const t = { requests: 0, sandbox_requests: 0, tokens: { input: 0, cached_read: 0, cache_write: 0, output: 0 }, retail: 0n, shortfall: 0n, supply: 0n };
+ for (const r of list) {
+ t.requests += r.requests;
+ t.sandbox_requests += r.sandbox_requests;
+ for (const k of Object.keys(t.tokens)) t.tokens[k] += r.tokens[k];
+ t.retail += BigInt(r.retail_nano_usd);
+ t.shortfall += BigInt(r.shortfall_nano_usd);
+ t.supply += BigInt(r.supply_nano_usd);
+ }
+ return {
+ requests: t.requests,
+ sandbox_requests: t.sandbox_requests,
+ tokens: { ...t.tokens },
+ retail_nano_usd: t.retail.toString(),
+ shortfall_nano_usd: t.shortfall.toString(),
+ supply_nano_usd: t.supply.toString(),
+ margin_nano_usd: (t.retail - t.supply).toString(),
+ };
+}
+
+/** The day totals for the chart, from the same rows. */
+function dailyOf(list) {
+ const map = new Map();
+ for (const r of list) {
+ let d = map.get(r.day);
+ if (!d) { d = { day: r.day, requests: 0, sandbox_requests: 0, retail: 0n, shortfall: 0n, supply: 0n }; map.set(r.day, d); }
+ d.requests += r.requests;
+ d.sandbox_requests += r.sandbox_requests;
+ d.retail += BigInt(r.retail_nano_usd);
+ d.shortfall += BigInt(r.shortfall_nano_usd);
+ d.supply += BigInt(r.supply_nano_usd);
+ }
+ return [...map.values()].sort((a, b) => (a.day < b.day ? -1 : 1)).map((d) => ({
+ day: d.day,
+ requests: d.requests,
+ sandbox_requests: d.sandbox_requests,
+ retail_nano_usd: d.retail.toString(),
+ shortfall_nano_usd: d.shortfall.toString(),
+ supply_nano_usd: d.supply.toString(),
+ margin_nano_usd: (d.retail - d.supply).toString(),
+ }));
+}
+
+/** One answer: the range, the rows, the day totals, the totals and the rejected count. */
+async function analytics(store, opts = {}) {
+ const r = range(opts);
+ const list = await rows(store, r);
+ return { range: r, rows: list, daily: dailyOf(list), totals: totalOf(list), rejected: await rejectedOf(store, r) };
+}
+
+/** The Exchange ledger as one object: the event consumer and the read routes share it. */
+function createAiExchange({ store, now = () => Date.now(), log = console } = {}) {
+ if (!store) return null;
+ return {
+ store,
+ topic: TOPIC,
+ record: (t, payload, opts = {}) => recordSettlement(t, payload, { now: now(), ...opts }),
+ analytics: (opts = {}) => analytics(store, { now: now(), ...opts }),
+ log,
+ };
+}
+
+module.exports = {
+ TOPIC, CONTRACT, isSettlementTopic, validSettlement, modelOf, recordSettlement, range, rows, rejectedOf,
+ totalOf, dailyOf, analytics, createAiExchange,
+};
@@ -14,8 +14,10 @@
@@ -14,8 +14,10 @@
const query = require('./query');
const query = require('./query');
const ingest = require('./ingest');
const ingest = require('./ingest');
-function createAnalytics({ config = {}, store = null, network = null, billing = null, now = () => Date.now(), log = console } = {}) {
+function createAnalytics({ config = {}, store = null, network = null, billing = null, now = () => Date.now(), log = console, aiExchange = false } = {}) {
const source = config.source === 'report' ? 'report' : 'network';
const source = config.source === 'report' ? 'report' : 'network';
+ // The AI Exchange switch may be a function: the owner can change it live (D11).
+ const exchangeOn = () => Boolean(typeof aiExchange === 'function' ? aiExchange() : aiExchange);
function warn(what, err) {
function warn(what, err) {
try { (log.warn || log.error || console.warn).call(log, `[OpenVibe.Report] ${what} failed: ${String((err && err.message) || err).replace(/\s+/g, ' ').slice(0, 200)}`); } catch { /* logging must never fail a page */ }
try { (log.warn || log.error || console.warn).call(log, `[OpenVibe.Report] ${what} failed: ${String((err && err.message) || err).replace(/\s+/g, ' ').slice(0, 200)}`); } catch { /* logging must never fail a page */ }
@@ -40,7 +42,7 @@ function createAnalytics({ config = {}, store = null, network = null, billing =
@@ -40,7 +42,7 @@ function createAnalytics({ config = {}, store = null, network = null, billing =
source, store, billing, network,
source, store, billing, network,
stats, channel, metrics,
stats, channel, metrics,
reportStats: (days, opts = {}) => (store ? query.reportStats(store, days, { now, ...opts }) : null),
reportStats: (days, opts = {}) => (store ? query.reportStats(store, days, { now, ...opts }) : null),
- recordEvent: (event, opts = {}) => (store ? ingest.recordEvent(store, event, { now, ...opts }) : Promise.resolve({ duplicate: false, outcome: 'no_database' })),
+ recordEvent: (event, opts = {}) => (store ? ingest.recordEvent(store, event, { now, aiExchange: exchangeOn(), ...opts }) : Promise.resolve({ duplicate: false, outcome: 'no_database' })),
ingestStats: (payload, opts = {}) => (store ? ingest.ingestStats(store, payload, { now, ...opts }) : Promise.resolve({ ok: false, reason: 'no_database' })),
ingestStats: (payload, opts = {}) => (store ? ingest.ingestStats(store, payload, { now, ...opts }) : Promise.resolve({ ok: false, reason: 'no_database' })),
ingestNetwork: (opts = {}) => (store && network ? ingest.ingestNetwork(store, network, { now, ...opts }) : Promise.resolve({ ok: false, reason: 'no_database' })),
ingestNetwork: (opts = {}) => (store && network ? ingest.ingestNetwork(store, network, { now, ...opts }) : Promise.resolve({ ok: false, reason: 'no_database' })),
log,
log,
@@ -11,16 +11,21 @@
@@ -11,16 +11,21 @@
* writes: a redelivered event id changes nothing. A stats snapshot is keyed by its own content hash, so polling
* writes: a redelivered event id changes nothing. A stats snapshot is keyed by its own content hash, so polling
* the same numbers again is a no-op while new numbers replace the stored ones.
* the same numbers again is a no-op while new numbers replace the stored ones.
*
*
- * Billing events are consciously not ingested: Report reads money from Billing's API, never from an event.
+ * Billing's money is read from Billing's API, never derived here. The one exception is the AI Exchange's
+ * settlement aggregates (plan G-P9), behind REPORT_AI_EXCHANGE: the raw settlement is not stored, only its
+ * aggregates (day, model, provider access) and its settlement id for idempotency. With the flag off, a settlement
+ * delivery is ignored before anything is written.
*/
*/
const crypto = require('crypto');
const crypto = require('crypto');
const { createPgInbox } = require('openvibe-sdk/events');
const { createPgInbox } = require('openvibe-sdk/events');
const { normaliseDays } = require('../data/source');
const { normaliseDays } = require('../data/source');
+const { isSettlementTopic } = require('./ai-exchange');
const INBOX = 'report-events';
const INBOX = 'report-events';
const STATS_INBOX = 'network-stats';
const STATS_INBOX = 'network-stats';
const DAY = 86_400_000;
const DAY = 86_400_000;
-const ALLOWED_TOPICS = new Set(['live.stream.ended', 'live.stream.started', 'network.frame.hit', 'network.frame.day', 'frame.hit', 'ai.call.recorded']);
+const ALLOWED_TOPICS = new Set(['live.stream.ended', 'live.stream.started', 'network.frame.hit', 'network.frame.day', 'frame.hit', 'ai.call.recorded',
+ 'billing.credit.settled']);
const hashOf = (value) => crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex');
const hashOf = (value) => crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex');
const num = (v) => (v == null || v === '' || !Number.isFinite(Number(v)) ? null : Number(v));
const num = (v) => (v == null || v === '' || !Number.isFinite(Number(v)) ? null : Number(v));
@@ -268,7 +273,7 @@ async function recordStream(t, event, payload, occurredAt, source) {
@@ -268,7 +273,7 @@ async function recordStream(t, event, payload, occurredAt, source) {
}
}
/** One Events envelope → the tables. Returns { duplicate, outcome }. */
/** One Events envelope → the tables. Returns { duplicate, outcome }. */
-async function recordEvent(store, event, { now = () => Date.now() } = {}) {
+async function recordEvent(store, event, { now = () => Date.now(), aiExchange = false } = {}) {
if (!store || !store.db) throw new TypeError('recordEvent needs an open Report store');
if (!store || !store.db) throw new TypeError('recordEvent needs an open Report store');
const e = event && typeof event === 'object' ? event : {};
const e = event && typeof event === 'object' ? event : {};
const eventId = String(e.event_id || e.id || '');
const eventId = String(e.event_id || e.id || '');
@@ -279,6 +284,8 @@ async function recordEvent(store, event, { now = () => Date.now() } = {}) {
@@ -279,6 +284,8 @@ async function recordEvent(store, event, { now = () => Date.now() } = {}) {
const rawOccurred = e.occurred_at || e.timestamp || (e.payload && (e.payload.ended_at || e.payload.at)) || new Date(now()).toISOString();
const rawOccurred = e.occurred_at || e.timestamp || (e.payload && (e.payload.ended_at || e.payload.at)) || new Date(now()).toISOString();
const occurredMs = Date.parse(String(rawOccurred));
const occurredMs = Date.parse(String(rawOccurred));
const occurredAt = Number.isFinite(occurredMs) ? new Date(occurredMs).toISOString() : null;
const occurredAt = Number.isFinite(occurredMs) ? new Date(occurredMs).toISOString() : null;
+ // The flag is off: nothing about a settlement is read or written, not even its event id.
+ if (isSettlementTopic(topic) && !aiExchange) return { duplicate: false, outcome: 'ignored:flag' };
if (!ALLOWED_TOPICS.has(topic)) {
if (!ALLOWED_TOPICS.has(topic)) {
// Today's billing and traffic events are not analytics facts Report may derive: a seen id changes nothing.
// Today's billing and traffic events are not analytics facts Report may derive: a seen id changes nothing.
const inbox = createPgInbox(store.db, { table: 'report_inbox', now });
const inbox = createPgInbox(store.db, { table: 'report_inbox', now });
@@ -288,13 +295,19 @@ async function recordEvent(store, event, { now = () => Date.now() } = {}) {
@@ -288,13 +295,19 @@ async function recordEvent(store, event, { now = () => Date.now() } = {}) {
const payload = e.payload && typeof e.payload === 'object' ? e.payload : {};
const payload = e.payload && typeof e.payload === 'object' ? e.payload : {};
const inbox = createPgInbox(store.db, { table: 'report_inbox', now });
const inbox = createPgInbox(store.db, { table: 'report_inbox', now });
const r = await inbox.once(INBOX, eventId, async (t) => {
const r = await inbox.once(INBOX, eventId, async (t) => {
- await t.prepare(`INSERT INTO report_events (event_id, source, topic, occurred_at, payload) VALUES (?, ?, ?, ?, ?::jsonb) ON CONFLICT (event_id) DO NOTHING`)
- .run(eventId, source, topic, occurredAt, JSON.stringify(payload));
+ // A settlement carries a person, a project and money. Report may keep its aggregates and its idempotency
+ // key, never the raw payload: no per-person rows and no prompt or answer text.
+ if (!isSettlementTopic(topic)) {
+ await t.prepare(`INSERT INTO report_events (event_id, source, topic, occurred_at, payload) VALUES (?, ?, ?, ?, ?::jsonb) ON CONFLICT (event_id) DO NOTHING`)
+ .run(eventId, source, topic, occurredAt, JSON.stringify(payload));
+ }
await t.prepare('UPDATE report_inbox SET source = ?, topic = ?, payload_hash = ? WHERE consumer = ? AND event_id = ?')
await t.prepare('UPDATE report_inbox SET source = ?, topic = ?, payload_hash = ? WHERE consumer = ? AND event_id = ?')
.run(source, topic, hashOf(payload), INBOX, eventId);
.run(source, topic, hashOf(payload), INBOX, eventId);
if (topic === 'live.stream.ended') return await recordStream(t, e, payload, occurredAt, source);
if (topic === 'live.stream.ended') return await recordStream(t, e, payload, occurredAt, source);
// The AI meter (L40/L41): one record per AI call, stored once by the system and the run (or turn).
// The AI meter (L40/L41): one record per AI call, stored once by the system and the run (or turn).
if (topic === 'ai.call.recorded') return await require('./ai-usage').recordUsage(t, payload, { occurredAt, source });
if (topic === 'ai.call.recorded') return await require('./ai-usage').recordUsage(t, payload, { occurredAt, source });
+ // AI Exchange (G-P9): Billing's settlement, counted once by settlement id.
+ if (isSettlementTopic(topic)) return await require('./ai-exchange').recordSettlement(t, payload, { occurredAt, now: now() });
if (topic === 'network.frame.hit' || topic === 'network.frame.day' || topic === 'frame.hit') return await recordFrame(t, topic, payload, occurredAt, source);
if (topic === 'network.frame.hit' || topic === 'network.frame.day' || topic === 'frame.hit') return await recordFrame(t, topic, payload, occurredAt, source);
return 'event:recorded';
return 'event:recorded';
});
});
@@ -33,6 +33,8 @@ const { createBillingClient } = require('./data/billing');
@@ -33,6 +33,8 @@ const { createBillingClient } = require('./data/billing');
const { createBenchClient } = require('./data/bench');
const { createBenchClient } = require('./data/bench');
const { createAnalytics } = require('./analytics');
const { createAnalytics } = require('./analytics');
const { createAiUsage } = require('./analytics/ai-usage');
const { createAiUsage } = require('./analytics/ai-usage');
+const { createAiExchange } = require('./analytics/ai-exchange');
+const createAiExchangeConfig = require('./ai-exchange-config').create;
const { createSearchIndex } = require('./search-index');
const { createSearchIndex } = require('./search-index');
const { createShadow } = require('./analytics/shadow');
const { createShadow } = require('./analytics/shadow');
const { createApi } = require('./http/api');
const { createApi } = require('./http/api');
@@ -60,9 +62,15 @@ async function createApp(opts = {}) {
@@ -60,9 +62,15 @@ async function createApp(opts = {}) {
const live = opts.live || createLiveClient({ config, fetchImpl, log, now });
const live = opts.live || createLiveClient({ config, fetchImpl, log, now });
const billing = opts.billing || createBillingClient({ config, fetchImpl, log, now });
const billing = opts.billing || createBillingClient({ config, fetchImpl, log, now });
const bench = opts.bench || createBenchClient({ config, fetchImpl, log, now });
const bench = opts.bench || createBenchClient({ config, fetchImpl, log, now });
- const analytics = opts.analytics || createAnalytics({ config, store, network, billing, now, log });
+ // D11 owner settings (report.ai_exchange): the switch is read through the store, env pins winning. Report can
+ // run without a database (a network-only front): then there is no store and the env value is all there is.
+ const exchangeConfig = store ? await createAiExchangeConfig({ db: store.db, config, env: opts.env || process.env, now, log, onActivate: opts.onConfigActivate }) : null;
+ if (exchangeConfig) Object.defineProperty(config.aiExchange, 'enabled', { enumerable: true, configurable: true, get: () => exchangeConfig.get('enabled') });
+ const analytics = opts.analytics || createAnalytics({ config, store, network, billing, now, log, aiExchange: () => Boolean(config.aiExchange && config.aiExchange.enabled) });
// The AI ledger (plan L40/L41): the records of every AI call on the network and their roll-ups.
// The AI ledger (plan L40/L41): the records of every AI call on the network and their roll-ups.
const aiUsage = opts.aiUsage || createAiUsage({ store, now, log });
const aiUsage = opts.aiUsage || createAiUsage({ store, now, log });
+ // AI Exchange analytics (G-P9, flag off): Billing's settlements, aggregated. No database, no ledger.
+ const aiExchange = opts.aiExchange !== undefined ? opts.aiExchange : (store ? createAiExchange({ store, now, log }) : null);
const shadow = opts.shadow || (store ? createShadow({ store, network, now, log }) : null);
const shadow = opts.shadow || (store ? createShadow({ store, network, now, log }) : null);
// The Rundown topic pages in OpenVibe.Search (started by server/index.js, never under REPORT_DRILL).
// The Rundown topic pages in OpenVibe.Search (started by server/index.js, never under REPORT_DRILL).
const searchIndex = opts.searchIndex || (store ? createSearchIndex({ config, store, live, log }) : null);
const searchIndex = opts.searchIndex || (store ? createSearchIndex({ config, store, live, log }) : null);
@@ -75,8 +83,9 @@ async function createApp(opts = {}) {
@@ -75,8 +83,9 @@ async function createApp(opts = {}) {
secureCookies: /^https:/.test(config.baseUrl), fetch: fetchImpl, log,
secureCookies: /^https:/.test(config.baseUrl), fetch: fetchImpl, log,
});
});
- const ctx = { config, log, fetchImpl, now, store, network, live, billing, bench, analytics, shadow, sso, aiUsage, searchIndex };
+ const ctx = { config, log, fetchImpl, now, store, network, live, billing, bench, analytics, shadow, sso, aiUsage, aiExchange, searchIndex, exchangeConfig };
const app = express();
const app = express();
+ if (exchangeConfig) exchangeConfig.mount(app);
// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts
// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts
// (openvibe-contracts); the operations they mark internal-only are left out.
// (openvibe-contracts); the operations they mark internal-only are left out.
app.get('/openapi.json', (_req, res) => {
app.get('/openapi.json', (_req, res) => {
@@ -189,7 +198,7 @@ async function createApp(opts = {}) {
@@ -189,7 +198,7 @@ async function createApp(opts = {}) {
legacyHeaders: false,
legacyHeaders: false,
handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }),
handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }),
}));
}));
- app.use('/api/v1', createApi({ network, live, analytics, aiUsage, config, now, log }));
+ app.use('/api/v1', createApi({ network, live, analytics, aiUsage, aiExchange, config, now, log }));
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
// ── Crawl artifacts, then the pages ─────────────────────
// ── Crawl artifacts, then the pages ─────────────────────
@@ -78,6 +78,13 @@ function load(env = process.env) {
@@ -78,6 +78,13 @@ function load(env = process.env) {
// Drill mode: the jobs that would change numbers stay off while a drill runs.
// Drill mode: the jobs that would change numbers stay off while a drill runs.
drill: env.REPORT_DRILL === '1' || env.REPORT_DRILL === 'true',
drill: env.REPORT_DRILL === '1' || env.REPORT_DRILL === 'true',
+ // AI Exchange analytics (plan G-P9, behind REPORT_AI_EXCHANGE, off). When on, Report consumes Billing's
+ // billing.credit.settled events into aggregates by day, model and provider access; off, a settlement
+ // delivery is ignored before anything is written.
+ aiExchange: {
+ enabled: bool(env.REPORT_AI_EXCHANGE, false),
+ },
+
// OpenVibe.Events → Report: live.stream.ended and the frame beacon. The secret signs a delivery (comma
// OpenVibe.Events → Report: live.stream.ended and the frame beacon. The secret signs a delivery (comma
// separated for rotation, 32+ characters each); unset makes POST /internal/events answer 503.
// separated for rotation, 32+ characters each); unset makes POST /internal/events answer 503.
events: {
events: {
@@ -29,7 +29,7 @@ const designHealth = require('../data/design-health');
@@ -29,7 +29,7 @@ const designHealth = require('../data/design-health');
const CACHE = 'public, max-age=120';
const CACHE = 'public, max-age=120';
-function createApi({ network, live, analytics = null, aiUsage = null, config = {}, now = () => Date.now(), log = console }) {
+function createApi({ network, live, analytics = null, aiUsage = null, aiExchange = null, config = {}, now = () => Date.now(), log = console }) {
const r = express.Router();
const r = express.Router();
const serviceAuth = createServiceAuth({ config, now });
const serviceAuth = createServiceAuth({ config, now });
@@ -170,6 +170,27 @@ function createApi({ network, live, analytics = null, aiUsage = null, config = {
@@ -170,6 +170,27 @@ function createApi({ network, live, analytics = null, aiUsage = null, config = {
}
}
});
});
+ // The AI Exchange (plan G-P9): Billing's settlements, summed by day, resolved model and provider access. Only
+ // when REPORT_AI_EXCHANGE is on, and only for the staff gate the AI meter already uses. Aggregates only: no
+ // person, no project, no prompt. Report never sets a rate, a price or a balance.
+ r.get('/ai/exchange', async (req, res) => {
+ if (!config.aiExchange || !config.aiExchange.enabled) return notFound(req, res, 'route.not_found', 'No such API route.');
+ if (!aiExchange) return problem(req, res, 503, 'report.no_database', 'Report has no database in this process.');
+ const v = await serviceAuth.authenticate(req);
+ if (!v.ok) return problem(req, res, v.status, v.code, v.detail);
+ if (!serviceAuth.hasCap(v.claims, CAPS.aiUsageRead)) {
+ return problem(req, res, 403, 'capability.denied', 'report.ai_usage.read is not granted to this token.');
+ }
+ try {
+ const body = await aiExchange.analytics({ from: req.query.from || null, to: req.query.to || null });
+ return res.set('Cache-Control', 'no-store').json(body);
+ } catch (err) {
+ if (err instanceof RangeError) return problem(req, res, 422, 'ai.invalid', err.message);
+ log.error('[OpenVibe.Report] the AI Exchange aggregates failed:', err && err.message ? String(err.message).slice(0, 200) : err);
+ return problem(req, res, 500, 'report.ai_exchange_failed', 'The AI Exchange aggregates could not be read.');
+ }
+ });
+
r.use((req, res) => notFound(req, res, 'route.not_found', 'No such API route.'));
r.use((req, res) => notFound(req, res, 'route.not_found', 'No such API route.'));
return r;
return r;
}
}
@@ -19,6 +19,7 @@
@@ -19,6 +19,7 @@
* Markdown twin render the very same page; `agent: true` in those views adds the discovery links.
* Markdown twin render the very same page; `agent: true` in those views adds the discovery links.
*/
*/
const express = require('express');
const express = require('express');
+const contracts = require('openvibe-contracts');
const views = require('../views');
const views = require('../views');
const { normaliseDays, isSlug, isStreamId, isUsername, streamIdOf } = require('../data/source');
const { normaliseDays, isSlug, isStreamId, isUsername, streamIdOf } = require('../data/source');
const { twinPath } = require('../agent-pages');
const { twinPath } = require('../agent-pages');
@@ -123,6 +124,24 @@ function createPageRoutes(ctx) {
@@ -123,6 +124,24 @@ function createPageRoutes(ctx) {
return send(req, res, page.html, 200, 'no-store');
return send(req, res, page.html, 200, 'no-store');
});
});
+ // The AI Exchange's daily aggregates (plan G-P9), staff-only, behind REPORT_AI_EXCHANGE. The gate is the signed-in
+ // person's staff role (`staff.site.view`, the capability that covers analytics and money state), the same
+ // openvibe-contracts staff map other staff pages use. The page is private and never cached.
+ r.get('/ai/exchange', async (req, res) => {
+ if (!ctx.config.aiExchange || !ctx.config.aiExchange.enabled) return notFound(req, res);
+ if (!req.user) return send(req, res, views.staffOnly({ signedIn: false }), 401, 'private, no-store');
+ if (!contracts.staff.can(req.user, 'staff.site.view')) return send(req, res, views.staffOnly({ signedIn: true }), 403, 'private, no-store');
+ if (!ctx.aiExchange) return contracts.http.sendProblem(res, 503, 'report.no_database', { detail: 'Report has no database in this process.', ctx: req.ov });
+ try {
+ const body = await ctx.aiExchange.analytics({ from: req.query.from || null, to: req.query.to || null });
+ return send(req, res, views.aiExchange(body), 200, 'private, no-store');
+ } catch (err) {
+ if (err instanceof RangeError) return contracts.http.sendProblem(res, 422, 'ai.invalid', { detail: err.message, ctx: req.ov });
+ ctx.log.error('[OpenVibe.Report] the AI Exchange page failed:', err && err.message ? String(err.message).slice(0, 200) : err);
+ return contracts.http.sendProblem(res, 500, 'report.ai_exchange_failed', { detail: 'The AI Exchange aggregates could not be read.', ctx: req.ov });
+ }
+ });
+
r.get('/ai/:kind', async (req, res) => {
r.get('/ai/:kind', async (req, res) => {
const page = await publicPages.aiBoard(req.params.kind, bench.normaliseTab(req.query.tab));
const page = await publicPages.aiBoard(req.params.kind, bench.normaliseTab(req.query.tab));
if (!page) return notFound(req, res);
if (!page) return notFound(req, res);
@@ -613,8 +613,84 @@ function aiModel({ model, summary }) {
@@ -613,8 +613,84 @@ function aiModel({ model, summary }) {
return layout({ title: `${model}: AI model`, description: `${model} across OpenVibe benchmark jobs: ratings, games and cost.`, body, active: '/ai', path: modelPath(model), agent: true });
return layout({ title: `${model}: AI model`, description: `${model} across OpenVibe benchmark jobs: ratings, games and cost.`, body, active: '/ai', path: modelPath(model), agent: true });
}
}
+// ── The AI Exchange (plan G-P9): Billing's settlements, summed by day, model and access ────────────────────────
+// Money is integer nano-USD and stays exact: this formatter works in BigInt, never in floats, and the title carries
+// the exact nano-USD the authority sent.
+function usdNano(v) {
+ if (v == null) return '\u2014';
+ let n;
+ try { n = BigInt(String(v)); } catch { return '\u2014'; }
+ const neg = n < 0n;
+ const abs = neg ? -n : n;
+ const whole = abs / 1_000_000_000n;
+ const frac = (abs % 1_000_000_000n).toString().padStart(9, '0').replace(/0+$/, '');
+ const digits = frac.length < 2 ? `${frac}00`.slice(0, 2) : frac;
+ const group = whole.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
+ return `${neg ? '-' : ''}$${group}.${digits}`;
+}
+const nanoCell = (v) => (v == null ? '\u2014' : `<span title="${esc(String(v))} nano-USD from Billing">${esc(usdNano(v))}</span>`);
+
+function aiExchange({ range = {}, rows = [], daily = [], totals = {}, rejected = {} } = {}) {
+ const t = totals || {};
+ const tokens = t.tokens || {};
+ const tokenTotal = (n(tokens.input) || 0) + (n(tokens.cached_read) || 0) + (n(tokens.cache_write) || 0) + (n(tokens.output) || 0);
+ const rejectedCount = Number(rejected && rejected.count) || 0;
+ const tiles = `<div class="rp-tiles rp-tiles-sm">
+${tile('Settlements', exact(t.requests))}
+${tile('Sandbox', exact(t.sandbox_requests), 'not money')}
+${tile('Tokens', esc(bigNum(tokenTotal)))}
+${tile('Retail', esc(usdNano(t.retail_nano_usd)))}
+${tile('Shortfall', esc(usdNano(t.shortfall_nano_usd)), 'owed, not collected')}
+${tile('Supply', esc(usdNano(t.supply_nano_usd)))}
+${tile('Margin', esc(usdNano(t.margin_nano_usd)), 'retail minus supply')}
+${rejectedCount ? tile('Rejected', exact(rejectedCount), 'invalid payloads') : ''}
+</div>`;
+ const table = rows.length ? kit.table({
+ caption: 'By day, model and provider access',
+ columns: [
+ { key: 'day', label: 'Day', primary: true },
+ { key: 'model', label: 'Model' },
+ { key: 'access', label: 'Access' },
+ { key: 'requests', label: 'Requests', num: true, fmt: 'int' },
+ { key: 'sandbox_requests', label: 'Sandbox', num: true, fmt: 'int' },
+ { key: 'input', label: 'Input', num: true, fmt: 'int' },
+ { key: 'cached_read', label: 'Cache read', num: true, fmt: 'int' },
+ { key: 'cache_write', label: 'Cache write', num: true, fmt: 'int' },
+ { key: 'output', label: 'Output', num: true, fmt: 'int' },
+ { key: 'retail', label: 'Retail', num: true, html: true },
+ { key: 'shortfall', label: 'Shortfall', num: true, html: true },
+ { key: 'supply', label: 'Supply', num: true, html: true },
+ { key: 'margin', label: 'Margin', num: true, html: true },
+ ],
+ rows: rows.map((r) => ({
+ day: r.day, model: r.model, access: r.provider_access, requests: r.requests, sandbox_requests: r.sandbox_requests,
+ input: r.tokens.input, cached_read: r.tokens.cached_read, cache_write: r.tokens.cache_write, output: r.tokens.output,
+ retail: nanoCell(r.retail_nano_usd), shortfall: nanoCell(r.shortfall_nano_usd), supply: nanoCell(r.supply_nano_usd),
+ margin: nanoCell(r.margin_nano_usd),
+ })),
+ }) : `<p class="rp-fine">No settlements in this range. The table fills as Billing posts settlements; with ${esc('REPORT_AI_EXCHANGE')} off, nothing is consumed.</p>`;
+ const head = kit.head({ title: 'AI Exchange', sub: 'What Billing settled, by day, model and provider access', sticky: false });
+ const body = head
+ + `<p class="rp-lede">Aggregates only: no person, no project, no prompt. Report reads the authorities and never sets a rate, a price or a balance. Retail is what was collected, shortfall is what is owed but not collected, supply is what the route cost, and the margin is retail minus supply. Sandbox settlements are counted apart and are never money.</p>
+<p class="rp-fine">${esc(range.from || '')} to ${esc(range.to || '')}${range.days ? ` \u00b7 ${esc(range.days)} days` : ''}. Money is exact integer nano-USD from Billing; the exact amount is in each cell's title.</p>
+${tiles}
+${daily.length > 1 ? `<section aria-labelledby="rp-ex-chart"><h2 id="rp-ex-chart">Settlements a day</h2>${bars(daily.map((d) => ({ day: d.day, n: d.requests })), 'Settlements a day', { h: 110 })}</section>` : ''}
+<section aria-labelledby="rp-ex-table"><h2 id="rp-ex-table">The daily table</h2>${table}</section>
+<p class="rp-meta"><a href="/api/v1/ai/exchange">As JSON</a></p>`;
+ return layout({ title: 'AI Exchange analytics', description: 'Billing settlements aggregated by day, model and provider access.', robots: 'noindex, nofollow', body, active: '/ai', path: '/ai/exchange' });
+}
+
+/** The page a person sees when the staff gate refuses them: signed out asks for a sign-in, signed in says why. */
+function staffOnly({ signedIn = false } = {}) {
+ const text = signedIn
+ ? 'Your OpenVibe.Network account does not hold staff.site.view. This page reads the network\'s AI Exchange analytics; ask an admin if you need it.'
+ : 'This page is for OpenVibe.Network staff. Sign in with an account that holds staff.site.view.';
+ const action = signedIn ? '' : '<p class="rp-meta"><a href="/auth/login?next=/ai/exchange">Sign in</a></p>';
+ return layout({ title: 'Staff only', description: 'Staff only.', robots: 'noindex, nofollow', body: `<header class="rp-page-h"><p class="rp-kicker">OpenVibe.Report</p><h1>Staff only</h1><p class="rp-lede">${esc(text)}</p>${action}</header>` });
+}
+
function notFound() {
function notFound() {
return layout({ title: 'Not found', robots: 'noindex, follow', body: `<header class="rp-page-h"><h1>Nothing here</h1><p class="rp-lede">That page does not exist, or its source has nothing for it. <a href="/">Back to Report</a>.</p></header>` });
return layout({ title: 'Not found', robots: 'noindex, follow', body: `<header class="rp-page-h"><h1>Nothing here</h1><p class="rp-lede">That page does not exist, or its source has nothing for it. <a href="/">Back to Report</a>.</p></header>` });
}
}
-module.exports = { home, stats, rundown, topic, episode, speaker, recap, channel, you, youSignedOut, developers, aiIndex, aiBoard, aiModel, paretoRows, paretoChart, notFound, layout, setRelease, configure };
+module.exports = { home, stats, rundown, topic, episode, speaker, recap, channel, you, youSignedOut, developers, aiIndex, aiBoard, aiModel, aiExchange, staffOnly, usdNano, paretoRows, paretoChart, notFound, layout, setRelease, configure };
new file mode 100644
new file mode 100644
@@ -0,0 +1,267 @@
@@ -0,0 +1,267 @@
+'use strict';
+/**
+ * The AI Exchange in Report (plan G-P9): the consumer of Billing's `billing.credit.settled` event, the aggregates and
+ * the staff-only page + service-token API. Aggregates only; exact BigInt money; a settlement id counts once; sandbox
+ * is not money; invalid payloads are rejected; the flag off consumes nothing.
+ */
+const assert = require('assert');
+const crypto = require('crypto');
+const contracts = require('openvibe-contracts');
+const { signDeliveryV2 } = require('openvibe-sdk/events');
+const { serviceAuth } = require('openvibe-contracts');
+const { createSsoClient } = require('openvibe-sdk/sso');
+const { boot, check, done } = require('./helpers/boot');
+
+const SECRET = 'test-report-events-secret-32-characters-long';
+const ISSUER = 'https://openvibe.network';
+const NOW = Date.parse('2026-11-04T12:00:00.000Z');
+const MODEL_A = 'qwen/qwen3_4b:q4_k_m@3f2a1c9';
+const MODEL_B = 'qwen/qwen3_coder_30b_a3b_instruct';
+const MODEL_C = 'meta/llama_3_1_8b:q4_k_m@a1b2c3d';
+const PERSON = 'usr_01JTESTPERSON000000000000';
+const B32 = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
+
+/** A valid ULID-shaped id for the fixtures (26 Crockford base32 characters). */
+function ulid(n) {
+ let s = '';
+ for (let i = 0; i < 26; i += 1) { s = B32[n % 32] + s; n = Math.floor(n / 32); }
+ return s;
+}
+
+const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {
+ modulusLength: 2048,
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
+});
+
+const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
+/** A person's access token, the way OpenVibe.Network signs one (real time: the SSO verifier reads the clock). */
+function personToken(claims) {
+ const now = Math.floor(Date.now() / 1000);
+ const head = b64({ alg: 'RS256', typ: 'JWT', kid: 'test' });
+ const body = b64({ iss: ISSUER, aud: ['openvibe.network', 'openvibe.report'], iat: now, exp: now + 3600, ...claims });
+ const sig = crypto.sign('RSA-SHA256', Buffer.from(`${head}.${body}`), privateKey).toString('base64url');
+ return `${head}.${body}.${sig}`;
+}
+const STAFF_PERSON = personToken({ sub: '1', subject_id: PERSON, username: 'staffer', display_name: 'Staffer', role: 'admin' });
+const PLAIN_PERSON = personToken({ sub: '2', subject_id: 'usr_01JTESTPERSON000000000002', username: 'visitor', display_name: 'Visitor', role: 'user' });
+
+/** A Network service token for the JSON endpoint (the existing report.ai_usage.read gate). */
+function serviceToken(cap) {
+ const now = Math.floor(NOW / 1000);
+ return serviceAuth.signServiceToken({
+ iss: ISSUER, sub: 'svc:operator', actor_type: 'service', aud: ['openvibe.report'], cap, ns: ['*'],
+ iat: now, exp: now + 300, jti: 'tok_aiexchange_' + String(now).slice(-6),
+ }, privateKey);
+}
+const SERVICE_STAFF = serviceToken(['report.ai_usage.read']);
+const SERVICE_WRONG = serviceToken(['report.other.read']);
+
+const sso = createSsoClient({
+ site: 'report', baseUrl: 'https://openvibe.report', clientId: 'report', clientSecret: 'x',
+ networkUrl: ISSUER, publicKey,
+ log: { warn() {}, info() {}, log() {}, error() {} },
+});
+
+let seq = 0;
+/** One `billing.credit.settled` event in the v0.287.0 payload shape. */
+function settlementEvent(over = {}) {
+ seq += 1;
+ const { event_id, event_type, occurred_at, ...fields } = over;
+ const payload = {
+ settlement_id: `stl_${ulid(seq)}`,
+ hold_id: `hld_${ulid(seq)}`,
+ service: 'ai',
+ settled_at: '2026-11-04T12:00:05.000Z',
+ deployment_id: 'own:qwen/qwen3_4b:q4_k_m@3f2a1c9',
+ provider_access: 'OWN_DEPLOYMENT',
+ rate_version: 'rr-test-001',
+ usage: { input_tokens: 1000, cached_read_tokens: 0, cache_write_tokens: 0, output_tokens: 500 },
+ charge_nano_usd: '18000',
+ supply_cost_nano_usd: '9000',
+ shortfall_nano_usd: '0',
+ released_nano_usd: '82000000',
+ byok: false,
+ sandbox: false,
+ ...fields,
+ };
+ return {
+ event_id: event_id || `evt_${ulid(seq)}`,
+ event_type: event_type || 'billing.credit.settled',
+ source: 'billing',
+ occurred_at: occurred_at || payload.settled_at,
+ payload,
+ };
+}
+
+function delivery(event) {
+ const raw = JSON.stringify({ event });
+ const ts = Math.floor(NOW / 1000);
+ return { raw, headers: { 'content-type': 'application/json', 'x-openvibe-timestamp': String(ts), 'x-openvibe-signature-v2': signDeliveryV2(raw, SECRET, ts) } };
+}
+
+(async () => {
+ const t = await boot({ db: true, now: () => NOW, sso, env: { REPORT_AI_EXCHANGE: '1', REPORT_EVENTS_SECRET: SECRET, OV_NETWORK_PUBLIC_KEY: publicKey } });
+ const db = t.ctx.store.db;
+ const send = async (event) => {
+ const d = delivery(event);
+ return await t.get('/internal/events', { method: 'POST', headers: d.headers, body: d.raw });
+ };
+ try {
+ await check('a v0.287.0 fixture aggregates by day, model and provider access, with exact money', async () => {
+ assert.ok(contracts.validate('billing.credit.settled@1', settlementEvent().payload).valid, 'the test fixture matches the contract');
+ const first = await send(settlementEvent());
+ assert.strictEqual(first.status, 200, first.text);
+ assert.strictEqual(first.json().outcome, 'settlement:recorded', first.text);
+
+ const second = await send(settlementEvent({
+ charge_nano_usd: '41000', supply_cost_nano_usd: '21000', shortfall_nano_usd: '7',
+ usage: { input_tokens: 2000, cached_read_tokens: 100, cache_write_tokens: 50, output_tokens: 100 },
+ }));
+ assert.strictEqual(second.json().outcome, 'settlement:recorded', second.text);
+
+ const third = await send(settlementEvent({
+ deployment_id: `deepinfra:${MODEL_B}`, provider_access: 'AUTHORIZED_WHOLESALE',
+ charge_nano_usd: '300', supply_cost_nano_usd: '100',
+ usage: { input_tokens: 10, cached_read_tokens: 0, cache_write_tokens: 0, output_tokens: 20 },
+ }));
+ assert.strictEqual(third.json().outcome, 'settlement:recorded', third.text);
+
+ // The v0.287.0 byok-sandbox fixture: counted apart, never money.
+ const sandbox = await send(settlementEvent({
+ settled_at: '2026-11-05T12:00:07.000Z', deployment_id: null, provider_access: null, byok: true, sandbox: true,
+ usage: { input_tokens: 0, cached_read_tokens: 0, cache_write_tokens: 0, output_tokens: 0 },
+ charge_nano_usd: '0', supply_cost_nano_usd: '0', shortfall_nano_usd: '0',
+ }));
+ assert.strictEqual(sandbox.json().outcome, 'settlement:recorded', sandbox.text);
+
+ const r = await t.get('/api/v1/ai/exchange?from=2026-11-04&to=2026-11-05', { headers: { authorization: 'Bearer ' + SERVICE_STAFF } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.headers.get('cache-control'), 'no-store');
+ const body = r.json();
+ assert.deepStrictEqual(body.range, { from: '2026-11-04', to: '2026-11-05', days: 2 });
+ assert.strictEqual(body.rejected.count, 0, 'nothing invalid yet');
+ const a = body.rows.find((x) => x.day === '2026-11-04' && x.model === MODEL_A && x.provider_access === 'OWN_DEPLOYMENT');
+ assert.ok(a, 'the main bucket, keyed by the model after the deployment id\'s first colon');
+ assert.strictEqual(a.requests, 2);
+ assert.strictEqual(a.sandbox_requests, 0);
+ assert.deepStrictEqual(a.tokens, { input: 3000, cached_read: 100, cache_write: 50, output: 600 });
+ assert.strictEqual(a.retail_nano_usd, '59000');
+ assert.strictEqual(a.shortfall_nano_usd, '7');
+ assert.strictEqual(a.supply_nano_usd, '30000');
+ assert.strictEqual(a.margin_nano_usd, '29000');
+ const b = body.rows.find((x) => x.model === MODEL_B);
+ assert.strictEqual(b.provider_access, 'AUTHORIZED_WHOLESALE');
+ assert.strictEqual(b.margin_nano_usd, '200');
+ const sandboxRow = body.rows.find((x) => x.day === '2026-11-05' && x.model === 'unknown' && x.provider_access === 'unknown');
+ assert.ok(sandboxRow, 'the sandbox row is counted apart');
+ assert.strictEqual(sandboxRow.sandbox_requests, 1);
+ assert.strictEqual(sandboxRow.requests, 0, 'sandbox is not a money request');
+ assert.strictEqual(sandboxRow.retail_nano_usd, '0');
+ assert.strictEqual(sandboxRow.tokens.input, 0);
+ assert.strictEqual(body.totals.requests, 3);
+ assert.strictEqual(body.totals.sandbox_requests, 1);
+ assert.strictEqual(body.totals.retail_nano_usd, '59300');
+ assert.strictEqual(body.totals.shortfall_nano_usd, '7');
+ assert.strictEqual(body.totals.supply_nano_usd, '30100');
+ assert.strictEqual(body.totals.margin_nano_usd, '29200');
+ assert.strictEqual(body.daily.length, 2, 'the chart has a day series');
+ const text = JSON.stringify(body);
+ for (const leak of ['usr_', 'prj_', 'subject']) assert.ok(!text.includes(leak), 'the aggregate names nobody: ' + leak);
+ const cols = await db.many("SELECT column_name FROM information_schema.columns WHERE table_name = 'report_ai_exchange_days'");
+ const names = cols.map((c) => c.column_name);
+ for (const banned of ['subject', 'project_id', 'prompt', 'output_text']) assert.ok(!names.includes(banned), 'no column ' + banned);
+ assert.strictEqual(await db.value("SELECT COUNT(*)::int AS n FROM report_events WHERE topic = 'billing.credit.settled'"), 0, 'the raw settlement is not stored');
+ });
+
+ await check('an invalid payload is counted as rejected, never aggregated', async () => {
+ const rows = await db.value('SELECT COUNT(*)::int AS n FROM report_ai_exchange_days');
+ const seen = await db.value('SELECT COUNT(*)::int AS n FROM report_ai_exchange_seen');
+ const bad = settlementEvent({ charge_nano_usd: 11 });
+ const r = await send(bad);
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json().outcome, 'settlement:rejected', 'a money number is invalid');
+ assert.strictEqual(await db.value('SELECT COUNT(*)::int AS n FROM report_ai_exchange_days'), rows, 'nothing aggregated');
+ assert.strictEqual(await db.value('SELECT COUNT(*)::int AS n FROM report_ai_exchange_seen'), seen, 'the invalid settlement id is not claimed');
+ assert.strictEqual(await db.value('SELECT SUM(events)::int AS n FROM report_ai_exchange_rejected'), 1);
+ const api = await t.get('/api/v1/ai/exchange?from=2026-11-04&to=2026-11-04', { headers: { authorization: 'Bearer ' + SERVICE_STAFF } });
+ assert.strictEqual(api.json().rejected.count, 1);
+ assert.deepStrictEqual(api.json().rejected.by_day, [{ day: '2026-11-04', events: 1 }]);
+ });
+
+ await check('a replayed settlement counts once, by its settlement id, even under a new event id', async () => {
+ const before = await db.value("SELECT requests::text AS n FROM report_ai_exchange_days WHERE model = $1 AND provider_access = 'OWN_DEPLOYMENT'", [MODEL_A]);
+ const event = settlementEvent({ settlement_id: `stl_${ulid(900)}`, hold_id: `hld_${ulid(900)}` });
+ assert.strictEqual((await send(event)).json().outcome, 'settlement:recorded');
+ const same = await send(event);
+ assert.strictEqual(same.status, 200, same.text);
+ assert.strictEqual(same.json().duplicate, true, 'the same event id is a duplicate');
+ const otherEvent = await send({ ...event, event_id: `evt_${ulid(901)}` });
+ assert.strictEqual(otherEvent.json().outcome, 'settlement:duplicate', 'the same settlement under a new event id is a duplicate');
+ const after = await db.value("SELECT requests::text AS n FROM report_ai_exchange_days WHERE model = $1 AND provider_access = 'OWN_DEPLOYMENT'", [MODEL_A]);
+ assert.strictEqual(Number(after) - Number(before), 1, 'the replay added exactly the new settlement');
+ });
+
+ await check('money stays exact beyond 2^53, as decimal strings', async () => {
+ await send(settlementEvent({
+ settled_at: '2026-11-06T01:00:00.000Z', deployment_id: `own:${MODEL_C}`, charge_nano_usd: '9007199254740993',
+ supply_cost_nano_usd: '1', shortfall_nano_usd: '2',
+ usage: { input_tokens: 1, cached_read_tokens: 0, cache_write_tokens: 0, output_tokens: 1 },
+ }));
+ await send(settlementEvent({
+ settled_at: '2026-11-06T02:00:00.000Z', deployment_id: `own:${MODEL_C}`, charge_nano_usd: '1',
+ supply_cost_nano_usd: '1', shortfall_nano_usd: '0',
+ usage: { input_tokens: 0, cached_read_tokens: 0, cache_write_tokens: 0, output_tokens: 0 },
+ }));
+ const r = await t.get('/api/v1/ai/exchange?from=2026-11-06&to=2026-11-06', { headers: { authorization: 'Bearer ' + SERVICE_STAFF } });
+ const row = r.json().rows.find((x) => x.model === MODEL_C);
+ assert.strictEqual(row.retail_nano_usd, '9007199254740994');
+ assert.strictEqual(row.supply_nano_usd, '2');
+ assert.strictEqual(row.shortfall_nano_usd, '2');
+ assert.strictEqual(row.margin_nano_usd, '9007199254740992');
+ assert.strictEqual(typeof row.retail_nano_usd, 'string', 'money is a decimal string, never a float');
+ });
+
+ await check('the page is for signed-in staff; the JSON endpoint keeps its service-token gate', async () => {
+ const anonymous = await t.get('/ai/exchange');
+ assert.strictEqual(anonymous.status, 401, 'no session');
+ assert.match(anonymous.text, /Staff only/);
+ const plain = await t.get('/ai/exchange', { headers: { cookie: `ov_token=${PLAIN_PERSON}` } });
+ assert.strictEqual(plain.status, 403, 'a signed-in non-staff person');
+ const bearerOnly = await t.get('/ai/exchange', { headers: { authorization: 'Bearer ' + SERVICE_STAFF } });
+ assert.strictEqual(bearerOnly.status, 401, 'the page does not accept the service token');
+ const staff = await t.get('/ai/exchange?from=2026-11-04&to=2026-11-05', { headers: { cookie: `ov_token=${STAFF_PERSON}` } });
+ assert.strictEqual(staff.status, 200, staff.text.slice(0, 300));
+ assert.strictEqual(staff.headers.get('cache-control'), 'private, no-store');
+ assert.match(staff.text, /AI Exchange/);
+ assert.match(staff.text, /qwen\/qwen3_4b/);
+ assert.match(staff.text, /<svg/);
+ assert.match(staff.text, /\$0\.0000002/, 'the exact margin of the second model is printed');
+ assert.match(staff.text, /200 nano-USD from Billing/, 'the exact nano amount is in the title');
+
+ const noToken = await t.get('/api/v1/ai/exchange');
+ assert.strictEqual(noToken.status, 401, 'the endpoint without a service token');
+ const wrong = await t.get('/api/v1/ai/exchange', { headers: { authorization: 'Bearer ' + SERVICE_WRONG } });
+ assert.strictEqual(wrong.status, 403, 'the endpoint with the wrong capability');
+ });
+ } finally { await t.close(); }
+
+ // ── The flag is off: a settlement delivery is consumed by nothing, not even its event id ──
+ const off = await boot({ db: true, now: () => NOW, sso, env: { REPORT_EVENTS_SECRET: SECRET } });
+ try {
+ await check('flag off consumes nothing and exposes no page or endpoint', async () => {
+ const d = delivery(settlementEvent({ event_id: 'evt_AITEST0000000000000100' }));
+ const r = await off.get('/internal/events', { method: 'POST', headers: d.headers, body: d.raw });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json().outcome, 'ignored:flag');
+ for (const table of ['report_ai_exchange_days', 'report_ai_exchange_seen', 'report_ai_exchange_rejected', 'report_events', 'report_inbox']) {
+ assert.strictEqual(await off.ctx.store.db.value(`SELECT COUNT(*)::int AS n FROM ${table}`), 0, table + ' stays empty');
+ }
+ const api = await off.get('/api/v1/ai/exchange', { headers: { authorization: 'Bearer ' + SERVICE_STAFF } });
+ assert.strictEqual(api.status, 404, 'the endpoint does not exist while the flag is off');
+ const page = await off.get('/ai/exchange', { headers: { cookie: `ov_token=${STAFF_PERSON}` } });
+ assert.strictEqual(page.status, 404, 'the page does not exist while the flag is off');
+ });
+ } finally { await off.close(); }
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -161,6 +161,7 @@ async function boot(opts = {}) {
@@ -161,6 +161,7 @@ async function boot(opts = {}) {
const store = opts.store || (testdatabase ? createStore(testdatabase.db, { now: opts.now }) : null);
const store = opts.store || (testdatabase ? createStore(testdatabase.db, { now: opts.now }) : null);
const built = await createApp({
const built = await createApp({
config,
config,
+ env,
store,
store,
fetchImpl,
fetchImpl,
log,
log,
new file mode 100644
new file mode 100644
@@ -0,0 +1,104 @@
@@ -0,0 +1,104 @@
+'use strict';
+/**
+ * D11 owner settings: the report.ai_exchange namespace and /internal/config.
+ * Env pins win; an owner change applies live (the ingest consumer and the staff page); report.config.manage
+ * is required; no KEK still works; a secret-class value never leaves a summary, snapshot or history.
+ */
+const assert = require('assert');
+const crypto = require('crypto');
+const { serviceAuth } = require('openvibe-contracts');
+const { boot, check, done } = require('./helpers/boot');
+
+const ISSUER = 'https://openvibe.network';
+const OWNER = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';
+const SECRET = 'sk_report_d11_toppsecret_123456';
+const CAP = ['report.config.manage', 'report.ai_usage.read'];
+const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {
+ modulusLength: 2048,
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
+});
+const now = Math.floor(Date.now() / 1000);
+const token = () => serviceAuth.signServiceToken({
+ iss: ISSUER, sub: 'svc:network', actor_type: 'service', aud: ['openvibe.report'], cap: CAP,
+ iat: now, exp: now + 300, jti: 'tok_report_config',
+}, privateKey);
+const bearer = () => ({ authorization: 'Bearer ' + token() });
+
+(async () => {
+ const t = await boot({ db: true, env: { REPORT_AI_EXCHANGE: '1', OV_NETWORK_PUBLIC_KEY: publicKey } });
+ try {
+ await check('the summary: schema, the env pin, sealed:false without a KEK', async () => {
+ const r = await t.get('/internal/config/report.ai_exchange', { headers: bearer() });
+ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
+ assert.strictEqual(r.json().namespace, 'report.ai_exchange');
+ assert.strictEqual(r.json().sealed, false);
+ assert.strictEqual(r.json().values.enabled, true);
+ assert.deepStrictEqual(r.json().pins.enabled, { env: 'REPORT_AI_EXCHANGE', pinned: true });
+ assert.ok(!r.text.includes(SECRET));
+ });
+
+ await check('a service token without report.config.manage is refused', async () => {
+ const wrong = serviceAuth.signServiceToken({
+ iss: ISSUER, sub: 'svc:operator', actor_type: 'service', aud: ['openvibe.report'], cap: ['report.ai_usage.read'],
+ iat: now, exp: now + 300, jti: 'tok_report_wrong',
+ }, privateKey);
+ const r = await t.get('/internal/config/report.ai_exchange', { headers: { authorization: 'Bearer ' + wrong } });
+ assert.strictEqual(r.status, 403);
+ assert.strictEqual(r.json().code, 'capability.denied');
+ });
+
+ await check('an env-pinned key is read-only', async () => {
+ const r = await t.get('/internal/config/report.ai_exchange', {
+ method: 'POST', headers: { ...bearer(), 'x-ov-subject': OWNER }, json: { values: { enabled: false }, reason: 'off' },
+ });
+ assert.strictEqual(r.status, 409, r.text.slice(0, 300));
+ assert.strictEqual(r.json().code, 'config.pinned_in_env');
+ });
+ } finally { await t.close(); }
+
+ const live = await boot({ db: true, env: { OV_NETWORK_PUBLIC_KEY: publicKey } });
+ try {
+ await check('an owner change applies live: the staff page and the JSON API open', async () => {
+ assert.strictEqual((await live.get('/api/v1/ai/exchange?from=2026-11-04&to=2026-11-05', { headers: bearer() })).status, 404);
+ const r = await live.get('/internal/config/report.ai_exchange', {
+ method: 'POST', headers: { ...bearer(), 'x-ov-subject': OWNER }, json: { values: { enabled: true }, reason: 'owner turned it on' },
+ });
+ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
+ assert.strictEqual(r.json().revision, 2);
+ assert.strictEqual(live.config.aiExchange.enabled, true);
+ const got = await live.get('/api/v1/ai/exchange?from=2026-11-04&to=2026-11-05', { headers: bearer() });
+ assert.strictEqual(got.status, 200, got.text.slice(0, 300));
+ assert.ok(live.logs().includes('by user:' + OWNER));
+ assert.ok(!live.logs().includes(SECRET));
+ });
+
+ await check('with a KEK the store is sealed and boot reseals', async () => {
+ const sealed = await boot({ db: true, env: { OV_NETWORK_PUBLIC_KEY: publicKey, OV_CONFIG_KEKS: 'k1:MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=', OV_CONFIG_KEK_CURRENT: 'k1' } });
+ try {
+ const r = await sealed.get('/internal/config/report.ai_exchange', { headers: bearer() });
+ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
+ assert.strictEqual(r.json().sealed, true);
+ assert.strictEqual(r.json().kek, 'k1');
+ } finally { await sealed.close(); }
+ });
+
+ await check('a secret is write-only and never appears in a summary, snapshot or history', async () => {
+ const shared = require('openvibe-shared/config');
+ const extra = await shared.createConfigStore({
+ db: live.ctx.store.db, service: 'report', namespace: 'report.test_secrets',
+ schema: { type: 'object', additionalProperties: false, properties: { api_key: { type: 'string' } } },
+ classify: () => 'secret',
+ });
+ const marker = await extra.setSecret('api_key', SECRET, { actor: { type: 'user', id: OWNER }, reason: 'test' });
+ assert.strictEqual(marker.redacted, true);
+ assert.ok(!JSON.stringify(marker).includes(SECRET));
+ const summary = await extra.summary();
+ assert.strictEqual(summary.values.api_key.redacted, true);
+ assert.ok(!JSON.stringify(summary).includes(SECRET));
+ assert.ok(!JSON.stringify(await extra.history()).includes(SECRET));
+ assert.ok(!JSON.stringify(await extra.snapshot(1)).includes(SECRET));
+ });
+ } finally { await live.close(); }
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });