openvibers/openvibe.ai Commits
Exchange for builders: signed webhooks, the MCP server and a personal usage read (#334)
Changes
@@@ -319,3 +319,15 @@ OV_MEDIA_URL=https://openvibe.media#AI_RESEARCH_DEEPSEEK_KEY_REF=env:DEEPSEEK_API_KEY#AI_RESEARCH_DEEPSEEK_DAILY=1500#AI_RESEARCH_TIMEOUT_MS=15000++++# ── Exchange webhooks (server/exchange/webhooks.js): a developer registers an https receiver and AI signs++# usage/earnings/offer.down with X-OpenVibe-Signature-V2, the header openvibe-sdk/exchange verifies. A delivery is++# retried with an exponential backoff; the receiver URL goes through the egress guard. On by default.++#AI_WEBHOOKS=1++#AI_WEBHOOK_TIMEOUT_MS=10000++#AI_WEBHOOK_MAX_BYTES=65536++#AI_WEBHOOK_MAX_ATTEMPTS=6++#AI_WEBHOOK_MAX_PER_OWNER=10++#AI_WEBHOOK_BASE_DELAY_MS=10000++#AI_WEBHOOK_MAX_DELAY_MS=3600000++#AI_WEBHOOK_TICK_MS=1000@@@ -696,6 +696,63 @@ listing it was read from and the day it was read.The tests never touch the network: the listings are recorded (`test/fixtures/pricebook/`, written by`scripts/record-price-book.js`) and replayed through the reader's transport seam.++## Webhooks and the MCP server++++The Exchange's developer tools (the plan of 2026-11-06, "For builders", item 7): `openvibe-sdk/exchange` has++verified webhooks since 0.91.0; `server/exchange/webhooks.js` is the sender, and `server/mcp/exchange.js` the MCP++server.++++### Webhooks++++A person's token or a developer app registers an https receiver on the Exchange API:++++| Route | What |++|---|---|++| `POST /api/v1/exchange/webhooks` | `{url, events?, description?}` → `201 {webhook}`; the signing `secret` (`whsec_…`) is in this answer only |++| `GET /api/v1/exchange/webhooks` | the caller's receivers (never a secret) |++| `DELETE /api/v1/exchange/webhooks/{id}` | remove one and its delivery history |++| `GET /api/v1/exchange/webhooks/{id}/deliveries` | the delivery log: event, attempts, status, the receiver's answer and the payload |++++Three events: `usage` (an answered run: the tokens, the money and who paid), `earnings` (a community offer earned++credits or kudos) and `offer.down` (an endpoint offer was removed). Every delivery carries++`X-OpenVibe-Signature-V2: t=<unix seconds>, v2=<hex HMAC-SHA256 of "<t>.<raw body>">` — exactly what++`require('openvibe-sdk/exchange').verifyWebhook(rawBody, header, secret)` checks — plus `X-OpenVibe-Event`,++`X-OpenVibe-Delivery` and `X-OpenVibe-Webhook-Id`. A payload never carries a prompt or an answer.++++The receiver URL goes through the same egress guard as a community endpoint: https, a public host name, never an IP++literal, port 443 or 8443, no query or fragment, and the DNS answer is judged inside the connection. A delivery is a++row, not a call in the request that caused it: a 408/425/429/5xx or a network failure is retried with an exponential++backoff (`AI_WEBHOOK_BASE_DELAY_MS`, up to `AI_WEBHOOK_MAX_DELAY_MS`, `AI_WEBHOOK_MAX_ATTEMPTS`); any other non-2xx++fails it at once. The worker is off in test mode and in `NODE_ENV=test`.++++### The MCP server++++`bin/openvibe-exchange-mcp.js` speaks MCP over stdio in the repo that owns the Exchange API:++++```json++{++ "mcpServers": {++ "openvibe-exchange": {++ "command": "openvibe-exchange-mcp",++ "env": { "OPENVIBE_EXCHANGE_TOKEN": "your Network developer key" }++ }++ }++}++```++++The tools are `exchange_models` (the live catalog: price in/out per million, measured p50/p95, uptime, health and the++data policy), `exchange_chat` (one completion on the person's allowance, or their own stored provider key when++`credential` names a provider) and `exchange_usage` (the person's own requests, tokens and spend by day and model,++from `GET /api/v1/exchange/me/usage`). `exchange_models` is public; the other two refuse a call without a token++before any request, and a 401 from the Exchange is refused back as the tool's error. The token is never printed,++logged or stored.++++### The CLI++++`openvibe-node exchange serve --model qwen3:32b --public-url https://…/v1` (OpenVibe.Node) serves the local model++through an OpenAI-compatible listener and registers it on the existing endpoint-seller API with the person's++`OPENVIBE_DEVELOPER_KEY`; Ctrl-C removes the offer. See that repo's README.++## Workflows- **Seed product workflows** — `wiki.generate_space`, `wiki.generate_page` (both on route `wiki.write`, see [MegaSource](#megasource-research)), `blog.draft_post`,@@@ -55,7 +55,9 @@"platform.usage-sample@1 readings to Billing with a per-subject provider free allowance; each run's reading state in explain and the free allowance on the staff console","tier budgets (free, paid, staff) per subject and per project on openvibe-sdk/govern in Valkey, behind AI_GOVERN_TIERS (off by default); readings from govern's onUsage","image generation (POST /api/v1/image, workflow ai.image, app op ai.image, route default.image over the OpenAI Images API), off unless AI_IMAGE_MODEL is set; metered per image (one platform.usage-sample@1 per attempt, resource output-images:<model>, unit image)",-- "the Exchange's routing presets (five built-ins plus one per account and per Network project, named '@preset/<name>' or as the request's preset, with floor, ceiling, max_price, quantization, region, data policy and community on or off), a hard max_price refused before the call with a problem document, the receipt of every answered request (offer, price, tokens, cache hits, latency, why the router chose it, the fallbacks it would have used, who paid and the privacy label) in the x-openvibe-receipt header and at GET /v1/receipts/{id}, BYOK at 0 % and the private/anonymized/community label on every answer"++ "the Exchange's routing presets (five built-ins plus one per account and per Network project, named '@preset/<name>' or as the request's preset, with floor, ceiling, max_price, quantization, region, data policy and community on or off), a hard max_price refused before the call with a problem document, the receipt of every answered request (offer, price, tokens, cache hits, latency, why the router chose it, the fallbacks it would have used, who paid and the privacy label) in the x-openvibe-receipt header and at GET /v1/receipts/{id}, BYOK at 0 % and the private/anonymized/community label on every answer",++ "Exchange webhooks (server/exchange/webhooks.js, migration 0038): usage, earnings and offer.down delivered with X-OpenVibe-Signature-V2 (<t>.<raw body>, the header openvibe-sdk/exchange verifies), retried with an exponential backoff, with a delivery log at GET /api/v1/exchange/webhooks/{id}/deliveries; a receiver URL goes through the egress guard (https, a public host, 443/8443), and the subscription secret is shown once",++ "the Exchange MCP server (bin/openvibe-exchange-mcp.js): stdio MCP tools exchange_models, exchange_chat (the person's allowance or their own stored provider key) and exchange_usage (GET /api/v1/exchange/me/usage), authenticated with the person's Network token and refusing a call without one"],"todo": ["owner: a provider key that serves the OpenAI Images API, then AI_IMAGE_MODEL (and AI_IMAGE_SIZE) in /etc/openvibe/ai.env — image generation stays off until then",nnew file mode 100755@@@ -0,0 +1,43 @@++#!/usr/bin/env node++'use strict';++/**++ * OpenVibe.Exchange MCP server over stdio (design EXCHANGE-REMAKE-2026-11-06, "For builders", item 7).++ *++ * OPENVIBE_EXCHANGE_TOKEN=... npx openvibe-exchange-mcp++ * OPENVIBE_EXCHANGE_URL=https://api.openvibe.exchange (a test or an alternate deployment)++ *++ * Add it to an MCP client's config as a stdio server with the token in the environment. The server writes one++ * JSON-RPC message per line to stdout and nothing else; logs go to stderr. A tool call without a token is refused++ * before any request, and a 401 from the Exchange is refused back as the tool's error.++ */++const readline = require('node:readline');++const { createExchangeMcp } = require('../server/mcp/exchange');++++async function main() {++ const mcp = createExchangeMcp({++ token: process.env.OPENVIBE_EXCHANGE_TOKEN || process.env.OPENVIBE_DEVELOPER_KEY || '',++ baseUrl: process.env.OPENVIBE_EXCHANGE_URL || 'https://api.openvibe.exchange',++ });++ const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });++ const write = (value) => { process.stdout.write(`${JSON.stringify(value)}\n`); };++ for await (const line of rl) {++ if (!line.trim()) continue;++ let message;++ try { message = JSON.parse(line); } catch {++ write({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'parse error' } });++ continue;++ }++ try {++ const out = await mcp.handle(message);++ if (out) write(out);++ } catch (err) {++ // A protocol-level failure never takes the server down; the client sees a JSON-RPC internal error.++ write({ jsonrpc: '2.0', id: message && message.id !== undefined ? message.id : null, error: { code: -32603, message: String((err && err.message) || err).slice(0, 300) } });++ }++ }++}++++main().catch((err) => {++ process.stderr.write(`openvibe-exchange-mcp: ${(err && err.stack) || err}\n`);++ process.exit(1);++});nnew file mode 100644@@@ -0,0 +1,37 @@++-- phase: expand++-- Exchange webhooks (design EXCHANGE-REMAKE-2026-11-06, "For builders", item 7). A developer registers an https++-- receiver; AI signs every delivery with X-OpenVibe-Signature-V2 (t=<unix>, v2=hex HMAC-SHA256 of "<t>.<raw body>"),++-- exactly the header openvibe-sdk/exchange's verifyWebhook checks. usage, earnings and offer.down are the three++-- events. A delivery is a row, retried with a backoff, so a receiver that is down loses nothing while the log keeps++-- what happened. The secret is a per-subscription signing key (never a person's token), and the receiver URL is++-- judged by the same egress guard the community endpoint flow uses.++CREATE TABLE IF NOT EXISTS exchange_webhook_endpoints (++ id text COLLATE "C" PRIMARY KEY, -- whk_<ULID>++ owner_subject text COLLATE "C" NOT NULL CHECK (owner_subject ~ '^(usr|prj)_[0-9A-HJKMNP-TV-Z]{26}$'),++ url text COLLATE "C" NOT NULL, -- https, a public host name, no query or fragment++ secret text COLLATE "C" NOT NULL, -- whsec_…: signs this subscription's deliveries, shown once++ events text[] COLLATE "C" NOT NULL CHECK (events <@ ARRAY['usage', 'earnings', 'offer.down']::text[]),++ description text COLLATE "C",++ created_at timestamptz NOT NULL,++ updated_at timestamptz NOT NULL++);++CREATE INDEX IF NOT EXISTS exchange_webhook_endpoints_owner ON exchange_webhook_endpoints (owner_subject, created_at DESC);++++CREATE TABLE IF NOT EXISTS exchange_webhook_deliveries (++ id text COLLATE "C" PRIMARY KEY, -- whd_<ULID>++ endpoint_id text COLLATE "C" NOT NULL REFERENCES exchange_webhook_endpoints (id) ON DELETE CASCADE,++ owner_subject text COLLATE "C" NOT NULL,++ event_id text COLLATE "C" NOT NULL, -- evt_…, stable for the same source event++ event_type text COLLATE "C" NOT NULL CHECK (event_type IN ('usage', 'earnings', 'offer.down')),++ payload jsonb NOT NULL, -- the exact bytes that are signed and sent++ status text COLLATE "C" NOT NULL CHECK (status IN ('pending', 'delivered', 'failed')),++ attempts integer NOT NULL DEFAULT 0 CHECK (attempts >= 0),++ next_attempt_at timestamptz,++ last_attempt_at timestamptz,++ delivered_at timestamptz,++ last_status integer,++ last_error text COLLATE "C",++ created_at timestamptz NOT NULL++);++CREATE INDEX IF NOT EXISTS exchange_webhook_deliveries_endpoint ON exchange_webhook_deliveries (endpoint_id, created_at DESC);++CREATE INDEX IF NOT EXISTS exchange_webhook_deliveries_due ON exchange_webhook_deliveries (next_attempt_at) WHERE status = 'pending';@@@ -4,9 +4,13 @@"private": true,"description": "OpenVibe.AI: providers, models, routing profiles, versioned prompt templates and workflows, runs, request logs, citations, scoped cache, quotas and audit for every OpenVibe product. Extracted from OpenVibe.Live's AI subsystem.","main": "server/index.js",++ "bin": {++ "openvibe-exchange-mcp": "bin/openvibe-exchange-mcp.js"++ },"scripts": {"start": "node server/index.js","dev": "NODE_ENV=development node server/index.js",++ "mcp:exchange": "node bin/openvibe-exchange-mcp.js","test": "node test/run.js","test:pg": "AI_TEST_STORE=pg node test/run.js"},@@@ -35,7 +35,7 @@ function memo(fn, ttlMs, clock) {};}--function exchangeRouter({ db, registry, pool, env = process.env, clock = { now: () => Date.now() }, config = null, presets, receipts, auth, log = console }) {++function exchangeRouter({ db, registry, pool, quotas = null, env = process.env, clock = { now: () => Date.now() }, config = null, presets, receipts, auth, log = console }) {const catalog = createCatalog({ db, registry, pool, env, clock, config });const usage = createUsage({ db, clock });// The price book's log: what a price was, what it became and when it was read.@@@ -218,6 +218,25 @@ function exchangeRouter({ db, registry, pool, env = process.env, clock = { now:} catch (err) { return next(err); }});++ // The caller's own usage (the MCP server and the Exchange's developer page read it): a person sees their++ // subject, a developer app its project. The public aggregate above is never per person; this one is never++ // anyone else's.++ r.get('/api/v1/exchange/me/usage', read, wrap(async (req) => {++ const p = req.principal || {};++ if (!p.kind) throw new AiError(403, 'capability.denied', 'a person or a developer app reads its own usage');++ if (!quotas) throw new AiError(503, 'exchange.unavailable', 'usage is not configured here');++ const who = p.kind === 'app' ? `project:${p.projectId}` : `user:${p.subject && p.subject.id}`;++ const day = (v, field) => {++ if (v == null || v === '') return undefined;++ const s = String(v).slice(0, 10);++ if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) throw new AiError(422, 'ai.invalid', `${field} must be YYYY-MM-DD`);++ return s;++ };++ const from = day(req.query.from, 'from');++ const to = day(req.query.to, 'to');++ return { requester: who, usage: await quotas.usage({ from, to, requester: who }) };++ }));++return r;}@@@ -19,7 +19,7 @@ const express = require('express');const { CAPS } = require('../auth');const { sendError } = require('../util');--function sellerRouter({ endpoints, community = null, auth, log = console }) {++function sellerRouter({ endpoints, community = null, webhooks = null, auth, log = console }) {const r = express.Router();const allow = auth.appOrCap(CAPS.appRun, CAPS.runCreate);const body = (req) => (req.body && typeof req.body === 'object' && !Array.isArray(req.body) ? req.body : {});@@@ -60,7 +60,16 @@ function sellerRouter({ endpoints, community = null, auth, log = console }) {r.delete('/api/v1/exchange/seller/offers/:id', allow, wrap(async (req, res) => {const who = await endpoints.ownerFor(req.principal);res.set('Cache-Control', 'no-store');-- return await endpoints.remove({ id: req.params.id, owner: who.owner });++ const mine = await endpoints.list(who.owner);++ const offer = mine.find((o) => o.id === req.params.id) || { id: req.params.id };++ const removed = await endpoints.remove({ id: req.params.id, owner: who.owner });++ // The offer.down webhook: its owner (and the developer project that registered it) hears once, after the++ // offer is gone. A notification never turns a successful removal into an error.++ if (webhooks && typeof webhooks.offerDown === 'function') {++ try { await webhooks.offerDown({ owner: who.owner, projectId: who.projectId, offer }); }++ catch (err) { log.warn(`[ai] offer.down webhook for ${req.params.id}: ${err.message}`); }++ }++ return removed;}));return r;nnew file mode 100644@@@ -0,0 +1,59 @@++'use strict';++/**++ * Exchange webhooks, the developer's side (design EXCHANGE-REMAKE-2026-11-06, "For builders", item 7).++ *++ * POST /api/v1/exchange/webhooks register an https receiver; the signing secret is in this answer only++ * GET /api/v1/exchange/webhooks the caller's receivers (never a secret)++ * DELETE /api/v1/exchange/webhooks/:id remove one (its delivery history goes with it)++ * GET /api/v1/exchange/webhooks/:id/deliveries the delivery log: event, attempts, status and the payload sent++ *++ * The owner is the caller: a person's own subject, or an app's project. A first-party service token is refused. The++ * secret is a signing key for this subscription, never a person's token, and it is never logged or read back.++ */++const express = require('express');++const { CAPS } = require('../auth');++const { AiError, sendError } = require('../util');++++function webhooksRouter({ webhooks, auth, log = console }) {++ const r = express.Router();++ const allow = auth.appOrCap(CAPS.appRun, CAPS.runCreate, CAPS.runRead);++ const body = (req) => (req.body && typeof req.body === 'object' && !Array.isArray(req.body) ? req.body : {});++ const wrap = (fn) => async (req, res) => {++ try {++ const out = await fn(req, res);++ if (out !== undefined) res.json(out);++ } catch (err) { sendError(res, err, req.ov, log); }++ };++ const ownerOf = (req) => {++ const p = req.principal || {};++ if (p.kind === 'user' && p.subject && p.subject.id) return p.subject.id;++ if (p.kind === 'app' && p.projectId) return p.projectId;++ throw new AiError(403, 'exchange.webhook_owner', 'a webhook belongs to a person or a developer app, not a service');++ };++++ r.post('/api/v1/exchange/webhooks', allow, wrap(async (req, res) => {++ const webhook = await webhooks.create({ owner: ownerOf(req), input: body(req) });++ res.status(201).set('Cache-Control', 'no-store');++ return { webhook };++ }));++++ r.get('/api/v1/exchange/webhooks', allow, wrap(async (req, res) => {++ res.set('Cache-Control', 'no-store');++ return { owner: ownerOf(req), webhooks: await webhooks.list(ownerOf(req)) };++ }));++++ r.delete('/api/v1/exchange/webhooks/:id', allow, wrap(async (req, res) => {++ res.set('Cache-Control', 'no-store');++ return await webhooks.remove({ id: req.params.id, owner: ownerOf(req) });++ }));++++ r.get('/api/v1/exchange/webhooks/:id/deliveries', allow, wrap(async (req, res) => {++ res.set('Cache-Control', 'no-store');++ const deliveries = await webhooks.deliveries({ id: req.params.id, owner: ownerOf(req), limit: req.query.limit });++ return { id: req.params.id, deliveries };++ }));++++ return r;++}++++module.exports = { webhooksRouter };@@@ -24,7 +24,7 @@ const pkg = require('../package.json');const { adminRoutes } = require('openvibe-shared/config');--function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, preferences = null, grants = null, vault = null, settings = null, benchEvents = null, runBudgets = null, memory = null, memoryImport = null, community = null, endpoints = null, presets = null, receipts = null, balance = null, gpus = null, abuse = null, priceBook = null }) {++function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, preferences = null, grants = null, vault = null, settings = null, benchEvents = null, runBudgets = null, memory = null, memoryImport = null, community = null, endpoints = null, webhooks = null, presets = null, receipts = null, balance = null, gpus = null, abuse = null, priceBook = null }) {const app = express();// /.well-known/agent.json (openvibe-shared/agent-layer, SEO-2): what this site serves, for agents.require('openvibe-shared/agent-layer').mount(app, { descriptor: require('./agent-descriptor') });@@@ -116,10 +116,13 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keysopenapi.mount(app);// OpenVibe.Exchange's read API (server/api/exchange.js): the public catalog and aggregate usage. Public on// purpose — the site and anyone comparing prices reads the same numbers the router ranks on.-- app.use(require('./api/exchange').exchangeRouter({ config, db, registry, pool, env, clock, presets, receipts, auth, log }));++ app.use(require('./api/exchange').exchangeRouter({ config, db, registry, pool, quotas, env, clock, presets, receipts, auth, log }));// The public seller API: a standalone OpenAI-compatible server registered by its own https address with the// seller's developer key (the design's SDK gap). Its offers join the community router's trust and ledger.-- if (endpoints) app.use(require('./api/seller').sellerRouter({ endpoints, community, auth, log }));++ if (endpoints) app.use(require('./api/seller').sellerRouter({ endpoints, community, webhooks, auth, log }));++ // Exchange webhooks (the plan's "For builders", item 7): a developer registers an https receiver and reads its++ // delivery log. The signature is the one openvibe-sdk/exchange verifies; the receiver URL goes through the guard.++ if (webhooks) app.use(require('./api/webhooks').webhooksRouter({ webhooks, auth, log }));// GET /release.json (ADR-016) and POST /release-metrics (open tabs' update reports into /metrics).release.mount(app, { registry: metrics.registry });@@@ -585,7 +585,9 @@ function createCommunity({ db, config, env = process.env, machines = null, endpoconst facts = {resolved_model: `community/${chosen.model}`, revision: null, deployment: source,provider_access: isEndpoint ? 'COMMUNITY_ENDPOINT' : 'COMMUNITY', rate_version: 'community', policy: 'community',-- community: { label: pools.label(chosen.offer.pool), ...(chosen.offer.pool ? { pool: chosen.offer.pool } : {}), kind: chosen.offer.pool ? 'pool' : isEndpoint ? 'endpoint' : 'machine', machine_id: chosen.offer.machine_id, owner: chosen.offer.owner, model: chosen.model, price: chosen.offer.price,++ community: { label: pools.label(chosen.offer.pool), ...(chosen.offer.pool ? { pool: chosen.offer.pool } : {}), kind: chosen.offer.pool ? 'pool' : isEndpoint ? 'endpoint' : 'machine',++ machine_id: chosen.offer.machine_id, offer_id: chosen.offer.offer_id, project_id: chosen.offer.projectId || null,++ owner: chosen.offer.owner, model: chosen.model, price: chosen.offer.price,verification: chosen.status, reputation: chosen.reputation, free_tier: freeTier(ctx), self_served: selfServed },};if (typeof ctx.onRoute === 'function') {@@@ -463,6 +463,22 @@ function load(env = process.env) {},},++ // ── Exchange webhooks (the plan's "For builders", item 7): a developer registers an https receiver and AI++ // signs usage, earnings and offer.down deliveries with the header openvibe-sdk/exchange verifies. A delivery++ // is a row retried with a backoff, so a receiver that is down loses nothing. The receiver URL is judged by++ // the same egress guard as a community endpoint; a private address is refused at registration.++ webhooks: {++ enabled: bool(env.AI_WEBHOOKS, true),++ timeoutMs: Math.max(1000, Math.min(120000, int(env.AI_WEBHOOK_TIMEOUT_MS, 10000))),++ maxBytes: Math.max(4096, Math.min(1024 * 1024, int(env.AI_WEBHOOK_MAX_BYTES, 65536))),++ maxAttempts: Math.max(1, Math.min(12, int(env.AI_WEBHOOK_MAX_ATTEMPTS, 6))),++ maxPerOwner: Math.max(1, Math.min(50, int(env.AI_WEBHOOK_MAX_PER_OWNER, 10))),++ // The first retry waits this long; each further attempt doubles it, up to maxDelayMs.++ baseDelayMs: Math.max(100, Math.min(3600000, int(env.AI_WEBHOOK_BASE_DELAY_MS, 10000))),++ maxDelayMs: Math.max(1000, Math.min(86400000, int(env.AI_WEBHOOK_MAX_DELAY_MS, 3600000))),++ tickMs: Math.max(250, Math.min(60000, int(env.AI_WEBHOOK_TICK_MS, 1000))),++ },++// ── Tier budgets (T6 step 8, server/govern.js): openvibe-sdk/govern in Valkey, off unless AI_GOVERN_TIERS=1.govern: loadGovern(env, { bool, int, num: float }),};nnew file mode 100644@@@ -0,0 +1,392 @@++'use strict';++/**++ * Exchange webhooks (design EXCHANGE-REMAKE-2026-11-06, "For builders", item 7).++ *++ * A developer registers an https receiver with their Network developer key (or a person's own token) on the public++ * Exchange API. AI then sends three events: usage (an answered request), earnings (a community offer earned credits++ * or kudos) and offer.down (an endpoint offer was removed). Every delivery carries++ * X-OpenVibe-Signature-V2: t=<unix seconds>, v2=<hex HMAC-SHA256 of "<t>.<raw body>"> -- exactly the header++ * openvibe-sdk/exchange's verifyWebhook checks, in constant time, with a 300-second timestamp window. The event id,++ * the type and the delivery id ride beside it so a receiver can deduplicate and log.++ *++ * A delivery is a row, not a call in the request that caused it: the run/offer write commits first, the receiver is++ * called after, and a failure is retried with an exponential backoff. The receiver sees the delivery log++ * (GET /api/v1/exchange/webhooks/:id/deliveries), with every attempt, status and error.++ *++ * The receiver URL is judged by the egress guard the community endpoint flow uses: https only, a public DNS answer++ * judged inside the connection (no rebinding), never an IP literal or a private name, port 443 or 8443, no query or++ * fragment. A redirect is never followed: a webhook receiver must answer where it was registered.++ */++const crypto = require('node:crypto');++const net = require('node:net');++const https = require('node:https');++const egress = require('openvibe-shared/egress');++const { ids } = require('openvibe-contracts');++const { AiError, parseJson, sha256 } = require('../util');++++const EVENTS = Object.freeze(['usage', 'earnings', 'offer.down']);++const EVENT_SET = new Set(EVENTS);++const OWNER_RE = /^(?:usr|prj)_[0-9A-HJKMNP-TV-Z]{26}$/;++const ENDPOINT_ID = /^whk_[0-9A-HJKMNP-TV-Z]{26}$/;++const MAX_URL = 2048;++const ALLOWED_PORTS = new Set(['', '443', '8443']);++++const iso = (ms) => new Date(ms).toISOString();++const nowSec = (ms) => Math.floor(ms / 1000);++const cut = (s, n) => String(s == null ? '' : s).slice(0, n);++++/**++ * The one URL rule for a receiver. Returns a URL, or throws AiError 422 `exchange.webhook_url`; the message never++ * echoes the host. The DNS answer is judged again inside every connection (createSafeLookup), so a name that later++ * resolves private is refused at delivery too.++ */++function judgeUrl(raw) {++ let u;++ try { u = new URL(String(raw == null ? '' : raw)); } catch { throw new AiError(422, 'exchange.webhook_url', 'url must be an https URL'); }++ if (u.protocol !== 'https:') throw new AiError(422, 'exchange.webhook_url', 'url must be https');++ if (u.username || u.password) throw new AiError(422, 'exchange.webhook_url', 'credentials in url are not allowed');++ if (u.search || u.hash) throw new AiError(422, 'exchange.webhook_url', 'url carries no query or fragment');++ if (!ALLOWED_PORTS.has(u.port)) throw new AiError(422, 'exchange.webhook_url', 'url must use port 443 or 8443');++ const host = egress.normalizeHost(u.hostname);++ // Node never calls the lookup hook for an IP literal, so the literal is the check.++ if (net.isIP(host)) throw new AiError(422, 'exchange.webhook_url', 'url must name a host, not an IP address');++ if (egress.isInternalName(host)) throw new AiError(422, 'exchange.webhook_url', 'url must be a public host name');++ if (String(raw).length > MAX_URL) throw new AiError(422, 'exchange.webhook_url', `url is at most ${MAX_URL} characters`);++ u.hostname = host;++ return u;++}++++/** A subscription as its owner sees it; the secret only on the create answer. */++function viewOf(row, { secret = false } = {}) {++ if (!row) return null;++ const out = {++ id: row.id, url: row.url, events: Array.isArray(row.events) ? row.events : parseJson(row.events, []),++ description: row.description || null, created_at: row.created_at, updated_at: row.updated_at,++ };++ if (secret) out.secret = row.secret;++ return out;++}++++function deliveryView(row) {++ return {++ id: row.id, event_id: row.event_id, event_type: row.event_type, status: row.status,++ attempts: Number(row.attempts) || 0, last_status: row.last_status == null ? null : Number(row.last_status),++ last_error: row.last_error || null, last_attempt_at: row.last_attempt_at || null,++ next_attempt_at: row.next_attempt_at || null, delivered_at: row.delivered_at || null,++ created_at: row.created_at, payload: row.payload && typeof row.payload === 'object' ? row.payload : parseJson(row.payload, null),++ };++}++++/** The retry pause after attempt n (1-based): base, 2x, 4x … up to maxDelayMs. */++function delayAfter(attempt, { baseDelayMs, maxDelayMs }) {++ const raw = baseDelayMs * Math.pow(2, Math.max(0, attempt - 1));++ return Math.max(baseDelayMs, Math.min(maxDelayMs, Math.round(raw)));++}++++function createWebhooks({ db, config = null, clock = { now: () => Date.now() }, log = console, transport = null, lookup = null }) {++ const cfg = (config && config.webhooks) || {};++ const enabled = Boolean(db) && cfg.enabled !== false;++ const timeoutMs = Math.max(1000, Math.min(120000, Number(cfg.timeoutMs) || 10000));++ const maxBytes = Math.max(4096, Math.min(1024 * 1024, Number(cfg.maxBytes) || 65536));++ const maxAttempts = Math.max(1, Math.min(12, Number(cfg.maxAttempts) || 6));++ const maxPerOwner = Math.max(1, Math.min(50, Number(cfg.maxPerOwner) || 10));++ const backoff = {++ baseDelayMs: Math.max(100, Number(cfg.baseDelayMs) || 10000),++ maxDelayMs: Math.max(1000, Number(cfg.maxDelayMs) || 3600000),++ };++ const tickMs = Math.max(250, Number(cfg.tickMs) || 1000);++ const safeLookup = egress.createSafeLookup({ ...(lookup ? { lookup } : {}) });++++ let timer = null;++ let working = false;++++ const endpointRows = () => db.prepare('SELECT * FROM exchange_webhook_endpoints ORDER BY created_at DESC');++++ async function list(owner) {++ if (!OWNER_RE.test(String(owner))) throw new AiError(422, 'exchange.webhook_owner', 'owner must be a usr_ or prj_ subject');++ return (await endpointRows().all()).filter((r) => r.owner_subject === owner).map((r) => viewOf(r));++ }++++ async function create({ owner, input }) {++ if (!OWNER_RE.test(String(owner))) throw new AiError(422, 'exchange.webhook_owner', 'owner must be a usr_ or prj_ subject');++ const b = input && typeof input === 'object' && !Array.isArray(input) ? input : {};++ for (const k of Object.keys(b)) {++ if (!['url', 'events', 'description'].includes(k)) throw new AiError(422, 'exchange.webhook_invalid', `unknown field ${k}`);++ }++ const url = judgeUrl(b.url).toString();++ let events = EVENTS.slice();++ if (b.events != null) {++ if (!Array.isArray(b.events) || !b.events.length) throw new AiError(422, 'exchange.webhook_invalid', 'events must be a non-empty array');++ events = [...new Set(b.events.map((e) => String(e)))];++ if (events.some((e) => !EVENT_SET.has(e))) throw new AiError(422, 'exchange.webhook_invalid', `events must be among ${EVENTS.join(', ')}`);++ }++ const description = b.description == null ? null : cut(String(b.description).trim(), 200) || null;++ const existing = (await endpointRows().all()).filter((r) => r.owner_subject === owner);++ if (existing.length >= maxPerOwner) throw new AiError(409, 'exchange.webhook_limit', `at most ${maxPerOwner} webhook receivers per account`);++ const at = iso(clock.now());++ const row = {++ id: `whk_${ids.ulid()}`, owner, url, events, description,++ secret: `whsec_${crypto.randomBytes(32).toString('base64url')}`, created_at: at, updated_at: at,++ };++ await db.prepare(`INSERT INTO exchange_webhook_endpoints (id, owner_subject, url, secret, events, description, created_at, updated_at)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)++ .run(row.id, row.owner, row.url, row.secret, row.events, row.description, row.at || at, at);++ // The secret is shown once, in this answer: a later read never carries it.++ return viewOf({ ...row, created_at: at, updated_at: at }, { secret: true });++ }++++ async function remove({ id, owner }) {++ if (!ENDPOINT_ID.test(String(id)) || !OWNER_RE.test(String(owner))) {++ throw new AiError(404, 'exchange.webhook_not_found', `no webhook ${id}`);++ }++ const row = await db.prepare('SELECT id FROM exchange_webhook_endpoints WHERE id = ? AND owner_subject = ?').get(String(id), String(owner));++ if (!row) throw new AiError(404, 'exchange.webhook_not_found', `no webhook ${id}`);++ await db.prepare('DELETE FROM exchange_webhook_endpoints WHERE id = ?').run(String(id));++ return { id: String(id), removed: true };++ }++++ /** The delivery log of one receiver, newest first. The payload is what was sent; no secret is ever here. */++ async function deliveries({ id, owner, limit = 50 } = {}) {++ if (!ENDPOINT_ID.test(String(id)) || !OWNER_RE.test(String(owner))) {++ throw new AiError(404, 'exchange.webhook_not_found', `no webhook ${id}`);++ }++ const row = await db.prepare('SELECT id FROM exchange_webhook_endpoints WHERE id = ? AND owner_subject = ?').get(String(id), String(owner));++ if (!row) throw new AiError(404, 'exchange.webhook_not_found', `no webhook ${id}`);++ const n = Math.max(1, Math.min(200, Number(limit) || 50));++ const rows = await db.prepare('SELECT * FROM exchange_webhook_deliveries WHERE endpoint_id = ? ORDER BY created_at DESC LIMIT ?').all(String(id), n);++ return rows.map(deliveryView);++ }++++ /** One response, without following anything: the transport seam is the tests'. */++ function once(target, { body, headers }) {++ const send = { method: 'POST', url: target.toString(), headers, body, lookup: safeLookup, timeoutMs, maxBytes };++ if (transport) return transport(send);++ return new Promise((resolve, reject) => {++ let settled = false;++ const fail = (err) => { if (settled) return; settled = true; try { req.destroy(); } catch { /* */ } reject(err); };++ const ok = (value) => { if (settled) return; settled = true; resolve(value); };++ const req = https.request(target, { method: 'POST', headers, lookup: safeLookup, timeout: timeoutMs, agent: false }, (res) => {++ const status = res.statusCode || 0;++ if (status >= 300 && status < 400) { res.resume(); return ok({ status, headers: res.headers, body: '' }); }++ const declared = Number(res.headers['content-length']);++ if (Number.isFinite(declared) && declared > maxBytes) { res.resume(); return fail(new AiError(413, 'exchange.webhook_too_large', `the receiver answered more than ${maxBytes} bytes`)); }++ let bytes = 0;++ const chunks = [];++ res.on('data', (c) => {++ bytes += c.length;++ if (bytes > maxBytes) { res.destroy(); return fail(new AiError(413, 'exchange.webhook_too_large', `the receiver answered more than ${maxBytes} bytes`)); }++ chunks.push(c);++ });++ res.on('error', (err) => fail(err));++ res.on('end', () => ok({ status, headers: res.headers, body: Buffer.concat(chunks).toString('utf8') }));++ });++ req.on('error', (err) => fail(err));++ req.on('timeout', () => fail(new AiError(504, 'exchange.webhook_timeout', `the receiver did not answer within ${timeoutMs}ms`)));++ const guard = setTimeout(() => fail(new AiError(504, 'exchange.webhook_timeout', `the receiver did not answer within ${timeoutMs}ms`)), timeoutMs);++ req.on('close', () => clearTimeout(guard));++ req.end(body);++ });++ }++++ /** A signed delivery of one row, retried or closed. Never throws into the caller. */++ async function attempt(row) {++ const at = iso(clock.now());++ const attemptNo = (Number(row.attempts) || 0) + 1;++ let outcome = { status: null, error: null };++ let headers = null;++ try {++ const target = judgeUrl(row.url);++ // jsonb hands back a parsed object in PostgreSQL; sign the exact string this delivery sends.++ const payload = Buffer.isBuffer(row.payload) ? row.payload.toString('utf8')++ : (typeof row.payload === 'string' ? row.payload : JSON.stringify(row.payload));++ const t = nowSec(clock.now());++ const signature = crypto.createHmac('sha256', String(row.secret)).update(`${t}.`).update(payload).digest('hex');++ headers = {++ 'Content-Type': 'application/json',++ 'User-Agent': 'OpenVibe.Exchange/1.0 (+https://openvibe.exchange)',++ 'X-OpenVibe-Signature-V2': `t=${t},v2=${signature}`,++ 'X-OpenVibe-Event': String(row.event_type),++ 'X-OpenVibe-Delivery': String(row.id),++ 'X-OpenVibe-Webhook-Id': String(row.endpoint_id),++ };++ const res = await once(target, { body: payload, headers });++ const status = Number(res && res.status) || 0;++ outcome = { status, error: null };++ if (status >= 200 && status < 300) {++ await db.prepare(`UPDATE exchange_webhook_deliveries SET status = 'delivered', attempts = ?, last_attempt_at = ?, delivered_at = ?,++ next_attempt_at = NULL, last_status = ?, last_error = NULL WHERE id = ?`).run(attemptNo, at, at, status, row.id);++ return { delivered: true, attempts: attemptNo, status };++ }++ outcome.error = `the receiver answered HTTP ${status}`;++ } catch (err) {++ outcome = { status: null, error: String((err && err.message) || err) };++ }++ const retry = attemptNo < maxAttempts++ && (outcome.status == null || [408, 425, 429, 500, 502, 503, 504].includes(outcome.status))++ && !(outcome.status >= 300 && outcome.status < 400);++ if (retry) {++ const next = iso(clock.now() + delayAfter(attemptNo, backoff));++ await db.prepare(`UPDATE exchange_webhook_deliveries SET status = 'pending', attempts = ?, last_attempt_at = ?, next_attempt_at = ?,++ last_status = ?, last_error = ? WHERE id = ?`).run(attemptNo, at, next, outcome.status, cut(outcome.error, 500), row.id);++ return { delivered: false, attempts: attemptNo, status: outcome.status, next_attempt_at: next, error: outcome.error };++ }++ await db.prepare(`UPDATE exchange_webhook_deliveries SET status = 'failed', attempts = ?, last_attempt_at = ?, next_attempt_at = NULL,++ last_status = ?, last_error = ? WHERE id = ?`).run(attemptNo, at, outcome.status, cut(outcome.error, 500), row.id);++ return { delivered: false, attempts: attemptNo, status: outcome.status, error: outcome.error };++ }++++ /**++ * Deliver every due row once. A row is claimed by pushing next_attempt_at a lease ahead, an atomic UPDATE, so++ * several workers of this service never send the same delivery twice; a worker that dies leaves the lease to++ * expire, and the next tick tries again.++ */++ async function deliverDue({ limit = 25 } = {}) {++ if (!enabled) return { sent: 0, delivered: 0, failed: 0 };++ const due = iso(clock.now());++ const lease = iso(clock.now() + Math.max(30000, timeoutMs + 30000));++ const rows = await db.prepare(`SELECT d.*, e.url, e.secret FROM exchange_webhook_deliveries d++ JOIN exchange_webhook_endpoints e ON e.id = d.endpoint_id++ WHERE d.status = 'pending' AND (d.next_attempt_at IS NULL OR d.next_attempt_at <= ?)++ ORDER BY d.next_attempt_at NULLS FIRST, d.created_at LIMIT ?`).all(due, Math.max(1, Math.min(100, Number(limit) || 25)));++ let sent = 0;++ let delivered = 0;++ let failed = 0;++ for (const row of rows) {++ const claimed = await db.prepare(`UPDATE exchange_webhook_deliveries SET next_attempt_at = ?++ WHERE id = ? AND status = 'pending' AND (next_attempt_at IS NULL OR next_attempt_at <= ?)`).run(lease, row.id, due);++ if (!claimed || !claimed.changes) continue;++ sent++;++ const out = await attempt(row);++ if (out.delivered) delivered++;++ else if (out.next_attempt_at == null) failed++;++ }++ return { sent, delivered, failed };++ }++++ /** Wake the worker after a write; a no-op before start() and inside tests that call deliverDue themselves. */++ function kick() {++ if (!timer || working) return;++ setImmediate(async () => {++ if (working) return;++ working = true;++ try { await deliverDue(); } catch (err) { log.warn(`[webhooks] delivery failed: ${err && err.message}`); }++ finally { working = false; }++ });++ }++++ /**++ * Queue one event for every matching receiver of an owner. The payload is the exact JSON string that will be++ * signed and sent, stored once per delivery. Never throws: a webhook is a notification, never a reason for the++ * write that caused it to fail.++ */++ async function emit({ owner, type, data, source = null, dedupe = true }) {++ if (!enabled || !OWNER_RE.test(String(owner)) || !EVENT_SET.has(String(type))) return 0;++ let rows;++ try {++ rows = await db.prepare('SELECT * FROM exchange_webhook_endpoints WHERE owner_subject = ? AND ? = ANY(events)').all(String(owner), String(type));++ } catch (err) {++ log.warn(`[webhooks] ${type} receivers for ${owner}: ${err && err.message}`);++ return 0;++ }++ if (!rows.length) return 0;++ const at = iso(clock.now());++ const eventId = dedupe && source++ ? `evt_${sha256(`${type}|${source}|${owner}`).slice(0, 26)}`++ : `evt_${ids.ulid()}`;++ const payload = JSON.stringify({ id: eventId, type, created_at: at, data: data || {} });++ let queued = 0;++ for (const row of rows) {++ try {++ await db.prepare(`INSERT INTO exchange_webhook_deliveries (id, endpoint_id, owner_subject, event_id, event_type, payload, status, attempts, next_attempt_at, created_at)++ VALUES (?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?)`)++ .run(`whd_${ids.ulid()}`, row.id, owner, eventId, String(type), payload, at, at);++ queued++;++ } catch (err) {++ log.warn(`[webhooks] ${type} delivery for ${owner}: ${err && err.message}`);++ }++ }++ kick();++ return queued;++ }++++ /**++ * The usage event of an answered run, and the earnings event when a community offer served it. The owner is the++ * run's own requester (a person or a project); a community earnings event also reaches the project that++ * registered the endpoint. The prompt and the answer are never in either payload.++ */++ async function usage({ row, record, outcome = 'succeeded' } = {}) {++ if (!enabled || !row || !record) return 0;++ const owner = row.requester_type === 'project' ? row.requester_id : (record.subject || null);++ let queued = 0;++ if (owner) {++ queued += await emit({++ owner, type: 'usage', source: row.id,++ data: {++ run_id: row.id, outcome,++ provider: record.provider || null, model: record.model || null,++ tokens: {++ input: Number(record.tokens_input) || 0, output: Number(record.tokens_output) || 0,++ reasoning: Number(record.tokens_reasoning) || 0,++ cache_read: Number(record.tokens_cache_read) || 0, cache_write: Number(record.tokens_cache_write) || 0,++ },++ cost_usd: {++ list: Number(record.list_cost_usd) || 0, charged: Number(record.charged_usd) || 0,++ fee: Number(record.fee_usd) || 0, cache_saved: Number(record.cache_saved_usd) || 0,++ },++ paid_by: record.paid_by || null, latency_ms: record.latency_ms == null ? null : Number(record.latency_ms),++ ttft_ms: record.ttft_ms == null ? null : Number(record.ttft_ms), at: record.at || iso(clock.now()),++ },++ });++ }++ const explain = parseJson(row.explain, null);++ const c = explain && explain.community;++ if (outcome === 'succeeded' && c && c.owner && (Number(c.credits_micro) > 0 || Number(c.kudos) > 0)) {++ const data = {++ offer_id: c.offer_id || null, kind: c.kind || 'machine', model: c.model || null, run_id: row.id,++ tokens: { input: Number(c.tokens_in) || 0, output: Number(c.tokens_out) || 0 },++ credits_micro: Number(c.credits_micro) || 0, earning_status: c.earning_status || null,++ self_served: Boolean(c.self_served), kudos: Number(c.kudos) || 0, at: record.at || iso(clock.now()),++ };++ queued += await emit({ owner: c.owner, type: 'earnings', source: `${row.id}|${c.owner}`, data });++ if (c.project_id && c.project_id !== c.owner) {++ queued += await emit({ owner: c.project_id, type: 'earnings', source: `${row.id}|${c.project_id}`, data });++ }++ }++ return queued;++ }++++ /** An endpoint offer is gone: tell its owner (and the developer project that registered it), once. */++ async function offerDown({ owner, projectId = null, offer = null, reason = 'removed by the seller' } = {}) {++ if (!enabled || !owner) return 0;++ const data = {++ offer_id: (offer && offer.offer_id) || null, kind: 'endpoint',++ models: (offer && Array.isArray(offer.models)) ? offer.models : [], reason, at: iso(clock.now()),++ };++ let queued = await emit({ owner, type: 'offer.down', source: `offer|${(offer && offer.id) || 'unknown'}`, data });++ if (projectId && projectId !== owner) {++ queued += await emit({ owner: projectId, type: 'offer.down', source: `offer|${(offer && offer.id) || 'unknown'}`, data });++ }++ return queued;++ }++++ /** Start the retry worker (off in tests, which call deliverDue with their own clock). */++ function start() {++ if (!enabled || timer) return;++ timer = setInterval(async () => {++ if (working) return;++ working = true;++ try { await deliverDue(); } catch (err) { log.warn(`[webhooks] delivery failed: ${err && err.message}`); }++ finally { working = false; }++ }, tickMs);++ if (timer.unref) timer.unref();++ }++++ function stop() {++ if (timer) clearInterval(timer);++ timer = null;++ }++++ return { enabled, EVENTS, list, create, remove, deliveries, emit, usage, offerDown, deliverDue, start, stop, _test: { judgeUrl, delayAfter } };++}++++module.exports = { createWebhooks, judgeUrl, EVENTS };@@@ -32,7 +32,7 @@ const { AiError } = require('./util');const { gracefulStop, telemetry } = require('openvibe-sdk/service');const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk/telemetry');--async function start({ config, db: givenDb = null, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, listen = true, credentialFetch = null, valkey = null, research = null, machineModels = null, variantEvidence = null, community: givenCommunity = null, communityEndpoints: givenEndpoints = null, abuse: givenAbuse = null } = {}) {++async function start({ config, db: givenDb = null, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, listen = true, credentialFetch = null, valkey = null, research = null, machineModels = null, variantEvidence = null, community: givenCommunity = null, communityEndpoints: givenEndpoints = null, webhooks: givenWebhooks = null, abuse: givenAbuse = null } = {}) {config = config || load(env);schemas.configure(config.schemaCache);// PostgreSQL (ADR-035): opened and migrated here unless the caller (a test) hands in a migrated handle.@@@ -70,6 +70,9 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.nowconst endpoints = givenEndpoints || (config.community.endpoints && config.community.endpoints.enabled === false? null: require('./community/endpoints').createEndpointOffers({ db, config, env, clock, fetchImpl, log }));++ // Exchange webhooks (the plan's "For builders", item 7): the receivers a developer registers, the signed++ // deliveries of usage/earnings/offer.down, and the retry worker. A row per delivery; the worker is off in tests.++ const webhooks = givenWebhooks || require('./exchange/webhooks').createWebhooks({ db, config, clock, log });const community = givenCommunity || require('./community').createCommunity({ db, config, env, machines, endpoints, clock, fetchImpl, log });// Plan E5: the free tier's allowance by trust, device and network signals, proof of work and anomaly throttling.const abuse = givenAbuse || require('./abuse').createAbuse({ db, config, env, clock, fetchImpl, log, registry, community });@@@ -128,7 +131,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.nowstartSettlements() { return credits ? credits.startSettlements() : null; },stopSettlements() { return credits ? credits.stopSettlements() : undefined; },};-- const runs = createRuns({ db, registry, engine, cache, quotas, config, clock, log, userModules, credentials, preferences, tiers, projectSecrets, pool, runBudgets, credits: creditsRef, community, presets, receipts, abuse });++ const runs = createRuns({ db, registry, engine, cache, quotas, config, clock, log, userModules, credentials, preferences, tiers, projectSecrets, pool, runBudgets, credits: creditsRef, community, presets, receipts, abuse, webhooks });await seed({ registry, quotas, config, env, db });// The seed creates or moves providers from the environment: publish their origins (the schema and every key's// bind follow), so a key is bound to the host it is for from the first boot.@@@ -161,7 +164,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.nowclientSecret: env.OV_OAUTH_CLIENT_SECRET, fetch: fetchImpl,}) : null;const benchEvents = createBenchEvents({ bench, accountData, accountSend, config, log, fetchImpl, clientSecret: env.OV_OAUTH_CLIENT_SECRET });-- const app = createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, preferences, grants, vault, settings, benchEvents, runBudgets, memory, memoryImport, community, endpoints, presets, receipts, balance, gpus, abuse, priceBook });++ const app = createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, preferences, grants, vault, settings, benchEvents, runBudgets, memory, memoryImport, community, endpoints, webhooks, presets, receipts, balance, gpus, abuse, priceBook });let jwks = null;let keyLoaded = Promise.resolve(null);if (!keys.pinned && config.nodeEnv !== 'test') {@@@ -198,6 +201,8 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.nowlet benchSubscription = null;let accountSubscription = null;if (listen) userModules.start();++ // The signed webhook retry worker: never in test mode (tests call deliverDue with their own clock).++ if (listen && config.nodeEnv !== 'test') webhooks.start();if (listen) balance.start();if (listen && config.memoryJobs && config.memoryJobs.enabled) memoryJobs.start();// The price book reads the providers' listings once a week; it never starts in test mode or under a drill.@@@ -238,6 +243,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.nowuserModules.stop();memoryJobs.stop();priceBook.stop();++ webhooks.stop();if (credits) credits.stopSettlements();if (jwks) jwks.stop();if (server) {@@@ -253,7 +259,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.nowif (!givenDb) await db.close();}-- return { config, db, registry, pool, quotas, tiers, cache, fetcher, engine, runs, keys, keyLoaded, auth, app, server, bench, benchEvents, memory, memoryJobs, priceBook, credits: creditsRef, settings, vault, community, endpoints, gpus, abuse, close };++ return { config, db, registry, pool, quotas, tiers, cache, fetcher, engine, runs, keys, keyLoaded, auth, app, server, bench, benchEvents, memory, memoryJobs, priceBook, credits: creditsRef, settings, vault, community, endpoints, webhooks, gpus, abuse, close };}if (require.main === module) {nnew file mode 100644@@@ -0,0 +1,248 @@++'use strict';++/**++ * The OpenVibe.Exchange MCP server (design EXCHANGE-REMAKE-2026-11-06, "For builders", item 7): the tools an MCP++ * client (Claude Desktop, Cursor, an agent) uses to pick a model and run a completion on the person's own account.++ *++ * It speaks MCP over stdio in bin/openvibe-exchange-mcp.js and calls the public Exchange API with the person's++ * Network token (OPENVIBE_EXCHANGE_TOKEN, sent as a Bearer, never printed or logged):++ *++ * exchange_models GET /api/v1/exchange/catalog -- the live catalog: price in/out, measured p50/p95, uptime++ * exchange_chat POST /v1/chat/completions -- the person's allowance, or their own stored provider key when the++ * request names a provider (x-openvibe-credential), exactly as the SDK's buyer side does++ * exchange_usage GET /api/v1/exchange/me/usage -- the person's own requests, tokens and spend by day and model++ *++ * The server never stores a key, a prompt or an answer, and it does not answer a tool call without a token: a++ * missing token is refused before any request, and a 401 from the Exchange is refused back as the tool's error.++ */++const pkg = require('../../package.json');++++const MCP_PROTOCOL = '2024-11-05';++const DEFAULT_BASE = 'https://api.openvibe.exchange';++const DEFAULT_TIMEOUT_MS = 30000;++const CHAT_TIMEOUT_MS = 180000;++const MAX_BODY = 8 * 1024 * 1024;++const MAX_MESSAGES = 200;++const MAX_CONTENT = 200000;++++class McpError extends Error {++ constructor(message, code = 'exchange.error') { super(message); this.name = 'McpError'; this.code = code; }++}++++const text = (s) => String(s == null ? '' : s);++++/** The error the Exchange answered, without ever echoing the token. */++function apiError(status, body) {++ const detail = body && (body.detail || (body.error && body.error.message) || body.title);++ if (status === 401) return new McpError('the Exchange refused the token (401): make a new Network developer key and set OPENVIBE_EXCHANGE_TOKEN', 'auth.refused');++ if (status === 403) return new McpError(`the Exchange refused this token (403${detail ? `: ${text(detail)}` : ''})`, 'auth.refused');++ return new McpError(`the Exchange answered HTTP ${status}${detail ? `: ${text(detail)}` : ''}`, 'exchange.error');++}++++/** The tools, with the JSON schemas an MCP client shows. */++const TOOLS = [++ {++ name: 'exchange_models',++ description: 'List the models OpenVibe.Exchange can run right now, with the live price (USD per million tokens, in and out), measured p50/p95 latency in ms, uptime (0-1), health and the provider data policy. Optional query and kind (chat, embedding, stt, image) filter the list. Public: no token is needed for this read.',++ inputSchema: {++ type: 'object', properties: {++ query: { type: 'string', description: 'Only models whose slug, name or family contains this text' },++ kind: { type: 'string', enum: ['chat', 'embedding', 'stt', 'image'], description: 'Only models of this kind' },++ limit: { type: 'integer', minimum: 1, maximum: 200, description: 'How many models to return (default 25)' },++ },++ },++ },++ {++ name: 'exchange_chat',++ description: "Run one chat completion on the person's OpenVibe.Exchange account: the person's daily free allowance or Balance, or their own stored provider key when `credential` names the provider. Returns the OpenAI-shaped answer and the receipt (x_openvibe). The prompt and the answer are not stored by the MCP server.",++ inputSchema: {++ type: 'object', required: ['model', 'messages'], properties: {++ model: { type: 'string', description: 'A model id (deepseek/deepseek-flash), openvibe/auto, or @preset/<name>' },++ messages: {++ type: 'array', minItems: 1, maxItems: MAX_MESSAGES,++ items: { type: 'object', required: ['role', 'content'], properties: {++ role: { type: 'string', enum: ['system', 'user', 'assistant', 'tool'] },++ content: { type: 'string' },++ } },++ },++ max_tokens: { type: 'integer', minimum: 1, maximum: 32768, description: 'Default 512' },++ temperature: { type: 'number', minimum: 0, maximum: 2 },++ credential: { type: 'string', description: "A provider whose key the person stored on OpenVibe (for example deepseek), instead of the router's own capacity" },++ },++ },++ },++ {++ name: 'exchange_usage',++ description: "Read the person's own Exchange usage for the last N days (or a date window): requests, tokens and spend by day, model and workflow. Never anyone else's usage.",++ inputSchema: {++ type: 'object', properties: {++ days: { type: 'integer', minimum: 1, maximum: 366, description: 'Look back this many days (default 30); ignored with from/to' },++ from: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$', description: 'First day, YYYY-MM-DD' },++ to: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$', description: 'Last day, YYYY-MM-DD' },++ },++ },++ },++];++++const byName = new Map(TOOLS.map((t) => [t.name, t]));++++function createExchangeMcp({ token = null, baseUrl = DEFAULT_BASE, fetchImpl = globalThis.fetch, timeoutMs = DEFAULT_TIMEOUT_MS, version = pkg.version } = {}) {++ const base = String(baseUrl || DEFAULT_BASE).replace(/\/+$/, '');++ const authToken = token == null ? '' : String(token);++++ async function publicCatalog({ kind = null } = {}) {++ const q = kind ? `?kind=${encodeURIComponent(kind)}` : '';++ const headers = { Accept: 'application/json' };++ if (authToken) headers.Authorization = `Bearer ${authToken}`;++ let res;++ try { res = await fetchImpl(`${base}/api/v1/exchange/catalog${q}`, { headers, signal: AbortSignal.timeout(timeoutMs) }); }++ catch (err) { throw new McpError(`the Exchange could not be reached: ${String((err && err.message) || err).slice(0, 200)}`, 'exchange.unreachable'); }++ const raw = await res.text();++ let data = null;++ try { data = raw ? JSON.parse(raw) : null; } catch { data = null; }++ if (!res.ok) throw apiError(res.status, data);++ return data;++ }++++ function compactModels(catalog, { query = null, limit = 25 } = {}) {++ const q = query == null ? '' : String(query).trim().toLowerCase();++ let models = Array.isArray(catalog && catalog.models) ? catalog.models : [];++ if (q) models = models.filter((m) => [m.slug, m.name, m.family, m.canonical].some((v) => String(v || '').toLowerCase().includes(q)));++ const n = Math.max(1, Math.min(200, Number(limit) || 25));++ return {++ generated_at: catalog && catalog.generated_at ? catalog.generated_at : null,++ window_days: catalog && catalog.window_days != null ? catalog.window_days : null,++ count: models.length,++ free_available: Boolean(catalog && catalog.free_available),++ models: models.slice(0, n).map((m) => ({++ slug: m.slug, name: m.name, family: m.family, kind: m.kind, uncensored: Boolean(m.uncensored),++ context: m.context == null ? null : m.context, free_now: Boolean(m.free_now),++ offers: (Array.isArray(m.offers) ? m.offers : []).map((o) => ({++ provider: o.provider, model: o.model, status: o.status, trust: o.trust,++ price: o.price ? { in: o.price.in, out: o.price.out, cached: o.price.cached, per: o.price.per } : null,++ speed: o.speed ? { p50_ms: o.speed.p50_ms, p95_ms: o.speed.p95_ms } : null,++ uptime: o.uptime == null ? null : o.uptime,++ requests_7d: o.requests_7d == null ? null : o.requests_7d,++ free_tier: Boolean(o.free_tier), privacy: o.privacy || null,++ })),++ })),++ };++ }++++ function checkChat(args) {++ const a = args && typeof args === 'object' ? args : {};++ const model = text(a.model).trim();++ if (!model) throw new McpError('exchange_chat needs a model', 'input.invalid');++ if (!Array.isArray(a.messages) || !a.messages.length || a.messages.length > MAX_MESSAGES) {++ throw new McpError(`exchange_chat needs 1 to ${MAX_MESSAGES} messages`, 'input.invalid');++ }++ const messages = a.messages.map((m) => {++ if (!m || typeof m !== 'object') throw new McpError('each message is {role, content}', 'input.invalid');++ const role = text(m.role);++ if (!['system', 'user', 'assistant', 'tool'].includes(role)) throw new McpError('a message role is system, user, assistant or tool', 'input.invalid');++ return { role, content: text(m.content).slice(0, MAX_CONTENT) };++ });++ const body = { model, messages, stream: false };++ if (a.max_tokens != null) {++ const n = Number(a.max_tokens);++ if (!Number.isInteger(n) || n < 1 || n > 32768) throw new McpError('max_tokens is an integer from 1 to 32768', 'input.invalid');++ body.max_tokens = n;++ }++ if (a.temperature != null) {++ const t = Number(a.temperature);++ if (!Number.isFinite(t) || t < 0 || t > 2) throw new McpError('temperature is a number from 0 to 2', 'input.invalid');++ body.temperature = t;++ }++ return { body, credential: a.credential == null ? null : text(a.credential).trim().slice(0, 64) };++ }++++ async function runTool(name, args) {++ if (name === 'exchange_models') {++ const a = args && typeof args === 'object' ? args : {};++ const kind = a.kind == null ? null : text(a.kind);++ if (kind && !['chat', 'embedding', 'stt', 'image'].includes(kind)) throw new McpError('kind is chat, embedding, stt or image', 'input.invalid');++ const catalog = await publicCatalog({ kind });++ return compactModels(catalog, { query: a.query, limit: a.limit });++ }++ if (name === 'exchange_chat') {++ const { body, credential } = checkChat(args);++ const headers = {};++ if (credential) headers['x-openvibe-credential'] = credential;++ return await call('/v1/chat/completions', { method: 'POST', body, headers, timeout: CHAT_TIMEOUT_MS });++ }++ if (name === 'exchange_usage') {++ const a = args && typeof args === 'object' ? args : {};++ const params = new URLSearchParams();++ if (a.from || a.to) {++ for (const k of ['from', 'to']) {++ if (a[k] == null) continue;++ const v = text(a[k]).slice(0, 10);++ if (!/^\d{4}-\d{2}-\d{2}$/.test(v)) throw new McpError(`${k} is YYYY-MM-DD`, 'input.invalid');++ params.set(k, v);++ }++ } else {++ const days = a.days == null ? 30 : Number(a.days);++ if (!Number.isInteger(days) || days < 1 || days > 366) throw new McpError('days is an integer from 1 to 366', 'input.invalid');++ params.set('days', String(days));++ }++ return await call(`/api/v1/exchange/me/usage?${params.toString()}`);++ }++ throw new McpError(`unknown tool ${name}`, 'tools.unknown');++ }++++ /** The authenticated JSON call; extra headers carry the person's own provider key selector. */++ async function call(path, { method = 'GET', body = null, headers = {}, timeout = timeoutMs } = {}) {++ if (!authToken) throw new McpError('not signed in: set OPENVIBE_EXCHANGE_TOKEN to a Network developer key (the Exchange MCP server never runs without one)', 'auth.missing');++ const all = { Authorization: `Bearer ${authToken}`, Accept: 'application/json', ...headers };++ if (body != null) all['Content-Type'] = 'application/json';++ let res;++ try {++ res = await fetchImpl(`${base}${path}`, { method, headers: all, body: body == null ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(timeout) });++ } catch (err) {++ throw new McpError(`the Exchange could not be reached: ${String((err && err.message) || err).slice(0, 200)}`, 'exchange.unreachable');++ }++ const raw = await res.text();++ let data = null;++ try { data = raw ? JSON.parse(raw) : null; } catch { data = null; }++ if (!res.ok) throw apiError(res.status, data);++ return data;++ }++++ /** One JSON-RPC message -> the response object, or null for a notification. */++ async function handle(message) {++ const isObject = message && typeof message === 'object' && !Array.isArray(message);++ if (!isObject) return { jsonrpc: '2.0', id: null, error: { code: -32600, message: 'invalid request' } };++ const id = message.id === undefined ? null : message.id;++ const method = text(message.method);++ const notification = message.id === undefined;++ const reply = (result) => (notification ? null : { jsonrpc: '2.0', id, result });++ const fail = (code, msg) => (notification ? null : { jsonrpc: '2.0', id, error: { code, message: msg } });++ if (method === 'initialize') {++ return reply({++ protocolVersion: MCP_PROTOCOL,++ capabilities: { tools: { listChanged: false } },++ serverInfo: { name: 'openvibe-exchange', version },++ instructions: 'Tools for OpenVibe.Exchange: list live models and prices, run a chat completion on the signed-in person\'s allowance or their own provider key, and read their usage. Set OPENVIBE_EXCHANGE_TOKEN to a Network developer key.',++ });++ }++ if (!method || method.startsWith('notifications/')) return null;++ if (method === 'ping') return reply({});++ if (method === 'tools/list') return reply({ tools: TOOLS });++ if (method === 'tools/call') {++ const name = text(message.params && message.params.name);++ const args = (message.params && message.params.arguments) || {};++ if (!byName.has(name)) return reply({ content: [{ type: 'text', text: `unknown tool ${name}` }], isError: true });++ try {++ const value = await runTool(name, args);++ return reply({ content: [{ type: 'text', text: JSON.stringify(value) }] });++ } catch (err) {++ const msg = err instanceof McpError ? err.message : `the tool failed: ${String((err && err.message) || err).slice(0, 300)}`;++ return reply({ content: [{ type: 'text', text: msg }], isError: true });++ }++ }++ return fail(-32601, `method not found: ${method}`);++ }++++ return { handle, tools: TOOLS, baseUrl: base, hasToken: Boolean(authToken), _test: { compactModels, checkChat } };++}++++module.exports = { createExchangeMcp, McpError, TOOLS, MCP_PROTOCOL };@@@ -374,6 +374,61 @@ route('get', '/exchange/price-changes', {} }) },});++// ── Webhooks: usage, earnings and offer.down (the design's "For builders", item 7) ─────────────++const exchangeWebhook = {++ type: 'object', required: ['id', 'url', 'events', 'created_at'], properties: {++ id: str({ pattern: '^whk_[0-9A-HJKMNP-TV-Z]{26}$' }),++ url: str({ description: 'The https receiver the signed deliveries go to' }),++ events: arrayOf(str({ enum: ['usage', 'earnings', 'offer.down'] })),++ description: nullable('string'),++ created_at: str({ format: 'date-time' }), updated_at: str({ format: 'date-time' }),++ secret: str({ description: 'The HMAC signing secret (whsec_…), in the create answer only. Keep it; it is never shown again.' }),++ },++};++const exchangeWebhookDelivery = {++ type: 'object', required: ['id', 'event_id', 'event_type', 'status', 'attempts'], properties: {++ id: str({ pattern: '^whd_[0-9A-HJKMNP-TV-Z]{26}$' }), event_id: str(), event_type: str({ enum: ['usage', 'earnings', 'offer.down'] }),++ status: str({ enum: ['pending', 'delivered', 'failed'] }), attempts: integer({ minimum: 0 }),++ last_status: nullable('integer'), last_error: nullable('string'),++ last_attempt_at: nullable('string', { format: 'date-time' }), next_attempt_at: nullable('string', { format: 'date-time' }),++ delivered_at: nullable('string', { format: 'date-time' }), created_at: str({ format: 'date-time' }),++ payload: { type: 'object', description: 'The exact event body that was signed and sent; no prompt, answer or secret' },++ },++};++route('post', '/exchange/webhooks', {++ operationId: 'createExchangeWebhook', summary: 'Register an https webhook receiver', tags: ['exchange'], security: SERVICE_OR_APP,++ description: 'ai.app.run (a developer key) or a person\'s own token; a first-party service token is refused. The body is {url, events?, description?}. The receiver URL is judged at registration by the egress guard: https, a public DNS answer, never an IP literal, port 443 or 8443, no query or fragment. The signing secret (whsec_…) is in this answer only. Every delivery carries X-OpenVibe-Signature-V2: t=<unix seconds>, v2=<hex HMAC-SHA256 of "<t>.<raw body>">, exactly the header openvibe-sdk/exchange verifyWebhook checks.',++ requestBody: body({ type: 'object', required: ['url'], properties: {++ url: str({ description: 'https receiver, a public host name' }),++ events: arrayOf(str({ enum: ['usage', 'earnings', 'offer.down'] }), { minItems: 1, maxItems: 3 }),++ description: str({ maxLength: 200 }),++ } }),++ responses: { 201: json('The receiver, with its secret once', { type: 'object', required: ['webhook'], properties: { webhook: exchangeWebhook } }), 409: problem, 422: problem, ...errors([401, 403]) },++});++route('get', '/exchange/webhooks', {++ operationId: 'listExchangeWebhooks', summary: "The caller's webhook receivers (never a secret)", tags: ['exchange'], security: SERVICE_OR_APP,++ description: 'The receivers of the person the token acts for, or of the developer app\'s project.',++ responses: { 200: json('The receivers', { type: 'object', required: ['owner', 'webhooks'], properties: { owner: str(), webhooks: arrayOf(exchangeWebhook) } }), ...errors([401, 403]) },++});++route('delete', '/exchange/webhooks/{id}', {++ operationId: 'deleteExchangeWebhook', summary: 'Remove one of the caller\'s webhook receivers', tags: ['exchange'], security: SERVICE_OR_APP,++ description: 'The receiver and its delivery history are removed. The secret is never shown again after creation.',++ parameters: [path('id', str({ pattern: '^whk_[0-9A-HJKMNP-TV-Z]{26}$' }))],++ responses: { 200: json('The removal', { type: 'object', properties: { id: str(), removed: { type: 'boolean' } } }), 404: problem, ...errors([401, 403]) },++});++route('get', '/exchange/webhooks/{id}/deliveries', {++ operationId: 'listExchangeWebhookDeliveries', summary: 'The delivery log: what was sent, how often and what the receiver answered', tags: ['exchange'], security: SERVICE_OR_APP,++ description: 'Newest first. A delivery is retried with an exponential backoff on a 408, 425, 429 or 5xx and on a network failure; any other non-2xx status fails it at once. The payload is the exact event body that was signed.',++ parameters: [path('id', str({ pattern: '^whk_[0-9A-HJKMNP-TV-Z]{26}$' })), query('limit', integer({ minimum: 1, maximum: 200 }), 'Default 50')],++ responses: { 200: json('The deliveries', { type: 'object', required: ['id', 'deliveries'], properties: { id: str(), deliveries: arrayOf(exchangeWebhookDelivery) } }), 404: problem, ...errors([401, 403]) },++});++route('get', '/exchange/me/usage', {++ operationId: 'exchangeMyUsage', summary: 'The caller\'s own requests, tokens and spend by day and model', tags: ['exchange'], security: SERVICE_OR_APP,++ description: 'A person\'s own usage, or a developer app\'s project usage. Never anyone else\'s: the caller is the owner, read from the token. Use from/to as YYYY-MM-DD; the rows are the same usage_daily roll-ups the platform keeps.',++ parameters: [query('from', str({ format: 'date' }), 'First day, YYYY-MM-DD'), query('to', str({ format: 'date' }), 'Last day, YYYY-MM-DD')],++ responses: { 200: json('The usage rows', { type: 'object', properties: { requester: str(), usage: arrayOf({ type: 'object' }) } }), ...errors([401, 403, 422]) },++});++// ── The seller API: a standalone OpenAI-compatible endpoint (the design's SDK gap) ───────────const sellerEndpointOffer = {type: 'object', required: ['id', 'offer_id', 'base_url', 'models', 'hours', 'concurrency', 'price', 'audience'],@@@ -106,7 +106,7 @@ function routePinRequest(value, principal, wf) {return { provider, model };}--function createRuns({ db, registry, engine, cache, quotas, config, clock = { now: () => Date.now() }, log = console, userModules = null, credentials = null, preferences: personPreferences = null, tiers = null, projectSecrets = null, pool = null, runBudgets = null, credits = null, community = null, presets = null, receipts = null, abuse = null }) {++function createRuns({ db, registry, engine, cache, quotas, config, clock = { now: () => Date.now() }, log = console, userModules = null, credentials = null, preferences: personPreferences = null, tiers = null, projectSecrets = null, pool = null, runBudgets = null, credits = null, community = null, presets = null, receipts = null, abuse = null, webhooks = null }) {const inflight = new Map(); // run id -> { controller, promise, release, caller }const queue = []; // run ids waiting for the workerlet active = 0;@@@ -563,6 +563,12 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now});// The event (ai.call.recorded) is queued in its own transaction, like every other event here.await db.tx(async () => { await events.callRecorded(rec); });++ // Exchange webhooks (server/exchange/webhooks.js): the usage event, and the earnings event when a++ // community offer served the run. They are rows delivered after this write, never a reason to fail it.++ if (webhooks && typeof webhooks.usage === 'function') {++ try { await webhooks.usage({ row, record: rec, outcome }); }++ catch (err) { log.warn(`[runs] ${row.id}: the exchange webhook was not queued: ${err && err.message}`); }++ }return { payer, prices, money, tokens };} catch (err) {log.warn(`[runs] ${row.id}: the usage record was not written: ${err && err.message}`);nnew file mode 100644@@@ -0,0 +1,143 @@++'use strict';++// The Exchange MCP server (the design's "For builders", item 7): list live models, run a completion on the person's++// own account, read their usage, and refuse a call without a token (or with a token the Exchange refuses) before it++// can spend anything. The transport is a local fake Exchange; no test reaches the network.++const assert = require('assert');++const http = require('node:http');++const { suite } = require('./helpers');++const { createExchangeMcp } = require('../server/mcp/exchange');++++const t = suite('exchange-mcp');++const TOKEN = 'ovk_test_person_token_0123456789';++const PERSON = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A1';++++function fakeExchange() {++ const calls = [];++ const server = http.createServer((req, res) => {++ let raw = '';++ req.on('data', (c) => { raw += c; });++ req.on('end', () => {++ let body = null;++ try { body = raw ? JSON.parse(raw) : null; } catch { body = raw; }++ calls.push({ method: req.method, url: req.url, headers: req.headers, body });++ const send = (status, value) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(value)); };++ if (req.url.startsWith('/api/v1/exchange/catalog')) {++ return send(200, {++ generated_at: '2026-11-07T12:00:00Z', window_days: 7, count: 1, free_available: true,++ models: [{++ slug: 'deepseek/deepseek-flash', name: 'DeepSeek Flash', family: 'DeepSeek', kind: 'chat',++ uncensored: false, context: 128000, free_now: true,++ offers: [{ provider: 'deepseek', model: 'deepseek-chat', status: 'up', trust: 'first-party',++ price: { in: 0.14, out: 0.42, cached: 0.014, per: '1M tokens', currency: 'USD' },++ speed: { p50_ms: 320, p95_ms: 900 }, uptime: 0.997, requests_7d: 1200, free_tier: true,++ privacy: { label: 'private' } }],++ }],++ });++ }++ if (req.url.startsWith('/v1/chat/completions')) {++ if (req.headers.authorization !== `Bearer ${TOKEN}`) return send(401, { detail: 'bad token' });++ return send(200, {++ id: 'chatcmpl_1', object: 'chat.completion', model: body.model,++ choices: [{ index: 0, message: { role: 'assistant', content: 'Hello from the Exchange' }, finish_reason: 'stop' }],++ usage: { prompt_tokens: 4, completion_tokens: 3 },++ x_openvibe: { run_id: 'run_1', cost_usd: 0, receipt_id: 'rcpt_1' },++ });++ }++ if (req.url.startsWith('/api/v1/exchange/me/usage')) {++ if (req.headers.authorization !== `Bearer ${TOKEN}`) return send(401, { detail: 'bad token' });++ return send(200, { requester: `user:${PERSON}`, usage: [{ day: '2026-11-07', requests: 2, tokens_in: 10, tokens_out: 5, cost_usd: 0.000004 }] });++ }++ return send(404, { detail: 'not found' });++ });++ });++ return new Promise((resolve) => {++ server.listen(0, '127.0.0.1', () => {++ resolve({ url: `http://127.0.0.1:${server.address().port}`, calls, close: () => new Promise((r) => { server.closeAllConnections?.(); server.close(() => r()); }) });++ });++ });++}++++let fake;++let mcp;++const call = (id, name, args) => mcp.handle({ jsonrpc: '2.0', id, method: 'tools/call', params: { name, arguments: args } });++++t.test('initialize and tools/list', async () => {++ fake = await fakeExchange();++ mcp = createExchangeMcp({ token: TOKEN, baseUrl: fake.url });++ const init = await mcp.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test' } } });++ assert.strictEqual(init.result.protocolVersion, '2024-11-05');++ assert.strictEqual(init.result.serverInfo.name, 'openvibe-exchange');++ const list = await mcp.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list' });++ assert.deepStrictEqual(list.result.tools.map((x) => x.name).sort(), ['exchange_chat', 'exchange_models', 'exchange_usage']);++ assert.strictEqual(await mcp.handle({ jsonrpc: '2.0', method: 'notifications/initialized' }), null);++ assert.strictEqual((await mcp.handle({ jsonrpc: '2.0', id: 3, method: 'nope' })).error.code, -32601);++});++++t.test('models: live prices, speed and uptime, without a token', async () => {++ const before = fake.calls.length;++ const out = await call(4, 'exchange_models', { query: 'deepseek' });++ assert.ok(!out.result.isError, JSON.stringify(out));++ const data = JSON.parse(out.result.content[0].text);++ assert.strictEqual(data.models.length, 1);++ const offer = data.models[0].offers[0];++ assert.deepStrictEqual(offer.price, { in: 0.14, out: 0.42, cached: 0.014, per: '1M tokens' });++ assert.deepStrictEqual(offer.speed, { p50_ms: 320, p95_ms: 900 });++ assert.strictEqual(offer.uptime, 0.997);++ assert.strictEqual(fake.calls.length, before + 1);++ assert.strictEqual(fake.calls[before].headers.authorization, `Bearer ${TOKEN}`, 'the token rides along when there is one');++ const anonCatalog = await createExchangeMcp({ token: '', baseUrl: fake.url }).handle({ jsonrpc: '2.0', id: 41, method: 'tools/call', params: { name: 'exchange_models', arguments: {} } });++ assert.ok(!anonCatalog.result.isError, 'the public catalog answers without a token too');++});++++t.test('chat: the person\'s allowance, or their own stored provider key, with their token', async () => {++ const before = fake.calls.length;++ const out = await call(5, 'exchange_chat', { model: 'openvibe/auto', messages: [{ role: 'user', content: 'hi' }], max_tokens: 32, credential: 'deepseek' });++ assert.ok(!out.result.isError, JSON.stringify(out));++ const answer = JSON.parse(out.result.content[0].text);++ assert.strictEqual(answer.choices[0].message.content, 'Hello from the Exchange');++ assert.strictEqual(answer.x_openvibe.run_id, 'run_1');++ const sent = fake.calls[before];++ assert.strictEqual(sent.url, '/v1/chat/completions');++ assert.strictEqual(sent.method, 'POST');++ assert.strictEqual(sent.headers.authorization, `Bearer ${TOKEN}`);++ assert.strictEqual(sent.headers['x-openvibe-credential'], 'deepseek');++ assert.deepStrictEqual(sent.body, { model: 'openvibe/auto', messages: [{ role: 'user', content: 'hi' }], stream: false, max_tokens: 32 });++});++++t.test('usage: the person\'s own rows', async () => {++ const out = await call(6, 'exchange_usage', { days: 7 });++ assert.ok(!out.result.isError, JSON.stringify(out));++ const data = JSON.parse(out.result.content[0].text);++ assert.strictEqual(data.requester, `user:${PERSON}`);++ assert.strictEqual(data.usage[0].requests, 2);++ const sent = fake.calls[fake.calls.length - 1];++ assert.match(sent.url, /^\/api\/v1\/exchange\/me\/usage\?days=7$/);++ assert.strictEqual(sent.headers.authorization, `Bearer ${TOKEN}`);++});++++t.test('auth refusal: no token is refused before any request; a 401 is refused back', async () => {++ const anon = createExchangeMcp({ token: '', baseUrl: fake.url });++ const before = fake.calls.length;++ const noToken = await anon.handle({ jsonrpc: '2.0', id: 7, method: 'tools/call', params: { name: 'exchange_chat', arguments: { model: 'openvibe', messages: [{ role: 'user', content: 'hi' }] } } });++ assert.strictEqual(noToken.result.isError, true);++ assert.match(noToken.result.content[0].text, /not signed in/);++ assert.strictEqual(fake.calls.length, before, 'nothing was attempted');++++ const wrong = createExchangeMcp({ token: 'ovk_wrong', baseUrl: fake.url });++ const refused = await wrong.handle({ jsonrpc: '2.0', id: 8, method: 'tools/call', params: { name: 'exchange_chat', arguments: { model: 'openvibe', messages: [{ role: 'user', content: 'hi' }] } } });++ assert.strictEqual(refused.result.isError, true);++ assert.match(refused.result.content[0].text, /refused the token \(401\)/);++ assert.ok(!refused.result.content[0].text.includes('ovk_wrong'), 'the token is never echoed');++});++++t.test('an invalid tool argument is refused, not sent', async () => {++ const before = fake.calls.length;++ const out = await call(9, 'exchange_chat', { model: 'openvibe', messages: [] });++ assert.strictEqual(out.result.isError, true);++ assert.match(out.result.content[0].text, /messages/);++ assert.strictEqual(fake.calls.length, before);++});++++t.test('stop', async () => { await fake.close(); });++++t.run();nnew file mode 100644@@@ -0,0 +1,232 @@++'use strict';++// Exchange webhooks (the design's "For builders", item 7): a signed delivery, a retry with a backoff, a delivery log,++// a refused private receiver, and the three events wired into the run and the seller API. The signature is checked++// with openvibe-sdk/exchange's own verifier -- the header a receiver will use.++const assert = require('assert');++const { boot, request, token, suite, ALL } = require('./helpers');++const { testDb } = require('./db');++const { createWebhooks, judgeUrl } = require('../server/exchange/webhooks');++const { verifyWebhook } = require('openvibe-sdk/exchange');++++const t = suite('exchange-webhooks');++const OWNER = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A1';++const silent = { log() {}, warn() {}, error() {} };++const cfg = { webhooks: { enabled: true, baseDelayMs: 1000, maxDelayMs: 60000, maxAttempts: 3, timeoutMs: 1000, tickMs: 250 } };++++function recorder(responses = []) {++ const calls = [];++ const transport = async (send) => {++ calls.push(send);++ const next = responses.shift();++ if (next instanceof Error) throw next;++ return next || { status: 200, headers: {}, body: 'ok' };++ };++ return { calls, transport };++}++++let store;++let ms = Date.parse('2026-11-07T12:00:00Z');++const clock = { now: () => ms };++++t.test('a receiver URL goes through the egress guard, at registration', () => {++ const bad = [++ ['http://hooks.example.com/x', /https/],++ ['https://127.0.0.1/x', /IP address/],++ ['https://169.254.169.254/latest', /IP address/],++ ['https://[::1]/x', /IP address/],++ ['https://localhost/x', /public host name/],++ ['https://hooks.example.com:8080/x', /port 443 or 8443/],++ ['https://hooks.example.com/x?token=1', /query or fragment/],++ ];++ for (const [url, re] of bad) {++ assert.throws(() => judgeUrl(url), (err) => err.code === 'exchange.webhook_url' && re.test(err.detail), url);++ }++ assert.strictEqual(judgeUrl('https://hooks.example.com/exchange').toString(), 'https://hooks.example.com/exchange');++});++++t.test('a subscription: the secret is shown once, the list never carries it, removal is the owner\'s', async () => {++ const dbh = await testDb();++ store = createWebhooks({ db: dbh.db, config: cfg, clock, log: silent, transport: recorder().transport });++ const created = await store.create({ owner: OWNER, input: { url: 'https://hooks.example.com/exchange', events: ['usage'] } });++ assert.match(created.id, /^whk_/);++ assert.match(created.secret, /^whsec_/);++ assert.deepStrictEqual(created.events, ['usage']);++ const listed = await store.list(OWNER);++ assert.strictEqual(listed.length, 1);++ assert.ok(!('secret' in listed[0]), 'the list never carries the secret');++ assert.deepStrictEqual(await store.deliveries({ id: created.id, owner: OWNER }), []);++ await assert.rejects(() => store.remove({ id: created.id, owner: 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2B2' }), (err) => err.code === 'exchange.webhook_not_found');++ assert.deepStrictEqual(await store.remove({ id: created.id, owner: OWNER }), { id: created.id, removed: true });++ assert.deepStrictEqual(await store.list(OWNER), []);++ await dbh.close();++});++++t.test('signature round-trip with the SDK verifier, and a delivery log a developer can read', async () => {++ const dbh = await testDb();++ const rec = recorder();++ store = createWebhooks({ db: dbh.db, config: cfg, clock, log: silent, transport: rec.transport });++ const sub = await store.create({ owner: OWNER, input: { url: 'https://hooks.example.com/exchange', events: ['usage'] } });++ const queued = await store.emit({ owner: OWNER, type: 'usage', source: 'run_1', data: { run_id: 'run_1', tokens: { input: 3, output: 2 } } });++ assert.strictEqual(queued, 1);++ assert.strictEqual(rec.calls.length, 0, 'the event is a row: nothing is sent before the worker runs');++ const out = await store.deliverDue();++ assert.deepStrictEqual(out, { sent: 1, delivered: 1, failed: 0 });++ assert.strictEqual(rec.calls.length, 1);++ const sent = rec.calls[0];++ assert.strictEqual(sent.method, 'POST');++ assert.strictEqual(sent.url, 'https://hooks.example.com/exchange');++ assert.strictEqual(sent.headers['X-OpenVibe-Event'], 'usage');++ assert.match(sent.headers['X-OpenVibe-Signature-V2'], /^t=\d+,v2=[0-9a-f]{64}$/);++ assert.strictEqual(verifyWebhook(sent.body, sent.headers['X-OpenVibe-Signature-V2'], sub.secret, { now: ms }), true);++ assert.strictEqual(verifyWebhook(`${sent.body} `, sent.headers['X-OpenVibe-Signature-V2'], sub.secret, { now: ms }), false, 'the raw bytes are signed');++ assert.strictEqual(verifyWebhook(sent.body, sent.headers['X-OpenVibe-Signature-V2'], 'whsec_wrong', { now: ms }), false);++ const payload = JSON.parse(sent.body);++ assert.strictEqual(payload.type, 'usage');++ assert.strictEqual(payload.data.run_id, 'run_1');++ assert.ok(!sent.body.includes(sub.secret), 'the secret never rides in the body');++ const log = await store.deliveries({ id: sub.id, owner: OWNER });++ assert.strictEqual(log.length, 1);++ assert.strictEqual(log[0].status, 'delivered');++ assert.strictEqual(log[0].attempts, 1);++ assert.strictEqual(log[0].last_status, 200);++ assert.deepStrictEqual(log[0].payload, payload);++ assert.strictEqual((await store.deliverDue()).sent, 0, 'a delivered row is not sent again');++ await dbh.close();++});++++t.test('a 503 is retried with a growing backoff; a 404 fails at once', async () => {++ const dbh = await testDb();++ const rec = recorder([{ status: 503, headers: {}, body: 'busy' }, { status: 200, headers: {}, body: 'ok' }, { status: 404, headers: {}, body: 'gone' }]);++ store = createWebhooks({ db: dbh.db, config: { webhooks: { ...cfg.webhooks, maxAttempts: 5 } }, clock, log: silent, transport: rec.transport });++ const sub = await store.create({ owner: OWNER, input: { url: 'https://hooks.example.com/exchange' } });++ await store.emit({ owner: OWNER, type: 'usage', source: 'run_2', data: { run_id: 'run_2' } });++ assert.deepStrictEqual(await store.deliverDue(), { sent: 1, delivered: 0, failed: 0 });++ let log = (await store.deliveries({ id: sub.id, owner: OWNER }))[0];++ assert.strictEqual(log.status, 'pending');++ assert.strictEqual(log.attempts, 1);++ assert.strictEqual(log.last_status, 503);++ assert.strictEqual(Date.parse(log.next_attempt_at) - ms, 1000, 'the first retry waits baseDelayMs');++ assert.strictEqual((await store.deliverDue()).sent, 0, 'not due yet');++ ms += 1001;++ assert.deepStrictEqual(await store.deliverDue(), { sent: 1, delivered: 1, failed: 0 });++ log = (await store.deliveries({ id: sub.id, owner: OWNER }))[0];++ assert.strictEqual(log.attempts, 2);++ assert.strictEqual(log.status, 'delivered');++++ await store.emit({ owner: OWNER, type: 'earnings', source: 'run_3', data: { credits_micro: 5 } });++ assert.deepStrictEqual(await store.deliverDue(), { sent: 1, delivered: 0, failed: 1 });++ log = (await store.deliveries({ id: sub.id, owner: OWNER }))[0];++ assert.strictEqual(log.event_type, 'earnings');++ assert.strictEqual(log.status, 'failed');++ assert.strictEqual(log.attempts, 1, 'a 404 is not retried');++ await dbh.close();++});++++t.test('a community answer queues earnings to the seller and the project that registered the offer', async () => {++ const dbh = await testDb();++ const rec = recorder();++ const store2 = createWebhooks({ db: dbh.db, config: cfg, clock, log: silent, transport: rec.transport });++ const PROJECT = 'prj_01J8Z3Q4R5S6T7V8W9X0Y1Z2P1';++ await store2.create({ owner: OWNER, input: { url: 'https://hooks.example.com/owner' } });++ await store2.create({ owner: PROJECT, input: { url: 'https://hooks.example.com/project' } });++ const row = {++ id: 'run_community', requester_type: 'user', requester_id: OWNER,++ explain: JSON.stringify({ community: { kind: 'endpoint', machine_id: 'endpoint:ep_1', offer_id: 'endpoint:ep_1', project_id: PROJECT,++ owner: OWNER, model: 'qwen3:32b', credits_micro: 500, earning_status: 'pending', self_served: false, kudos: 0, tokens_in: 12, tokens_out: 8 } }),++ };++ const record = { subject: OWNER, provider: 'community-endpoint:ep_1', model: 'qwen3:32b', tokens_input: 12, tokens_output: 8,++ tokens_reasoning: 0, tokens_cache_read: 0, tokens_cache_write: 0, list_cost_usd: 0, charged_usd: 0, fee_usd: 0, cache_saved_usd: 0,++ paid_by: 'network_allowance', latency_ms: 10, ttft_ms: 2, at: '2026-11-07T12:00:00Z' };++ assert.strictEqual(await store2.usage({ row, record, outcome: 'succeeded' }), 3, 'usage to the owner, earnings to the owner and the project');++ await store2.deliverDue();++ const events = rec.calls.map((c) => ({ event: c.headers['X-OpenVibe-Event'], url: c.url, body: JSON.parse(c.body) }));++ assert.strictEqual(events.filter((e) => e.event === 'usage').length, 1);++ const earnings = events.filter((e) => e.event === 'earnings');++ assert.strictEqual(earnings.length, 2);++ assert.deepStrictEqual(earnings.map((e) => e.url).sort(), ['https://hooks.example.com/owner', 'https://hooks.example.com/project']);++ assert.strictEqual(earnings[0].body.data.offer_id, 'endpoint:ep_1');++ assert.strictEqual(earnings[0].body.data.credits_micro, 500);++ assert.strictEqual(earnings[0].body.data.earning_status, 'pending');++ await dbh.close();++});++++// ── The wiring: a run and an offer removal each queue their event ─────────────────────────────++let h;++let rec;++++t.test('boot with a webhook store on a socket the tests drive', async () => {++ rec = recorder();++ h = await boot({ webhooks: (db) => createWebhooks({ db, config: cfg, log: silent, transport: rec.transport }) });++});++++t.test('a completed run queues a signed usage event for the person', async () => {++ const person = token('dana', ALL, { sub: OWNER, actorType: 'user' });++ const created = await request(h.base, 'POST', '/api/v1/exchange/webhooks', { tok: person, body: { url: 'https://hooks.example.com/exchange', events: ['usage', 'offer.down'] } });++ assert.strictEqual(created.status, 201, created.text);++ const secret = created.body.webhook.secret;++ const chat = await request(h.base, 'POST', '/v1/chat/completions', { tok: person, body: { model: 'openvibe', messages: [{ role: 'user', content: 'say hi' }] } });++ assert.strictEqual(chat.status, 200, chat.text);++ const out = await h.webhooks.deliverDue();++ assert.ok(out.sent >= 1, 'the run queued a delivery');++ const call = rec.calls.find((c) => c.headers['X-OpenVibe-Event'] === 'usage');++ assert.ok(call, 'a usage delivery');++ assert.strictEqual(verifyWebhook(call.body, call.headers['X-OpenVibe-Signature-V2'], secret), true);++ const payload = JSON.parse(call.body);++ assert.strictEqual(payload.data.run_id, chat.headers.get('x-openvibe-run-id'));++ assert.strictEqual(payload.data.outcome, 'succeeded');++ assert.ok(payload.data.tokens.output >= 0);++ assert.ok(!payload.data.prompt && !payload.data.answer, 'never the prompt or the answer');++++ // The developer reads the same delivery, and the caller's own usage, with their token.++ const log = await request(h.base, 'GET', `/api/v1/exchange/webhooks/${created.body.webhook.id}/deliveries`, { tok: person });++ assert.strictEqual(log.status, 200, log.text);++ assert.strictEqual(log.body.deliveries[0].event_type, 'usage');++ assert.strictEqual(log.body.deliveries[0].status, 'delivered');++ assert.ok(!log.text.includes(secret), 'the delivery log never carries the secret');++ const mine = await request(h.base, 'GET', '/api/v1/exchange/me/usage?days=7', { tok: person });++ assert.strictEqual(mine.status, 200, mine.text);++ assert.strictEqual(mine.body.requester, `user:${OWNER}`);++ assert.ok(Array.isArray(mine.body.usage));++ const other = token('eli', ALL, { sub: 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2B2', actorType: 'user' });++ const notMine = await request(h.base, 'GET', `/api/v1/exchange/webhooks/${created.body.webhook.id}/deliveries`, { tok: other });++ assert.strictEqual(notMine.status, 404, notMine.text);++});++++t.test('removing an endpoint offer queues offer.down', async () => {++ const person = token('dana', ALL, { sub: OWNER, actorType: 'user' });++ const created = await request(h.base, 'POST', '/api/v1/exchange/seller/offers', { tok: person, body: {++ base_url: 'https://models.example/v1', models: ['qwen3:32b'],++ price: { in_per_million: 10, out_per_million: 30 }, hours: { start: '00:00', end: '23:59', tz: 'UTC' },++ concurrency: 2, audience: 'everyone',++ } });++ assert.strictEqual(created.status, 201, created.text);++ const removed = await request(h.base, 'DELETE', `/api/v1/exchange/seller/offers/${created.body.offer.id}`, { tok: person });++ assert.strictEqual(removed.status, 200, removed.text);++ await h.webhooks.deliverDue();++ const call = rec.calls.find((c) => c.headers['X-OpenVibe-Event'] === 'offer.down');++ assert.ok(call, 'an offer.down delivery');++ const payload = JSON.parse(call.body);++ assert.strictEqual(payload.type, 'offer.down');++ assert.strictEqual(payload.data.offer_id, `endpoint:${created.body.offer.id}`);++ assert.deepStrictEqual(payload.data.models, ['qwen3:32b']);++});++++t.test('a service token cannot register a webhook', async () => {++ const service = token('live', ['ai.run.create', 'ai.app.run']);++ const out = await request(h.base, 'POST', '/api/v1/exchange/webhooks', { tok: service, body: { url: 'https://hooks.example.com/x' } });++ assert.strictEqual(out.status, 403, out.text);++ assert.strictEqual(out.body.code, 'exchange.webhook_owner');++});++++t.test('the private receiver is refused by the API', async () => {++ const person = token('dana', ALL, { sub: OWNER, actorType: 'user' });++ for (const url of ['https://127.0.0.1/hook', 'https://localhost/hook', 'http://hooks.example.com/hook']) {++ const out = await request(h.base, 'POST', '/api/v1/exchange/webhooks', { tok: person, body: { url } });++ assert.strictEqual(out.status, 422, out.text);++ assert.strictEqual(out.body.code, 'exchange.webhook_url');++ }++});++++t.test('stop', async () => { await h.stop(); });++++t.run();@@@ -45,7 +45,7 @@ function tmpDir() {// A boot on a dir used before is a restart on the same database: its database stays open.const restartable = new Map();--async function boot({ env = {}, dir, clock, fetchImpl, credentialFetch, research, machineModels, log, variantEvidence, community, communityEndpoints } = {}) {++async function boot({ env = {}, dir, clock, fetchImpl, credentialFetch, research, machineModels, log, variantEvidence, community, communityEndpoints, webhooks } = {}) {const reuse = dir !== undefined;if (dir === undefined) dir = tmpDir();const fullEnv = {@@@ -61,7 +61,8 @@ async function boot({ env = {}, dir, clock, fetchImpl, credentialFetch, researchconst researcher = research || require('../server/research').createResearch({ config: config.research, env: {}, fetchImpl: require('./fixtures/research-fetch').createResearchFetch().fetchImpl, log: silent });// An injected endpoint store needs the migrated database, so a factory is called with it before the app boots.const endpointStore = typeof communityEndpoints === 'function' ? communityEndpoints(testdb.db) : communityEndpoints;-- const h = await start({ config, db: testdb.db, env: fullEnv, log: log || silent, clock, fetchImpl, credentialFetch, research: researcher, machineModels, variantEvidence, community, communityEndpoints: endpointStore });++ const webhookStore = typeof webhooks === 'function' ? webhooks(testdb.db) : webhooks;++ const h = await start({ config, db: testdb.db, env: fullEnv, log: log || silent, clock, fetchImpl, credentialFetch, research: researcher, machineModels, variantEvidence, community, communityEndpoints: endpointStore, webhooks: webhookStore });const base = `http://127.0.0.1:${h.server.address().port}`;return { ...h, base, dir, env: fullEnv, testdb, stop: async () => { await h.close(); if (!reuse) await testdb.close(); } };}
@@ -319,3 +319,15 @@ OV_MEDIA_URL=https://openvibe.media
@@ -319,3 +319,15 @@ OV_MEDIA_URL=https://openvibe.media
#AI_RESEARCH_DEEPSEEK_KEY_REF=env:DEEPSEEK_API_KEY
#AI_RESEARCH_DEEPSEEK_KEY_REF=env:DEEPSEEK_API_KEY
#AI_RESEARCH_DEEPSEEK_DAILY=1500
#AI_RESEARCH_DEEPSEEK_DAILY=1500
#AI_RESEARCH_TIMEOUT_MS=15000
#AI_RESEARCH_TIMEOUT_MS=15000
+
+# ── Exchange webhooks (server/exchange/webhooks.js): a developer registers an https receiver and AI signs
+# usage/earnings/offer.down with X-OpenVibe-Signature-V2, the header openvibe-sdk/exchange verifies. A delivery is
+# retried with an exponential backoff; the receiver URL goes through the egress guard. On by default.
+#AI_WEBHOOKS=1
+#AI_WEBHOOK_TIMEOUT_MS=10000
+#AI_WEBHOOK_MAX_BYTES=65536
+#AI_WEBHOOK_MAX_ATTEMPTS=6
+#AI_WEBHOOK_MAX_PER_OWNER=10
+#AI_WEBHOOK_BASE_DELAY_MS=10000
+#AI_WEBHOOK_MAX_DELAY_MS=3600000
+#AI_WEBHOOK_TICK_MS=1000
@@ -696,6 +696,63 @@ listing it was read from and the day it was read.
@@ -696,6 +696,63 @@ listing it was read from and the day it was read.
The tests never touch the network: the listings are recorded (`test/fixtures/pricebook/`, written by
The tests never touch the network: the listings are recorded (`test/fixtures/pricebook/`, written by
`scripts/record-price-book.js`) and replayed through the reader's transport seam.
`scripts/record-price-book.js`) and replayed through the reader's transport seam.
+## Webhooks and the MCP server
+
+The Exchange's developer tools (the plan of 2026-11-06, "For builders", item 7): `openvibe-sdk/exchange` has
+verified webhooks since 0.91.0; `server/exchange/webhooks.js` is the sender, and `server/mcp/exchange.js` the MCP
+server.
+
+### Webhooks
+
+A person's token or a developer app registers an https receiver on the Exchange API:
+
+| Route | What |
+|---|---|
+| `POST /api/v1/exchange/webhooks` | `{url, events?, description?}` → `201 {webhook}`; the signing `secret` (`whsec_…`) is in this answer only |
+| `GET /api/v1/exchange/webhooks` | the caller's receivers (never a secret) |
+| `DELETE /api/v1/exchange/webhooks/{id}` | remove one and its delivery history |
+| `GET /api/v1/exchange/webhooks/{id}/deliveries` | the delivery log: event, attempts, status, the receiver's answer and the payload |
+
+Three events: `usage` (an answered run: the tokens, the money and who paid), `earnings` (a community offer earned
+credits or kudos) and `offer.down` (an endpoint offer was removed). Every delivery carries
+`X-OpenVibe-Signature-V2: t=<unix seconds>, v2=<hex HMAC-SHA256 of "<t>.<raw body>">` — exactly what
+`require('openvibe-sdk/exchange').verifyWebhook(rawBody, header, secret)` checks — plus `X-OpenVibe-Event`,
+`X-OpenVibe-Delivery` and `X-OpenVibe-Webhook-Id`. A payload never carries a prompt or an answer.
+
+The receiver URL goes through the same egress guard as a community endpoint: https, a public host name, never an IP
+literal, port 443 or 8443, no query or fragment, and the DNS answer is judged inside the connection. A delivery is a
+row, not a call in the request that caused it: a 408/425/429/5xx or a network failure is retried with an exponential
+backoff (`AI_WEBHOOK_BASE_DELAY_MS`, up to `AI_WEBHOOK_MAX_DELAY_MS`, `AI_WEBHOOK_MAX_ATTEMPTS`); any other non-2xx
+fails it at once. The worker is off in test mode and in `NODE_ENV=test`.
+
+### The MCP server
+
+`bin/openvibe-exchange-mcp.js` speaks MCP over stdio in the repo that owns the Exchange API:
+
+```json
+{
+ "mcpServers": {
+ "openvibe-exchange": {
+ "command": "openvibe-exchange-mcp",
+ "env": { "OPENVIBE_EXCHANGE_TOKEN": "your Network developer key" }
+ }
+ }
+}
+```
+
+The tools are `exchange_models` (the live catalog: price in/out per million, measured p50/p95, uptime, health and the
+data policy), `exchange_chat` (one completion on the person's allowance, or their own stored provider key when
+`credential` names a provider) and `exchange_usage` (the person's own requests, tokens and spend by day and model,
+from `GET /api/v1/exchange/me/usage`). `exchange_models` is public; the other two refuse a call without a token
+before any request, and a 401 from the Exchange is refused back as the tool's error. The token is never printed,
+logged or stored.
+
+### The CLI
+
+`openvibe-node exchange serve --model qwen3:32b --public-url https://…/v1` (OpenVibe.Node) serves the local model
+through an OpenAI-compatible listener and registers it on the existing endpoint-seller API with the person's
+`OPENVIBE_DEVELOPER_KEY`; Ctrl-C removes the offer. See that repo's README.
+
## Workflows
## Workflows
- **Seed product workflows** — `wiki.generate_space`, `wiki.generate_page` (both on route `wiki.write`, see [MegaSource](#megasource-research)), `blog.draft_post`,
- **Seed product workflows** — `wiki.generate_space`, `wiki.generate_page` (both on route `wiki.write`, see [MegaSource](#megasource-research)), `blog.draft_post`,
@@ -55,7 +55,9 @@
@@ -55,7 +55,9 @@
"platform.usage-sample@1 readings to Billing with a per-subject provider free allowance; each run's reading state in explain and the free allowance on the staff console",
"platform.usage-sample@1 readings to Billing with a per-subject provider free allowance; each run's reading state in explain and the free allowance on the staff console",
"tier budgets (free, paid, staff) per subject and per project on openvibe-sdk/govern in Valkey, behind AI_GOVERN_TIERS (off by default); readings from govern's onUsage",
"tier budgets (free, paid, staff) per subject and per project on openvibe-sdk/govern in Valkey, behind AI_GOVERN_TIERS (off by default); readings from govern's onUsage",
"image generation (POST /api/v1/image, workflow ai.image, app op ai.image, route default.image over the OpenAI Images API), off unless AI_IMAGE_MODEL is set; metered per image (one platform.usage-sample@1 per attempt, resource output-images:<model>, unit image)",
"image generation (POST /api/v1/image, workflow ai.image, app op ai.image, route default.image over the OpenAI Images API), off unless AI_IMAGE_MODEL is set; metered per image (one platform.usage-sample@1 per attempt, resource output-images:<model>, unit image)",
- "the Exchange's routing presets (five built-ins plus one per account and per Network project, named '@preset/<name>' or as the request's preset, with floor, ceiling, max_price, quantization, region, data policy and community on or off), a hard max_price refused before the call with a problem document, the receipt of every answered request (offer, price, tokens, cache hits, latency, why the router chose it, the fallbacks it would have used, who paid and the privacy label) in the x-openvibe-receipt header and at GET /v1/receipts/{id}, BYOK at 0 % and the private/anonymized/community label on every answer"
+ "the Exchange's routing presets (five built-ins plus one per account and per Network project, named '@preset/<name>' or as the request's preset, with floor, ceiling, max_price, quantization, region, data policy and community on or off), a hard max_price refused before the call with a problem document, the receipt of every answered request (offer, price, tokens, cache hits, latency, why the router chose it, the fallbacks it would have used, who paid and the privacy label) in the x-openvibe-receipt header and at GET /v1/receipts/{id}, BYOK at 0 % and the private/anonymized/community label on every answer",
+ "Exchange webhooks (server/exchange/webhooks.js, migration 0038): usage, earnings and offer.down delivered with X-OpenVibe-Signature-V2 (<t>.<raw body>, the header openvibe-sdk/exchange verifies), retried with an exponential backoff, with a delivery log at GET /api/v1/exchange/webhooks/{id}/deliveries; a receiver URL goes through the egress guard (https, a public host, 443/8443), and the subscription secret is shown once",
+ "the Exchange MCP server (bin/openvibe-exchange-mcp.js): stdio MCP tools exchange_models, exchange_chat (the person's allowance or their own stored provider key) and exchange_usage (GET /api/v1/exchange/me/usage), authenticated with the person's Network token and refusing a call without one"
],
],
"todo": [
"todo": [
"owner: a provider key that serves the OpenAI Images API, then AI_IMAGE_MODEL (and AI_IMAGE_SIZE) in /etc/openvibe/ai.env — image generation stays off until then",
"owner: a provider key that serves the OpenAI Images API, then AI_IMAGE_MODEL (and AI_IMAGE_SIZE) in /etc/openvibe/ai.env — image generation stays off until then",
new file mode 100755
new file mode 100755
@@ -0,0 +1,43 @@
@@ -0,0 +1,43 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * OpenVibe.Exchange MCP server over stdio (design EXCHANGE-REMAKE-2026-11-06, "For builders", item 7).
+ *
+ * OPENVIBE_EXCHANGE_TOKEN=... npx openvibe-exchange-mcp
+ * OPENVIBE_EXCHANGE_URL=https://api.openvibe.exchange (a test or an alternate deployment)
+ *
+ * Add it to an MCP client's config as a stdio server with the token in the environment. The server writes one
+ * JSON-RPC message per line to stdout and nothing else; logs go to stderr. A tool call without a token is refused
+ * before any request, and a 401 from the Exchange is refused back as the tool's error.
+ */
+const readline = require('node:readline');
+const { createExchangeMcp } = require('../server/mcp/exchange');
+
+async function main() {
+ const mcp = createExchangeMcp({
+ token: process.env.OPENVIBE_EXCHANGE_TOKEN || process.env.OPENVIBE_DEVELOPER_KEY || '',
+ baseUrl: process.env.OPENVIBE_EXCHANGE_URL || 'https://api.openvibe.exchange',
+ });
+ const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
+ const write = (value) => { process.stdout.write(`${JSON.stringify(value)}\n`); };
+ for await (const line of rl) {
+ if (!line.trim()) continue;
+ let message;
+ try { message = JSON.parse(line); } catch {
+ write({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'parse error' } });
+ continue;
+ }
+ try {
+ const out = await mcp.handle(message);
+ if (out) write(out);
+ } catch (err) {
+ // A protocol-level failure never takes the server down; the client sees a JSON-RPC internal error.
+ write({ jsonrpc: '2.0', id: message && message.id !== undefined ? message.id : null, error: { code: -32603, message: String((err && err.message) || err).slice(0, 300) } });
+ }
+ }
+}
+
+main().catch((err) => {
+ process.stderr.write(`openvibe-exchange-mcp: ${(err && err.stack) || err}\n`);
+ process.exit(1);
+});
new file mode 100644
new file mode 100644
@@ -0,0 +1,37 @@
@@ -0,0 +1,37 @@
+-- phase: expand
+-- Exchange webhooks (design EXCHANGE-REMAKE-2026-11-06, "For builders", item 7). A developer registers an https
+-- receiver; AI signs every delivery with X-OpenVibe-Signature-V2 (t=<unix>, v2=hex HMAC-SHA256 of "<t>.<raw body>"),
+-- exactly the header openvibe-sdk/exchange's verifyWebhook checks. usage, earnings and offer.down are the three
+-- events. A delivery is a row, retried with a backoff, so a receiver that is down loses nothing while the log keeps
+-- what happened. The secret is a per-subscription signing key (never a person's token), and the receiver URL is
+-- judged by the same egress guard the community endpoint flow uses.
+CREATE TABLE IF NOT EXISTS exchange_webhook_endpoints (
+ id text COLLATE "C" PRIMARY KEY, -- whk_<ULID>
+ owner_subject text COLLATE "C" NOT NULL CHECK (owner_subject ~ '^(usr|prj)_[0-9A-HJKMNP-TV-Z]{26}$'),
+ url text COLLATE "C" NOT NULL, -- https, a public host name, no query or fragment
+ secret text COLLATE "C" NOT NULL, -- whsec_…: signs this subscription's deliveries, shown once
+ events text[] COLLATE "C" NOT NULL CHECK (events <@ ARRAY['usage', 'earnings', 'offer.down']::text[]),
+ description text COLLATE "C",
+ created_at timestamptz NOT NULL,
+ updated_at timestamptz NOT NULL
+);
+CREATE INDEX IF NOT EXISTS exchange_webhook_endpoints_owner ON exchange_webhook_endpoints (owner_subject, created_at DESC);
+
+CREATE TABLE IF NOT EXISTS exchange_webhook_deliveries (
+ id text COLLATE "C" PRIMARY KEY, -- whd_<ULID>
+ endpoint_id text COLLATE "C" NOT NULL REFERENCES exchange_webhook_endpoints (id) ON DELETE CASCADE,
+ owner_subject text COLLATE "C" NOT NULL,
+ event_id text COLLATE "C" NOT NULL, -- evt_…, stable for the same source event
+ event_type text COLLATE "C" NOT NULL CHECK (event_type IN ('usage', 'earnings', 'offer.down')),
+ payload jsonb NOT NULL, -- the exact bytes that are signed and sent
+ status text COLLATE "C" NOT NULL CHECK (status IN ('pending', 'delivered', 'failed')),
+ attempts integer NOT NULL DEFAULT 0 CHECK (attempts >= 0),
+ next_attempt_at timestamptz,
+ last_attempt_at timestamptz,
+ delivered_at timestamptz,
+ last_status integer,
+ last_error text COLLATE "C",
+ created_at timestamptz NOT NULL
+);
+CREATE INDEX IF NOT EXISTS exchange_webhook_deliveries_endpoint ON exchange_webhook_deliveries (endpoint_id, created_at DESC);
+CREATE INDEX IF NOT EXISTS exchange_webhook_deliveries_due ON exchange_webhook_deliveries (next_attempt_at) WHERE status = 'pending';
@@ -4,9 +4,13 @@
@@ -4,9 +4,13 @@
"private": true,
"private": true,
"description": "OpenVibe.AI: providers, models, routing profiles, versioned prompt templates and workflows, runs, request logs, citations, scoped cache, quotas and audit for every OpenVibe product. Extracted from OpenVibe.Live's AI subsystem.",
"description": "OpenVibe.AI: providers, models, routing profiles, versioned prompt templates and workflows, runs, request logs, citations, scoped cache, quotas and audit for every OpenVibe product. Extracted from OpenVibe.Live's AI subsystem.",
"main": "server/index.js",
"main": "server/index.js",
+ "bin": {
+ "openvibe-exchange-mcp": "bin/openvibe-exchange-mcp.js"
+ },
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
+ "mcp:exchange": "node bin/openvibe-exchange-mcp.js",
"test": "node test/run.js",
"test": "node test/run.js",
"test:pg": "AI_TEST_STORE=pg node test/run.js"
"test:pg": "AI_TEST_STORE=pg node test/run.js"
},
},
@@ -35,7 +35,7 @@ function memo(fn, ttlMs, clock) {
@@ -35,7 +35,7 @@ function memo(fn, ttlMs, clock) {
};
};
}
}
-function exchangeRouter({ db, registry, pool, env = process.env, clock = { now: () => Date.now() }, config = null, presets, receipts, auth, log = console }) {
+function exchangeRouter({ db, registry, pool, quotas = null, env = process.env, clock = { now: () => Date.now() }, config = null, presets, receipts, auth, log = console }) {
const catalog = createCatalog({ db, registry, pool, env, clock, config });
const catalog = createCatalog({ db, registry, pool, env, clock, config });
const usage = createUsage({ db, clock });
const usage = createUsage({ db, clock });
// The price book's log: what a price was, what it became and when it was read.
// The price book's log: what a price was, what it became and when it was read.
@@ -218,6 +218,25 @@ function exchangeRouter({ db, registry, pool, env = process.env, clock = { now:
@@ -218,6 +218,25 @@ function exchangeRouter({ db, registry, pool, env = process.env, clock = { now:
} catch (err) { return next(err); }
} catch (err) { return next(err); }
});
});
+ // The caller's own usage (the MCP server and the Exchange's developer page read it): a person sees their
+ // subject, a developer app its project. The public aggregate above is never per person; this one is never
+ // anyone else's.
+ r.get('/api/v1/exchange/me/usage', read, wrap(async (req) => {
+ const p = req.principal || {};
+ if (!p.kind) throw new AiError(403, 'capability.denied', 'a person or a developer app reads its own usage');
+ if (!quotas) throw new AiError(503, 'exchange.unavailable', 'usage is not configured here');
+ const who = p.kind === 'app' ? `project:${p.projectId}` : `user:${p.subject && p.subject.id}`;
+ const day = (v, field) => {
+ if (v == null || v === '') return undefined;
+ const s = String(v).slice(0, 10);
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) throw new AiError(422, 'ai.invalid', `${field} must be YYYY-MM-DD`);
+ return s;
+ };
+ const from = day(req.query.from, 'from');
+ const to = day(req.query.to, 'to');
+ return { requester: who, usage: await quotas.usage({ from, to, requester: who }) };
+ }));
+
return r;
return r;
}
}
@@ -19,7 +19,7 @@ const express = require('express');
@@ -19,7 +19,7 @@ const express = require('express');
const { CAPS } = require('../auth');
const { CAPS } = require('../auth');
const { sendError } = require('../util');
const { sendError } = require('../util');
-function sellerRouter({ endpoints, community = null, auth, log = console }) {
+function sellerRouter({ endpoints, community = null, webhooks = null, auth, log = console }) {
const r = express.Router();
const r = express.Router();
const allow = auth.appOrCap(CAPS.appRun, CAPS.runCreate);
const allow = auth.appOrCap(CAPS.appRun, CAPS.runCreate);
const body = (req) => (req.body && typeof req.body === 'object' && !Array.isArray(req.body) ? req.body : {});
const body = (req) => (req.body && typeof req.body === 'object' && !Array.isArray(req.body) ? req.body : {});
@@ -60,7 +60,16 @@ function sellerRouter({ endpoints, community = null, auth, log = console }) {
@@ -60,7 +60,16 @@ function sellerRouter({ endpoints, community = null, auth, log = console }) {
r.delete('/api/v1/exchange/seller/offers/:id', allow, wrap(async (req, res) => {
r.delete('/api/v1/exchange/seller/offers/:id', allow, wrap(async (req, res) => {
const who = await endpoints.ownerFor(req.principal);
const who = await endpoints.ownerFor(req.principal);
res.set('Cache-Control', 'no-store');
res.set('Cache-Control', 'no-store');
- return await endpoints.remove({ id: req.params.id, owner: who.owner });
+ const mine = await endpoints.list(who.owner);
+ const offer = mine.find((o) => o.id === req.params.id) || { id: req.params.id };
+ const removed = await endpoints.remove({ id: req.params.id, owner: who.owner });
+ // The offer.down webhook: its owner (and the developer project that registered it) hears once, after the
+ // offer is gone. A notification never turns a successful removal into an error.
+ if (webhooks && typeof webhooks.offerDown === 'function') {
+ try { await webhooks.offerDown({ owner: who.owner, projectId: who.projectId, offer }); }
+ catch (err) { log.warn(`[ai] offer.down webhook for ${req.params.id}: ${err.message}`); }
+ }
+ return removed;
}));
}));
return r;
return r;
new file mode 100644
new file mode 100644
@@ -0,0 +1,59 @@
@@ -0,0 +1,59 @@
+'use strict';
+/**
+ * Exchange webhooks, the developer's side (design EXCHANGE-REMAKE-2026-11-06, "For builders", item 7).
+ *
+ * POST /api/v1/exchange/webhooks register an https receiver; the signing secret is in this answer only
+ * GET /api/v1/exchange/webhooks the caller's receivers (never a secret)
+ * DELETE /api/v1/exchange/webhooks/:id remove one (its delivery history goes with it)
+ * GET /api/v1/exchange/webhooks/:id/deliveries the delivery log: event, attempts, status and the payload sent
+ *
+ * The owner is the caller: a person's own subject, or an app's project. A first-party service token is refused. The
+ * secret is a signing key for this subscription, never a person's token, and it is never logged or read back.
+ */
+const express = require('express');
+const { CAPS } = require('../auth');
+const { AiError, sendError } = require('../util');
+
+function webhooksRouter({ webhooks, auth, log = console }) {
+ const r = express.Router();
+ const allow = auth.appOrCap(CAPS.appRun, CAPS.runCreate, CAPS.runRead);
+ const body = (req) => (req.body && typeof req.body === 'object' && !Array.isArray(req.body) ? req.body : {});
+ const wrap = (fn) => async (req, res) => {
+ try {
+ const out = await fn(req, res);
+ if (out !== undefined) res.json(out);
+ } catch (err) { sendError(res, err, req.ov, log); }
+ };
+ const ownerOf = (req) => {
+ const p = req.principal || {};
+ if (p.kind === 'user' && p.subject && p.subject.id) return p.subject.id;
+ if (p.kind === 'app' && p.projectId) return p.projectId;
+ throw new AiError(403, 'exchange.webhook_owner', 'a webhook belongs to a person or a developer app, not a service');
+ };
+
+ r.post('/api/v1/exchange/webhooks', allow, wrap(async (req, res) => {
+ const webhook = await webhooks.create({ owner: ownerOf(req), input: body(req) });
+ res.status(201).set('Cache-Control', 'no-store');
+ return { webhook };
+ }));
+
+ r.get('/api/v1/exchange/webhooks', allow, wrap(async (req, res) => {
+ res.set('Cache-Control', 'no-store');
+ return { owner: ownerOf(req), webhooks: await webhooks.list(ownerOf(req)) };
+ }));
+
+ r.delete('/api/v1/exchange/webhooks/:id', allow, wrap(async (req, res) => {
+ res.set('Cache-Control', 'no-store');
+ return await webhooks.remove({ id: req.params.id, owner: ownerOf(req) });
+ }));
+
+ r.get('/api/v1/exchange/webhooks/:id/deliveries', allow, wrap(async (req, res) => {
+ res.set('Cache-Control', 'no-store');
+ const deliveries = await webhooks.deliveries({ id: req.params.id, owner: ownerOf(req), limit: req.query.limit });
+ return { id: req.params.id, deliveries };
+ }));
+
+ return r;
+}
+
+module.exports = { webhooksRouter };
@@ -24,7 +24,7 @@ const pkg = require('../package.json');
@@ -24,7 +24,7 @@ const pkg = require('../package.json');
const { adminRoutes } = require('openvibe-shared/config');
const { adminRoutes } = require('openvibe-shared/config');
-function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, preferences = null, grants = null, vault = null, settings = null, benchEvents = null, runBudgets = null, memory = null, memoryImport = null, community = null, endpoints = null, presets = null, receipts = null, balance = null, gpus = null, abuse = null, priceBook = null }) {
+function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, preferences = null, grants = null, vault = null, settings = null, benchEvents = null, runBudgets = null, memory = null, memoryImport = null, community = null, endpoints = null, webhooks = null, presets = null, receipts = null, balance = null, gpus = null, abuse = null, priceBook = null }) {
const app = express();
const app = express();
// /.well-known/agent.json (openvibe-shared/agent-layer, SEO-2): what this site serves, for agents.
// /.well-known/agent.json (openvibe-shared/agent-layer, SEO-2): what this site serves, for agents.
require('openvibe-shared/agent-layer').mount(app, { descriptor: require('./agent-descriptor') });
require('openvibe-shared/agent-layer').mount(app, { descriptor: require('./agent-descriptor') });
@@ -116,10 +116,13 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
@@ -116,10 +116,13 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
openapi.mount(app);
openapi.mount(app);
// OpenVibe.Exchange's read API (server/api/exchange.js): the public catalog and aggregate usage. Public on
// OpenVibe.Exchange's read API (server/api/exchange.js): the public catalog and aggregate usage. Public on
// purpose — the site and anyone comparing prices reads the same numbers the router ranks on.
// purpose — the site and anyone comparing prices reads the same numbers the router ranks on.
- app.use(require('./api/exchange').exchangeRouter({ config, db, registry, pool, env, clock, presets, receipts, auth, log }));
+ app.use(require('./api/exchange').exchangeRouter({ config, db, registry, pool, quotas, env, clock, presets, receipts, auth, log }));
// The public seller API: a standalone OpenAI-compatible server registered by its own https address with the
// The public seller API: a standalone OpenAI-compatible server registered by its own https address with the
// seller's developer key (the design's SDK gap). Its offers join the community router's trust and ledger.
// seller's developer key (the design's SDK gap). Its offers join the community router's trust and ledger.
- if (endpoints) app.use(require('./api/seller').sellerRouter({ endpoints, community, auth, log }));
+ if (endpoints) app.use(require('./api/seller').sellerRouter({ endpoints, community, webhooks, auth, log }));
+ // Exchange webhooks (the plan's "For builders", item 7): a developer registers an https receiver and reads its
+ // delivery log. The signature is the one openvibe-sdk/exchange verifies; the receiver URL goes through the guard.
+ if (webhooks) app.use(require('./api/webhooks').webhooksRouter({ webhooks, auth, log }));
// GET /release.json (ADR-016) and POST /release-metrics (open tabs' update reports into /metrics).
// GET /release.json (ADR-016) and POST /release-metrics (open tabs' update reports into /metrics).
release.mount(app, { registry: metrics.registry });
release.mount(app, { registry: metrics.registry });
@@ -585,7 +585,9 @@ function createCommunity({ db, config, env = process.env, machines = null, endpo
@@ -585,7 +585,9 @@ function createCommunity({ db, config, env = process.env, machines = null, endpo
const facts = {
const facts = {
resolved_model: `community/${chosen.model}`, revision: null, deployment: source,
resolved_model: `community/${chosen.model}`, revision: null, deployment: source,
provider_access: isEndpoint ? 'COMMUNITY_ENDPOINT' : 'COMMUNITY', rate_version: 'community', policy: 'community',
provider_access: isEndpoint ? 'COMMUNITY_ENDPOINT' : 'COMMUNITY', rate_version: 'community', policy: 'community',
- community: { label: pools.label(chosen.offer.pool), ...(chosen.offer.pool ? { pool: chosen.offer.pool } : {}), kind: chosen.offer.pool ? 'pool' : isEndpoint ? 'endpoint' : 'machine', machine_id: chosen.offer.machine_id, owner: chosen.offer.owner, model: chosen.model, price: chosen.offer.price,
+ community: { label: pools.label(chosen.offer.pool), ...(chosen.offer.pool ? { pool: chosen.offer.pool } : {}), kind: chosen.offer.pool ? 'pool' : isEndpoint ? 'endpoint' : 'machine',
+ machine_id: chosen.offer.machine_id, offer_id: chosen.offer.offer_id, project_id: chosen.offer.projectId || null,
+ owner: chosen.offer.owner, model: chosen.model, price: chosen.offer.price,
verification: chosen.status, reputation: chosen.reputation, free_tier: freeTier(ctx), self_served: selfServed },
verification: chosen.status, reputation: chosen.reputation, free_tier: freeTier(ctx), self_served: selfServed },
};
};
if (typeof ctx.onRoute === 'function') {
if (typeof ctx.onRoute === 'function') {
@@ -463,6 +463,22 @@ function load(env = process.env) {
@@ -463,6 +463,22 @@ function load(env = process.env) {
},
},
},
},
+ // ── Exchange webhooks (the plan's "For builders", item 7): a developer registers an https receiver and AI
+ // signs usage, earnings and offer.down deliveries with the header openvibe-sdk/exchange verifies. A delivery
+ // is a row retried with a backoff, so a receiver that is down loses nothing. The receiver URL is judged by
+ // the same egress guard as a community endpoint; a private address is refused at registration.
+ webhooks: {
+ enabled: bool(env.AI_WEBHOOKS, true),
+ timeoutMs: Math.max(1000, Math.min(120000, int(env.AI_WEBHOOK_TIMEOUT_MS, 10000))),
+ maxBytes: Math.max(4096, Math.min(1024 * 1024, int(env.AI_WEBHOOK_MAX_BYTES, 65536))),
+ maxAttempts: Math.max(1, Math.min(12, int(env.AI_WEBHOOK_MAX_ATTEMPTS, 6))),
+ maxPerOwner: Math.max(1, Math.min(50, int(env.AI_WEBHOOK_MAX_PER_OWNER, 10))),
+ // The first retry waits this long; each further attempt doubles it, up to maxDelayMs.
+ baseDelayMs: Math.max(100, Math.min(3600000, int(env.AI_WEBHOOK_BASE_DELAY_MS, 10000))),
+ maxDelayMs: Math.max(1000, Math.min(86400000, int(env.AI_WEBHOOK_MAX_DELAY_MS, 3600000))),
+ tickMs: Math.max(250, Math.min(60000, int(env.AI_WEBHOOK_TICK_MS, 1000))),
+ },
+
// ── Tier budgets (T6 step 8, server/govern.js): openvibe-sdk/govern in Valkey, off unless AI_GOVERN_TIERS=1.
// ── Tier budgets (T6 step 8, server/govern.js): openvibe-sdk/govern in Valkey, off unless AI_GOVERN_TIERS=1.
govern: loadGovern(env, { bool, int, num: float }),
govern: loadGovern(env, { bool, int, num: float }),
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,392 @@
@@ -0,0 +1,392 @@
+'use strict';
+/**
+ * Exchange webhooks (design EXCHANGE-REMAKE-2026-11-06, "For builders", item 7).
+ *
+ * A developer registers an https receiver with their Network developer key (or a person's own token) on the public
+ * Exchange API. AI then sends three events: usage (an answered request), earnings (a community offer earned credits
+ * or kudos) and offer.down (an endpoint offer was removed). Every delivery carries
+ * X-OpenVibe-Signature-V2: t=<unix seconds>, v2=<hex HMAC-SHA256 of "<t>.<raw body>"> -- exactly the header
+ * openvibe-sdk/exchange's verifyWebhook checks, in constant time, with a 300-second timestamp window. The event id,
+ * the type and the delivery id ride beside it so a receiver can deduplicate and log.
+ *
+ * A delivery is a row, not a call in the request that caused it: the run/offer write commits first, the receiver is
+ * called after, and a failure is retried with an exponential backoff. The receiver sees the delivery log
+ * (GET /api/v1/exchange/webhooks/:id/deliveries), with every attempt, status and error.
+ *
+ * The receiver URL is judged by the egress guard the community endpoint flow uses: https only, a public DNS answer
+ * judged inside the connection (no rebinding), never an IP literal or a private name, port 443 or 8443, no query or
+ * fragment. A redirect is never followed: a webhook receiver must answer where it was registered.
+ */
+const crypto = require('node:crypto');
+const net = require('node:net');
+const https = require('node:https');
+const egress = require('openvibe-shared/egress');
+const { ids } = require('openvibe-contracts');
+const { AiError, parseJson, sha256 } = require('../util');
+
+const EVENTS = Object.freeze(['usage', 'earnings', 'offer.down']);
+const EVENT_SET = new Set(EVENTS);
+const OWNER_RE = /^(?:usr|prj)_[0-9A-HJKMNP-TV-Z]{26}$/;
+const ENDPOINT_ID = /^whk_[0-9A-HJKMNP-TV-Z]{26}$/;
+const MAX_URL = 2048;
+const ALLOWED_PORTS = new Set(['', '443', '8443']);
+
+const iso = (ms) => new Date(ms).toISOString();
+const nowSec = (ms) => Math.floor(ms / 1000);
+const cut = (s, n) => String(s == null ? '' : s).slice(0, n);
+
+/**
+ * The one URL rule for a receiver. Returns a URL, or throws AiError 422 `exchange.webhook_url`; the message never
+ * echoes the host. The DNS answer is judged again inside every connection (createSafeLookup), so a name that later
+ * resolves private is refused at delivery too.
+ */
+function judgeUrl(raw) {
+ let u;
+ try { u = new URL(String(raw == null ? '' : raw)); } catch { throw new AiError(422, 'exchange.webhook_url', 'url must be an https URL'); }
+ if (u.protocol !== 'https:') throw new AiError(422, 'exchange.webhook_url', 'url must be https');
+ if (u.username || u.password) throw new AiError(422, 'exchange.webhook_url', 'credentials in url are not allowed');
+ if (u.search || u.hash) throw new AiError(422, 'exchange.webhook_url', 'url carries no query or fragment');
+ if (!ALLOWED_PORTS.has(u.port)) throw new AiError(422, 'exchange.webhook_url', 'url must use port 443 or 8443');
+ const host = egress.normalizeHost(u.hostname);
+ // Node never calls the lookup hook for an IP literal, so the literal is the check.
+ if (net.isIP(host)) throw new AiError(422, 'exchange.webhook_url', 'url must name a host, not an IP address');
+ if (egress.isInternalName(host)) throw new AiError(422, 'exchange.webhook_url', 'url must be a public host name');
+ if (String(raw).length > MAX_URL) throw new AiError(422, 'exchange.webhook_url', `url is at most ${MAX_URL} characters`);
+ u.hostname = host;
+ return u;
+}
+
+/** A subscription as its owner sees it; the secret only on the create answer. */
+function viewOf(row, { secret = false } = {}) {
+ if (!row) return null;
+ const out = {
+ id: row.id, url: row.url, events: Array.isArray(row.events) ? row.events : parseJson(row.events, []),
+ description: row.description || null, created_at: row.created_at, updated_at: row.updated_at,
+ };
+ if (secret) out.secret = row.secret;
+ return out;
+}
+
+function deliveryView(row) {
+ return {
+ id: row.id, event_id: row.event_id, event_type: row.event_type, status: row.status,
+ attempts: Number(row.attempts) || 0, last_status: row.last_status == null ? null : Number(row.last_status),
+ last_error: row.last_error || null, last_attempt_at: row.last_attempt_at || null,
+ next_attempt_at: row.next_attempt_at || null, delivered_at: row.delivered_at || null,
+ created_at: row.created_at, payload: row.payload && typeof row.payload === 'object' ? row.payload : parseJson(row.payload, null),
+ };
+}
+
+/** The retry pause after attempt n (1-based): base, 2x, 4x … up to maxDelayMs. */
+function delayAfter(attempt, { baseDelayMs, maxDelayMs }) {
+ const raw = baseDelayMs * Math.pow(2, Math.max(0, attempt - 1));
+ return Math.max(baseDelayMs, Math.min(maxDelayMs, Math.round(raw)));
+}
+
+function createWebhooks({ db, config = null, clock = { now: () => Date.now() }, log = console, transport = null, lookup = null }) {
+ const cfg = (config && config.webhooks) || {};
+ const enabled = Boolean(db) && cfg.enabled !== false;
+ const timeoutMs = Math.max(1000, Math.min(120000, Number(cfg.timeoutMs) || 10000));
+ const maxBytes = Math.max(4096, Math.min(1024 * 1024, Number(cfg.maxBytes) || 65536));
+ const maxAttempts = Math.max(1, Math.min(12, Number(cfg.maxAttempts) || 6));
+ const maxPerOwner = Math.max(1, Math.min(50, Number(cfg.maxPerOwner) || 10));
+ const backoff = {
+ baseDelayMs: Math.max(100, Number(cfg.baseDelayMs) || 10000),
+ maxDelayMs: Math.max(1000, Number(cfg.maxDelayMs) || 3600000),
+ };
+ const tickMs = Math.max(250, Number(cfg.tickMs) || 1000);
+ const safeLookup = egress.createSafeLookup({ ...(lookup ? { lookup } : {}) });
+
+ let timer = null;
+ let working = false;
+
+ const endpointRows = () => db.prepare('SELECT * FROM exchange_webhook_endpoints ORDER BY created_at DESC');
+
+ async function list(owner) {
+ if (!OWNER_RE.test(String(owner))) throw new AiError(422, 'exchange.webhook_owner', 'owner must be a usr_ or prj_ subject');
+ return (await endpointRows().all()).filter((r) => r.owner_subject === owner).map((r) => viewOf(r));
+ }
+
+ async function create({ owner, input }) {
+ if (!OWNER_RE.test(String(owner))) throw new AiError(422, 'exchange.webhook_owner', 'owner must be a usr_ or prj_ subject');
+ const b = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
+ for (const k of Object.keys(b)) {
+ if (!['url', 'events', 'description'].includes(k)) throw new AiError(422, 'exchange.webhook_invalid', `unknown field ${k}`);
+ }
+ const url = judgeUrl(b.url).toString();
+ let events = EVENTS.slice();
+ if (b.events != null) {
+ if (!Array.isArray(b.events) || !b.events.length) throw new AiError(422, 'exchange.webhook_invalid', 'events must be a non-empty array');
+ events = [...new Set(b.events.map((e) => String(e)))];
+ if (events.some((e) => !EVENT_SET.has(e))) throw new AiError(422, 'exchange.webhook_invalid', `events must be among ${EVENTS.join(', ')}`);
+ }
+ const description = b.description == null ? null : cut(String(b.description).trim(), 200) || null;
+ const existing = (await endpointRows().all()).filter((r) => r.owner_subject === owner);
+ if (existing.length >= maxPerOwner) throw new AiError(409, 'exchange.webhook_limit', `at most ${maxPerOwner} webhook receivers per account`);
+ const at = iso(clock.now());
+ const row = {
+ id: `whk_${ids.ulid()}`, owner, url, events, description,
+ secret: `whsec_${crypto.randomBytes(32).toString('base64url')}`, created_at: at, updated_at: at,
+ };
+ await db.prepare(`INSERT INTO exchange_webhook_endpoints (id, owner_subject, url, secret, events, description, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
+ .run(row.id, row.owner, row.url, row.secret, row.events, row.description, row.at || at, at);
+ // The secret is shown once, in this answer: a later read never carries it.
+ return viewOf({ ...row, created_at: at, updated_at: at }, { secret: true });
+ }
+
+ async function remove({ id, owner }) {
+ if (!ENDPOINT_ID.test(String(id)) || !OWNER_RE.test(String(owner))) {
+ throw new AiError(404, 'exchange.webhook_not_found', `no webhook ${id}`);
+ }
+ const row = await db.prepare('SELECT id FROM exchange_webhook_endpoints WHERE id = ? AND owner_subject = ?').get(String(id), String(owner));
+ if (!row) throw new AiError(404, 'exchange.webhook_not_found', `no webhook ${id}`);
+ await db.prepare('DELETE FROM exchange_webhook_endpoints WHERE id = ?').run(String(id));
+ return { id: String(id), removed: true };
+ }
+
+ /** The delivery log of one receiver, newest first. The payload is what was sent; no secret is ever here. */
+ async function deliveries({ id, owner, limit = 50 } = {}) {
+ if (!ENDPOINT_ID.test(String(id)) || !OWNER_RE.test(String(owner))) {
+ throw new AiError(404, 'exchange.webhook_not_found', `no webhook ${id}`);
+ }
+ const row = await db.prepare('SELECT id FROM exchange_webhook_endpoints WHERE id = ? AND owner_subject = ?').get(String(id), String(owner));
+ if (!row) throw new AiError(404, 'exchange.webhook_not_found', `no webhook ${id}`);
+ const n = Math.max(1, Math.min(200, Number(limit) || 50));
+ const rows = await db.prepare('SELECT * FROM exchange_webhook_deliveries WHERE endpoint_id = ? ORDER BY created_at DESC LIMIT ?').all(String(id), n);
+ return rows.map(deliveryView);
+ }
+
+ /** One response, without following anything: the transport seam is the tests'. */
+ function once(target, { body, headers }) {
+ const send = { method: 'POST', url: target.toString(), headers, body, lookup: safeLookup, timeoutMs, maxBytes };
+ if (transport) return transport(send);
+ return new Promise((resolve, reject) => {
+ let settled = false;
+ const fail = (err) => { if (settled) return; settled = true; try { req.destroy(); } catch { /* */ } reject(err); };
+ const ok = (value) => { if (settled) return; settled = true; resolve(value); };
+ const req = https.request(target, { method: 'POST', headers, lookup: safeLookup, timeout: timeoutMs, agent: false }, (res) => {
+ const status = res.statusCode || 0;
+ if (status >= 300 && status < 400) { res.resume(); return ok({ status, headers: res.headers, body: '' }); }
+ const declared = Number(res.headers['content-length']);
+ if (Number.isFinite(declared) && declared > maxBytes) { res.resume(); return fail(new AiError(413, 'exchange.webhook_too_large', `the receiver answered more than ${maxBytes} bytes`)); }
+ let bytes = 0;
+ const chunks = [];
+ res.on('data', (c) => {
+ bytes += c.length;
+ if (bytes > maxBytes) { res.destroy(); return fail(new AiError(413, 'exchange.webhook_too_large', `the receiver answered more than ${maxBytes} bytes`)); }
+ chunks.push(c);
+ });
+ res.on('error', (err) => fail(err));
+ res.on('end', () => ok({ status, headers: res.headers, body: Buffer.concat(chunks).toString('utf8') }));
+ });
+ req.on('error', (err) => fail(err));
+ req.on('timeout', () => fail(new AiError(504, 'exchange.webhook_timeout', `the receiver did not answer within ${timeoutMs}ms`)));
+ const guard = setTimeout(() => fail(new AiError(504, 'exchange.webhook_timeout', `the receiver did not answer within ${timeoutMs}ms`)), timeoutMs);
+ req.on('close', () => clearTimeout(guard));
+ req.end(body);
+ });
+ }
+
+ /** A signed delivery of one row, retried or closed. Never throws into the caller. */
+ async function attempt(row) {
+ const at = iso(clock.now());
+ const attemptNo = (Number(row.attempts) || 0) + 1;
+ let outcome = { status: null, error: null };
+ let headers = null;
+ try {
+ const target = judgeUrl(row.url);
+ // jsonb hands back a parsed object in PostgreSQL; sign the exact string this delivery sends.
+ const payload = Buffer.isBuffer(row.payload) ? row.payload.toString('utf8')
+ : (typeof row.payload === 'string' ? row.payload : JSON.stringify(row.payload));
+ const t = nowSec(clock.now());
+ const signature = crypto.createHmac('sha256', String(row.secret)).update(`${t}.`).update(payload).digest('hex');
+ headers = {
+ 'Content-Type': 'application/json',
+ 'User-Agent': 'OpenVibe.Exchange/1.0 (+https://openvibe.exchange)',
+ 'X-OpenVibe-Signature-V2': `t=${t},v2=${signature}`,
+ 'X-OpenVibe-Event': String(row.event_type),
+ 'X-OpenVibe-Delivery': String(row.id),
+ 'X-OpenVibe-Webhook-Id': String(row.endpoint_id),
+ };
+ const res = await once(target, { body: payload, headers });
+ const status = Number(res && res.status) || 0;
+ outcome = { status, error: null };
+ if (status >= 200 && status < 300) {
+ await db.prepare(`UPDATE exchange_webhook_deliveries SET status = 'delivered', attempts = ?, last_attempt_at = ?, delivered_at = ?,
+ next_attempt_at = NULL, last_status = ?, last_error = NULL WHERE id = ?`).run(attemptNo, at, at, status, row.id);
+ return { delivered: true, attempts: attemptNo, status };
+ }
+ outcome.error = `the receiver answered HTTP ${status}`;
+ } catch (err) {
+ outcome = { status: null, error: String((err && err.message) || err) };
+ }
+ const retry = attemptNo < maxAttempts
+ && (outcome.status == null || [408, 425, 429, 500, 502, 503, 504].includes(outcome.status))
+ && !(outcome.status >= 300 && outcome.status < 400);
+ if (retry) {
+ const next = iso(clock.now() + delayAfter(attemptNo, backoff));
+ await db.prepare(`UPDATE exchange_webhook_deliveries SET status = 'pending', attempts = ?, last_attempt_at = ?, next_attempt_at = ?,
+ last_status = ?, last_error = ? WHERE id = ?`).run(attemptNo, at, next, outcome.status, cut(outcome.error, 500), row.id);
+ return { delivered: false, attempts: attemptNo, status: outcome.status, next_attempt_at: next, error: outcome.error };
+ }
+ await db.prepare(`UPDATE exchange_webhook_deliveries SET status = 'failed', attempts = ?, last_attempt_at = ?, next_attempt_at = NULL,
+ last_status = ?, last_error = ? WHERE id = ?`).run(attemptNo, at, outcome.status, cut(outcome.error, 500), row.id);
+ return { delivered: false, attempts: attemptNo, status: outcome.status, error: outcome.error };
+ }
+
+ /**
+ * Deliver every due row once. A row is claimed by pushing next_attempt_at a lease ahead, an atomic UPDATE, so
+ * several workers of this service never send the same delivery twice; a worker that dies leaves the lease to
+ * expire, and the next tick tries again.
+ */
+ async function deliverDue({ limit = 25 } = {}) {
+ if (!enabled) return { sent: 0, delivered: 0, failed: 0 };
+ const due = iso(clock.now());
+ const lease = iso(clock.now() + Math.max(30000, timeoutMs + 30000));
+ const rows = await db.prepare(`SELECT d.*, e.url, e.secret FROM exchange_webhook_deliveries d
+ JOIN exchange_webhook_endpoints e ON e.id = d.endpoint_id
+ WHERE d.status = 'pending' AND (d.next_attempt_at IS NULL OR d.next_attempt_at <= ?)
+ ORDER BY d.next_attempt_at NULLS FIRST, d.created_at LIMIT ?`).all(due, Math.max(1, Math.min(100, Number(limit) || 25)));
+ let sent = 0;
+ let delivered = 0;
+ let failed = 0;
+ for (const row of rows) {
+ const claimed = await db.prepare(`UPDATE exchange_webhook_deliveries SET next_attempt_at = ?
+ WHERE id = ? AND status = 'pending' AND (next_attempt_at IS NULL OR next_attempt_at <= ?)`).run(lease, row.id, due);
+ if (!claimed || !claimed.changes) continue;
+ sent++;
+ const out = await attempt(row);
+ if (out.delivered) delivered++;
+ else if (out.next_attempt_at == null) failed++;
+ }
+ return { sent, delivered, failed };
+ }
+
+ /** Wake the worker after a write; a no-op before start() and inside tests that call deliverDue themselves. */
+ function kick() {
+ if (!timer || working) return;
+ setImmediate(async () => {
+ if (working) return;
+ working = true;
+ try { await deliverDue(); } catch (err) { log.warn(`[webhooks] delivery failed: ${err && err.message}`); }
+ finally { working = false; }
+ });
+ }
+
+ /**
+ * Queue one event for every matching receiver of an owner. The payload is the exact JSON string that will be
+ * signed and sent, stored once per delivery. Never throws: a webhook is a notification, never a reason for the
+ * write that caused it to fail.
+ */
+ async function emit({ owner, type, data, source = null, dedupe = true }) {
+ if (!enabled || !OWNER_RE.test(String(owner)) || !EVENT_SET.has(String(type))) return 0;
+ let rows;
+ try {
+ rows = await db.prepare('SELECT * FROM exchange_webhook_endpoints WHERE owner_subject = ? AND ? = ANY(events)').all(String(owner), String(type));
+ } catch (err) {
+ log.warn(`[webhooks] ${type} receivers for ${owner}: ${err && err.message}`);
+ return 0;
+ }
+ if (!rows.length) return 0;
+ const at = iso(clock.now());
+ const eventId = dedupe && source
+ ? `evt_${sha256(`${type}|${source}|${owner}`).slice(0, 26)}`
+ : `evt_${ids.ulid()}`;
+ const payload = JSON.stringify({ id: eventId, type, created_at: at, data: data || {} });
+ let queued = 0;
+ for (const row of rows) {
+ try {
+ await db.prepare(`INSERT INTO exchange_webhook_deliveries (id, endpoint_id, owner_subject, event_id, event_type, payload, status, attempts, next_attempt_at, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?)`)
+ .run(`whd_${ids.ulid()}`, row.id, owner, eventId, String(type), payload, at, at);
+ queued++;
+ } catch (err) {
+ log.warn(`[webhooks] ${type} delivery for ${owner}: ${err && err.message}`);
+ }
+ }
+ kick();
+ return queued;
+ }
+
+ /**
+ * The usage event of an answered run, and the earnings event when a community offer served it. The owner is the
+ * run's own requester (a person or a project); a community earnings event also reaches the project that
+ * registered the endpoint. The prompt and the answer are never in either payload.
+ */
+ async function usage({ row, record, outcome = 'succeeded' } = {}) {
+ if (!enabled || !row || !record) return 0;
+ const owner = row.requester_type === 'project' ? row.requester_id : (record.subject || null);
+ let queued = 0;
+ if (owner) {
+ queued += await emit({
+ owner, type: 'usage', source: row.id,
+ data: {
+ run_id: row.id, outcome,
+ provider: record.provider || null, model: record.model || null,
+ tokens: {
+ input: Number(record.tokens_input) || 0, output: Number(record.tokens_output) || 0,
+ reasoning: Number(record.tokens_reasoning) || 0,
+ cache_read: Number(record.tokens_cache_read) || 0, cache_write: Number(record.tokens_cache_write) || 0,
+ },
+ cost_usd: {
+ list: Number(record.list_cost_usd) || 0, charged: Number(record.charged_usd) || 0,
+ fee: Number(record.fee_usd) || 0, cache_saved: Number(record.cache_saved_usd) || 0,
+ },
+ paid_by: record.paid_by || null, latency_ms: record.latency_ms == null ? null : Number(record.latency_ms),
+ ttft_ms: record.ttft_ms == null ? null : Number(record.ttft_ms), at: record.at || iso(clock.now()),
+ },
+ });
+ }
+ const explain = parseJson(row.explain, null);
+ const c = explain && explain.community;
+ if (outcome === 'succeeded' && c && c.owner && (Number(c.credits_micro) > 0 || Number(c.kudos) > 0)) {
+ const data = {
+ offer_id: c.offer_id || null, kind: c.kind || 'machine', model: c.model || null, run_id: row.id,
+ tokens: { input: Number(c.tokens_in) || 0, output: Number(c.tokens_out) || 0 },
+ credits_micro: Number(c.credits_micro) || 0, earning_status: c.earning_status || null,
+ self_served: Boolean(c.self_served), kudos: Number(c.kudos) || 0, at: record.at || iso(clock.now()),
+ };
+ queued += await emit({ owner: c.owner, type: 'earnings', source: `${row.id}|${c.owner}`, data });
+ if (c.project_id && c.project_id !== c.owner) {
+ queued += await emit({ owner: c.project_id, type: 'earnings', source: `${row.id}|${c.project_id}`, data });
+ }
+ }
+ return queued;
+ }
+
+ /** An endpoint offer is gone: tell its owner (and the developer project that registered it), once. */
+ async function offerDown({ owner, projectId = null, offer = null, reason = 'removed by the seller' } = {}) {
+ if (!enabled || !owner) return 0;
+ const data = {
+ offer_id: (offer && offer.offer_id) || null, kind: 'endpoint',
+ models: (offer && Array.isArray(offer.models)) ? offer.models : [], reason, at: iso(clock.now()),
+ };
+ let queued = await emit({ owner, type: 'offer.down', source: `offer|${(offer && offer.id) || 'unknown'}`, data });
+ if (projectId && projectId !== owner) {
+ queued += await emit({ owner: projectId, type: 'offer.down', source: `offer|${(offer && offer.id) || 'unknown'}`, data });
+ }
+ return queued;
+ }
+
+ /** Start the retry worker (off in tests, which call deliverDue with their own clock). */
+ function start() {
+ if (!enabled || timer) return;
+ timer = setInterval(async () => {
+ if (working) return;
+ working = true;
+ try { await deliverDue(); } catch (err) { log.warn(`[webhooks] delivery failed: ${err && err.message}`); }
+ finally { working = false; }
+ }, tickMs);
+ if (timer.unref) timer.unref();
+ }
+
+ function stop() {
+ if (timer) clearInterval(timer);
+ timer = null;
+ }
+
+ return { enabled, EVENTS, list, create, remove, deliveries, emit, usage, offerDown, deliverDue, start, stop, _test: { judgeUrl, delayAfter } };
+}
+
+module.exports = { createWebhooks, judgeUrl, EVENTS };
@@ -32,7 +32,7 @@ const { AiError } = require('./util');
@@ -32,7 +32,7 @@ const { AiError } = require('./util');
const { gracefulStop, telemetry } = require('openvibe-sdk/service');
const { gracefulStop, telemetry } = require('openvibe-sdk/service');
const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk/telemetry');
const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk/telemetry');
-async function start({ config, db: givenDb = null, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, listen = true, credentialFetch = null, valkey = null, research = null, machineModels = null, variantEvidence = null, community: givenCommunity = null, communityEndpoints: givenEndpoints = null, abuse: givenAbuse = null } = {}) {
+async function start({ config, db: givenDb = null, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, listen = true, credentialFetch = null, valkey = null, research = null, machineModels = null, variantEvidence = null, community: givenCommunity = null, communityEndpoints: givenEndpoints = null, webhooks: givenWebhooks = null, abuse: givenAbuse = null } = {}) {
config = config || load(env);
config = config || load(env);
schemas.configure(config.schemaCache);
schemas.configure(config.schemaCache);
// PostgreSQL (ADR-035): opened and migrated here unless the caller (a test) hands in a migrated handle.
// PostgreSQL (ADR-035): opened and migrated here unless the caller (a test) hands in a migrated handle.
@@ -70,6 +70,9 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
@@ -70,6 +70,9 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
const endpoints = givenEndpoints || (config.community.endpoints && config.community.endpoints.enabled === false
const endpoints = givenEndpoints || (config.community.endpoints && config.community.endpoints.enabled === false
? null
? null
: require('./community/endpoints').createEndpointOffers({ db, config, env, clock, fetchImpl, log }));
: require('./community/endpoints').createEndpointOffers({ db, config, env, clock, fetchImpl, log }));
+ // Exchange webhooks (the plan's "For builders", item 7): the receivers a developer registers, the signed
+ // deliveries of usage/earnings/offer.down, and the retry worker. A row per delivery; the worker is off in tests.
+ const webhooks = givenWebhooks || require('./exchange/webhooks').createWebhooks({ db, config, clock, log });
const community = givenCommunity || require('./community').createCommunity({ db, config, env, machines, endpoints, clock, fetchImpl, log });
const community = givenCommunity || require('./community').createCommunity({ db, config, env, machines, endpoints, clock, fetchImpl, log });
// Plan E5: the free tier's allowance by trust, device and network signals, proof of work and anomaly throttling.
// Plan E5: the free tier's allowance by trust, device and network signals, proof of work and anomaly throttling.
const abuse = givenAbuse || require('./abuse').createAbuse({ db, config, env, clock, fetchImpl, log, registry, community });
const abuse = givenAbuse || require('./abuse').createAbuse({ db, config, env, clock, fetchImpl, log, registry, community });
@@ -128,7 +131,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
@@ -128,7 +131,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
startSettlements() { return credits ? credits.startSettlements() : null; },
startSettlements() { return credits ? credits.startSettlements() : null; },
stopSettlements() { return credits ? credits.stopSettlements() : undefined; },
stopSettlements() { return credits ? credits.stopSettlements() : undefined; },
};
};
- const runs = createRuns({ db, registry, engine, cache, quotas, config, clock, log, userModules, credentials, preferences, tiers, projectSecrets, pool, runBudgets, credits: creditsRef, community, presets, receipts, abuse });
+ const runs = createRuns({ db, registry, engine, cache, quotas, config, clock, log, userModules, credentials, preferences, tiers, projectSecrets, pool, runBudgets, credits: creditsRef, community, presets, receipts, abuse, webhooks });
await seed({ registry, quotas, config, env, db });
await seed({ registry, quotas, config, env, db });
// The seed creates or moves providers from the environment: publish their origins (the schema and every key's
// The seed creates or moves providers from the environment: publish their origins (the schema and every key's
// bind follow), so a key is bound to the host it is for from the first boot.
// bind follow), so a key is bound to the host it is for from the first boot.
@@ -161,7 +164,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
@@ -161,7 +164,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
clientSecret: env.OV_OAUTH_CLIENT_SECRET, fetch: fetchImpl,
clientSecret: env.OV_OAUTH_CLIENT_SECRET, fetch: fetchImpl,
}) : null;
}) : null;
const benchEvents = createBenchEvents({ bench, accountData, accountSend, config, log, fetchImpl, clientSecret: env.OV_OAUTH_CLIENT_SECRET });
const benchEvents = createBenchEvents({ bench, accountData, accountSend, config, log, fetchImpl, clientSecret: env.OV_OAUTH_CLIENT_SECRET });
- const app = createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, preferences, grants, vault, settings, benchEvents, runBudgets, memory, memoryImport, community, endpoints, presets, receipts, balance, gpus, abuse, priceBook });
+ const app = createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, preferences, grants, vault, settings, benchEvents, runBudgets, memory, memoryImport, community, endpoints, webhooks, presets, receipts, balance, gpus, abuse, priceBook });
let jwks = null;
let jwks = null;
let keyLoaded = Promise.resolve(null);
let keyLoaded = Promise.resolve(null);
if (!keys.pinned && config.nodeEnv !== 'test') {
if (!keys.pinned && config.nodeEnv !== 'test') {
@@ -198,6 +201,8 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
@@ -198,6 +201,8 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
let benchSubscription = null;
let benchSubscription = null;
let accountSubscription = null;
let accountSubscription = null;
if (listen) userModules.start();
if (listen) userModules.start();
+ // The signed webhook retry worker: never in test mode (tests call deliverDue with their own clock).
+ if (listen && config.nodeEnv !== 'test') webhooks.start();
if (listen) balance.start();
if (listen) balance.start();
if (listen && config.memoryJobs && config.memoryJobs.enabled) memoryJobs.start();
if (listen && config.memoryJobs && config.memoryJobs.enabled) memoryJobs.start();
// The price book reads the providers' listings once a week; it never starts in test mode or under a drill.
// The price book reads the providers' listings once a week; it never starts in test mode or under a drill.
@@ -238,6 +243,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
@@ -238,6 +243,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
userModules.stop();
userModules.stop();
memoryJobs.stop();
memoryJobs.stop();
priceBook.stop();
priceBook.stop();
+ webhooks.stop();
if (credits) credits.stopSettlements();
if (credits) credits.stopSettlements();
if (jwks) jwks.stop();
if (jwks) jwks.stop();
if (server) {
if (server) {
@@ -253,7 +259,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
@@ -253,7 +259,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
if (!givenDb) await db.close();
if (!givenDb) await db.close();
}
}
- return { config, db, registry, pool, quotas, tiers, cache, fetcher, engine, runs, keys, keyLoaded, auth, app, server, bench, benchEvents, memory, memoryJobs, priceBook, credits: creditsRef, settings, vault, community, endpoints, gpus, abuse, close };
+ return { config, db, registry, pool, quotas, tiers, cache, fetcher, engine, runs, keys, keyLoaded, auth, app, server, bench, benchEvents, memory, memoryJobs, priceBook, credits: creditsRef, settings, vault, community, endpoints, webhooks, gpus, abuse, close };
}
}
if (require.main === module) {
if (require.main === module) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,248 @@
@@ -0,0 +1,248 @@
+'use strict';
+/**
+ * The OpenVibe.Exchange MCP server (design EXCHANGE-REMAKE-2026-11-06, "For builders", item 7): the tools an MCP
+ * client (Claude Desktop, Cursor, an agent) uses to pick a model and run a completion on the person's own account.
+ *
+ * It speaks MCP over stdio in bin/openvibe-exchange-mcp.js and calls the public Exchange API with the person's
+ * Network token (OPENVIBE_EXCHANGE_TOKEN, sent as a Bearer, never printed or logged):
+ *
+ * exchange_models GET /api/v1/exchange/catalog -- the live catalog: price in/out, measured p50/p95, uptime
+ * exchange_chat POST /v1/chat/completions -- the person's allowance, or their own stored provider key when the
+ * request names a provider (x-openvibe-credential), exactly as the SDK's buyer side does
+ * exchange_usage GET /api/v1/exchange/me/usage -- the person's own requests, tokens and spend by day and model
+ *
+ * The server never stores a key, a prompt or an answer, and it does not answer a tool call without a token: a
+ * missing token is refused before any request, and a 401 from the Exchange is refused back as the tool's error.
+ */
+const pkg = require('../../package.json');
+
+const MCP_PROTOCOL = '2024-11-05';
+const DEFAULT_BASE = 'https://api.openvibe.exchange';
+const DEFAULT_TIMEOUT_MS = 30000;
+const CHAT_TIMEOUT_MS = 180000;
+const MAX_BODY = 8 * 1024 * 1024;
+const MAX_MESSAGES = 200;
+const MAX_CONTENT = 200000;
+
+class McpError extends Error {
+ constructor(message, code = 'exchange.error') { super(message); this.name = 'McpError'; this.code = code; }
+}
+
+const text = (s) => String(s == null ? '' : s);
+
+/** The error the Exchange answered, without ever echoing the token. */
+function apiError(status, body) {
+ const detail = body && (body.detail || (body.error && body.error.message) || body.title);
+ if (status === 401) return new McpError('the Exchange refused the token (401): make a new Network developer key and set OPENVIBE_EXCHANGE_TOKEN', 'auth.refused');
+ if (status === 403) return new McpError(`the Exchange refused this token (403${detail ? `: ${text(detail)}` : ''})`, 'auth.refused');
+ return new McpError(`the Exchange answered HTTP ${status}${detail ? `: ${text(detail)}` : ''}`, 'exchange.error');
+}
+
+/** The tools, with the JSON schemas an MCP client shows. */
+const TOOLS = [
+ {
+ name: 'exchange_models',
+ description: 'List the models OpenVibe.Exchange can run right now, with the live price (USD per million tokens, in and out), measured p50/p95 latency in ms, uptime (0-1), health and the provider data policy. Optional query and kind (chat, embedding, stt, image) filter the list. Public: no token is needed for this read.',
+ inputSchema: {
+ type: 'object', properties: {
+ query: { type: 'string', description: 'Only models whose slug, name or family contains this text' },
+ kind: { type: 'string', enum: ['chat', 'embedding', 'stt', 'image'], description: 'Only models of this kind' },
+ limit: { type: 'integer', minimum: 1, maximum: 200, description: 'How many models to return (default 25)' },
+ },
+ },
+ },
+ {
+ name: 'exchange_chat',
+ description: "Run one chat completion on the person's OpenVibe.Exchange account: the person's daily free allowance or Balance, or their own stored provider key when `credential` names the provider. Returns the OpenAI-shaped answer and the receipt (x_openvibe). The prompt and the answer are not stored by the MCP server.",
+ inputSchema: {
+ type: 'object', required: ['model', 'messages'], properties: {
+ model: { type: 'string', description: 'A model id (deepseek/deepseek-flash), openvibe/auto, or @preset/<name>' },
+ messages: {
+ type: 'array', minItems: 1, maxItems: MAX_MESSAGES,
+ items: { type: 'object', required: ['role', 'content'], properties: {
+ role: { type: 'string', enum: ['system', 'user', 'assistant', 'tool'] },
+ content: { type: 'string' },
+ } },
+ },
+ max_tokens: { type: 'integer', minimum: 1, maximum: 32768, description: 'Default 512' },
+ temperature: { type: 'number', minimum: 0, maximum: 2 },
+ credential: { type: 'string', description: "A provider whose key the person stored on OpenVibe (for example deepseek), instead of the router's own capacity" },
+ },
+ },
+ },
+ {
+ name: 'exchange_usage',
+ description: "Read the person's own Exchange usage for the last N days (or a date window): requests, tokens and spend by day, model and workflow. Never anyone else's usage.",
+ inputSchema: {
+ type: 'object', properties: {
+ days: { type: 'integer', minimum: 1, maximum: 366, description: 'Look back this many days (default 30); ignored with from/to' },
+ from: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$', description: 'First day, YYYY-MM-DD' },
+ to: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$', description: 'Last day, YYYY-MM-DD' },
+ },
+ },
+ },
+];
+
+const byName = new Map(TOOLS.map((t) => [t.name, t]));
+
+function createExchangeMcp({ token = null, baseUrl = DEFAULT_BASE, fetchImpl = globalThis.fetch, timeoutMs = DEFAULT_TIMEOUT_MS, version = pkg.version } = {}) {
+ const base = String(baseUrl || DEFAULT_BASE).replace(/\/+$/, '');
+ const authToken = token == null ? '' : String(token);
+
+ async function publicCatalog({ kind = null } = {}) {
+ const q = kind ? `?kind=${encodeURIComponent(kind)}` : '';
+ const headers = { Accept: 'application/json' };
+ if (authToken) headers.Authorization = `Bearer ${authToken}`;
+ let res;
+ try { res = await fetchImpl(`${base}/api/v1/exchange/catalog${q}`, { headers, signal: AbortSignal.timeout(timeoutMs) }); }
+ catch (err) { throw new McpError(`the Exchange could not be reached: ${String((err && err.message) || err).slice(0, 200)}`, 'exchange.unreachable'); }
+ const raw = await res.text();
+ let data = null;
+ try { data = raw ? JSON.parse(raw) : null; } catch { data = null; }
+ if (!res.ok) throw apiError(res.status, data);
+ return data;
+ }
+
+ function compactModels(catalog, { query = null, limit = 25 } = {}) {
+ const q = query == null ? '' : String(query).trim().toLowerCase();
+ let models = Array.isArray(catalog && catalog.models) ? catalog.models : [];
+ if (q) models = models.filter((m) => [m.slug, m.name, m.family, m.canonical].some((v) => String(v || '').toLowerCase().includes(q)));
+ const n = Math.max(1, Math.min(200, Number(limit) || 25));
+ return {
+ generated_at: catalog && catalog.generated_at ? catalog.generated_at : null,
+ window_days: catalog && catalog.window_days != null ? catalog.window_days : null,
+ count: models.length,
+ free_available: Boolean(catalog && catalog.free_available),
+ models: models.slice(0, n).map((m) => ({
+ slug: m.slug, name: m.name, family: m.family, kind: m.kind, uncensored: Boolean(m.uncensored),
+ context: m.context == null ? null : m.context, free_now: Boolean(m.free_now),
+ offers: (Array.isArray(m.offers) ? m.offers : []).map((o) => ({
+ provider: o.provider, model: o.model, status: o.status, trust: o.trust,
+ price: o.price ? { in: o.price.in, out: o.price.out, cached: o.price.cached, per: o.price.per } : null,
+ speed: o.speed ? { p50_ms: o.speed.p50_ms, p95_ms: o.speed.p95_ms } : null,
+ uptime: o.uptime == null ? null : o.uptime,
+ requests_7d: o.requests_7d == null ? null : o.requests_7d,
+ free_tier: Boolean(o.free_tier), privacy: o.privacy || null,
+ })),
+ })),
+ };
+ }
+
+ function checkChat(args) {
+ const a = args && typeof args === 'object' ? args : {};
+ const model = text(a.model).trim();
+ if (!model) throw new McpError('exchange_chat needs a model', 'input.invalid');
+ if (!Array.isArray(a.messages) || !a.messages.length || a.messages.length > MAX_MESSAGES) {
+ throw new McpError(`exchange_chat needs 1 to ${MAX_MESSAGES} messages`, 'input.invalid');
+ }
+ const messages = a.messages.map((m) => {
+ if (!m || typeof m !== 'object') throw new McpError('each message is {role, content}', 'input.invalid');
+ const role = text(m.role);
+ if (!['system', 'user', 'assistant', 'tool'].includes(role)) throw new McpError('a message role is system, user, assistant or tool', 'input.invalid');
+ return { role, content: text(m.content).slice(0, MAX_CONTENT) };
+ });
+ const body = { model, messages, stream: false };
+ if (a.max_tokens != null) {
+ const n = Number(a.max_tokens);
+ if (!Number.isInteger(n) || n < 1 || n > 32768) throw new McpError('max_tokens is an integer from 1 to 32768', 'input.invalid');
+ body.max_tokens = n;
+ }
+ if (a.temperature != null) {
+ const t = Number(a.temperature);
+ if (!Number.isFinite(t) || t < 0 || t > 2) throw new McpError('temperature is a number from 0 to 2', 'input.invalid');
+ body.temperature = t;
+ }
+ return { body, credential: a.credential == null ? null : text(a.credential).trim().slice(0, 64) };
+ }
+
+ async function runTool(name, args) {
+ if (name === 'exchange_models') {
+ const a = args && typeof args === 'object' ? args : {};
+ const kind = a.kind == null ? null : text(a.kind);
+ if (kind && !['chat', 'embedding', 'stt', 'image'].includes(kind)) throw new McpError('kind is chat, embedding, stt or image', 'input.invalid');
+ const catalog = await publicCatalog({ kind });
+ return compactModels(catalog, { query: a.query, limit: a.limit });
+ }
+ if (name === 'exchange_chat') {
+ const { body, credential } = checkChat(args);
+ const headers = {};
+ if (credential) headers['x-openvibe-credential'] = credential;
+ return await call('/v1/chat/completions', { method: 'POST', body, headers, timeout: CHAT_TIMEOUT_MS });
+ }
+ if (name === 'exchange_usage') {
+ const a = args && typeof args === 'object' ? args : {};
+ const params = new URLSearchParams();
+ if (a.from || a.to) {
+ for (const k of ['from', 'to']) {
+ if (a[k] == null) continue;
+ const v = text(a[k]).slice(0, 10);
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(v)) throw new McpError(`${k} is YYYY-MM-DD`, 'input.invalid');
+ params.set(k, v);
+ }
+ } else {
+ const days = a.days == null ? 30 : Number(a.days);
+ if (!Number.isInteger(days) || days < 1 || days > 366) throw new McpError('days is an integer from 1 to 366', 'input.invalid');
+ params.set('days', String(days));
+ }
+ return await call(`/api/v1/exchange/me/usage?${params.toString()}`);
+ }
+ throw new McpError(`unknown tool ${name}`, 'tools.unknown');
+ }
+
+ /** The authenticated JSON call; extra headers carry the person's own provider key selector. */
+ async function call(path, { method = 'GET', body = null, headers = {}, timeout = timeoutMs } = {}) {
+ if (!authToken) throw new McpError('not signed in: set OPENVIBE_EXCHANGE_TOKEN to a Network developer key (the Exchange MCP server never runs without one)', 'auth.missing');
+ const all = { Authorization: `Bearer ${authToken}`, Accept: 'application/json', ...headers };
+ if (body != null) all['Content-Type'] = 'application/json';
+ let res;
+ try {
+ res = await fetchImpl(`${base}${path}`, { method, headers: all, body: body == null ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(timeout) });
+ } catch (err) {
+ throw new McpError(`the Exchange could not be reached: ${String((err && err.message) || err).slice(0, 200)}`, 'exchange.unreachable');
+ }
+ const raw = await res.text();
+ let data = null;
+ try { data = raw ? JSON.parse(raw) : null; } catch { data = null; }
+ if (!res.ok) throw apiError(res.status, data);
+ return data;
+ }
+
+ /** One JSON-RPC message -> the response object, or null for a notification. */
+ async function handle(message) {
+ const isObject = message && typeof message === 'object' && !Array.isArray(message);
+ if (!isObject) return { jsonrpc: '2.0', id: null, error: { code: -32600, message: 'invalid request' } };
+ const id = message.id === undefined ? null : message.id;
+ const method = text(message.method);
+ const notification = message.id === undefined;
+ const reply = (result) => (notification ? null : { jsonrpc: '2.0', id, result });
+ const fail = (code, msg) => (notification ? null : { jsonrpc: '2.0', id, error: { code, message: msg } });
+ if (method === 'initialize') {
+ return reply({
+ protocolVersion: MCP_PROTOCOL,
+ capabilities: { tools: { listChanged: false } },
+ serverInfo: { name: 'openvibe-exchange', version },
+ instructions: 'Tools for OpenVibe.Exchange: list live models and prices, run a chat completion on the signed-in person\'s allowance or their own provider key, and read their usage. Set OPENVIBE_EXCHANGE_TOKEN to a Network developer key.',
+ });
+ }
+ if (!method || method.startsWith('notifications/')) return null;
+ if (method === 'ping') return reply({});
+ if (method === 'tools/list') return reply({ tools: TOOLS });
+ if (method === 'tools/call') {
+ const name = text(message.params && message.params.name);
+ const args = (message.params && message.params.arguments) || {};
+ if (!byName.has(name)) return reply({ content: [{ type: 'text', text: `unknown tool ${name}` }], isError: true });
+ try {
+ const value = await runTool(name, args);
+ return reply({ content: [{ type: 'text', text: JSON.stringify(value) }] });
+ } catch (err) {
+ const msg = err instanceof McpError ? err.message : `the tool failed: ${String((err && err.message) || err).slice(0, 300)}`;
+ return reply({ content: [{ type: 'text', text: msg }], isError: true });
+ }
+ }
+ return fail(-32601, `method not found: ${method}`);
+ }
+
+ return { handle, tools: TOOLS, baseUrl: base, hasToken: Boolean(authToken), _test: { compactModels, checkChat } };
+}
+
+module.exports = { createExchangeMcp, McpError, TOOLS, MCP_PROTOCOL };
@@ -374,6 +374,61 @@ route('get', '/exchange/price-changes', {
@@ -374,6 +374,61 @@ route('get', '/exchange/price-changes', {
} }) },
} }) },
});
});
+// ── Webhooks: usage, earnings and offer.down (the design's "For builders", item 7) ─────────────
+const exchangeWebhook = {
+ type: 'object', required: ['id', 'url', 'events', 'created_at'], properties: {
+ id: str({ pattern: '^whk_[0-9A-HJKMNP-TV-Z]{26}$' }),
+ url: str({ description: 'The https receiver the signed deliveries go to' }),
+ events: arrayOf(str({ enum: ['usage', 'earnings', 'offer.down'] })),
+ description: nullable('string'),
+ created_at: str({ format: 'date-time' }), updated_at: str({ format: 'date-time' }),
+ secret: str({ description: 'The HMAC signing secret (whsec_…), in the create answer only. Keep it; it is never shown again.' }),
+ },
+};
+const exchangeWebhookDelivery = {
+ type: 'object', required: ['id', 'event_id', 'event_type', 'status', 'attempts'], properties: {
+ id: str({ pattern: '^whd_[0-9A-HJKMNP-TV-Z]{26}$' }), event_id: str(), event_type: str({ enum: ['usage', 'earnings', 'offer.down'] }),
+ status: str({ enum: ['pending', 'delivered', 'failed'] }), attempts: integer({ minimum: 0 }),
+ last_status: nullable('integer'), last_error: nullable('string'),
+ last_attempt_at: nullable('string', { format: 'date-time' }), next_attempt_at: nullable('string', { format: 'date-time' }),
+ delivered_at: nullable('string', { format: 'date-time' }), created_at: str({ format: 'date-time' }),
+ payload: { type: 'object', description: 'The exact event body that was signed and sent; no prompt, answer or secret' },
+ },
+};
+route('post', '/exchange/webhooks', {
+ operationId: 'createExchangeWebhook', summary: 'Register an https webhook receiver', tags: ['exchange'], security: SERVICE_OR_APP,
+ description: 'ai.app.run (a developer key) or a person\'s own token; a first-party service token is refused. The body is {url, events?, description?}. The receiver URL is judged at registration by the egress guard: https, a public DNS answer, never an IP literal, port 443 or 8443, no query or fragment. The signing secret (whsec_…) is in this answer only. Every delivery carries X-OpenVibe-Signature-V2: t=<unix seconds>, v2=<hex HMAC-SHA256 of "<t>.<raw body>">, exactly the header openvibe-sdk/exchange verifyWebhook checks.',
+ requestBody: body({ type: 'object', required: ['url'], properties: {
+ url: str({ description: 'https receiver, a public host name' }),
+ events: arrayOf(str({ enum: ['usage', 'earnings', 'offer.down'] }), { minItems: 1, maxItems: 3 }),
+ description: str({ maxLength: 200 }),
+ } }),
+ responses: { 201: json('The receiver, with its secret once', { type: 'object', required: ['webhook'], properties: { webhook: exchangeWebhook } }), 409: problem, 422: problem, ...errors([401, 403]) },
+});
+route('get', '/exchange/webhooks', {
+ operationId: 'listExchangeWebhooks', summary: "The caller's webhook receivers (never a secret)", tags: ['exchange'], security: SERVICE_OR_APP,
+ description: 'The receivers of the person the token acts for, or of the developer app\'s project.',
+ responses: { 200: json('The receivers', { type: 'object', required: ['owner', 'webhooks'], properties: { owner: str(), webhooks: arrayOf(exchangeWebhook) } }), ...errors([401, 403]) },
+});
+route('delete', '/exchange/webhooks/{id}', {
+ operationId: 'deleteExchangeWebhook', summary: 'Remove one of the caller\'s webhook receivers', tags: ['exchange'], security: SERVICE_OR_APP,
+ description: 'The receiver and its delivery history are removed. The secret is never shown again after creation.',
+ parameters: [path('id', str({ pattern: '^whk_[0-9A-HJKMNP-TV-Z]{26}$' }))],
+ responses: { 200: json('The removal', { type: 'object', properties: { id: str(), removed: { type: 'boolean' } } }), 404: problem, ...errors([401, 403]) },
+});
+route('get', '/exchange/webhooks/{id}/deliveries', {
+ operationId: 'listExchangeWebhookDeliveries', summary: 'The delivery log: what was sent, how often and what the receiver answered', tags: ['exchange'], security: SERVICE_OR_APP,
+ description: 'Newest first. A delivery is retried with an exponential backoff on a 408, 425, 429 or 5xx and on a network failure; any other non-2xx status fails it at once. The payload is the exact event body that was signed.',
+ parameters: [path('id', str({ pattern: '^whk_[0-9A-HJKMNP-TV-Z]{26}$' })), query('limit', integer({ minimum: 1, maximum: 200 }), 'Default 50')],
+ responses: { 200: json('The deliveries', { type: 'object', required: ['id', 'deliveries'], properties: { id: str(), deliveries: arrayOf(exchangeWebhookDelivery) } }), 404: problem, ...errors([401, 403]) },
+});
+route('get', '/exchange/me/usage', {
+ operationId: 'exchangeMyUsage', summary: 'The caller\'s own requests, tokens and spend by day and model', tags: ['exchange'], security: SERVICE_OR_APP,
+ description: 'A person\'s own usage, or a developer app\'s project usage. Never anyone else\'s: the caller is the owner, read from the token. Use from/to as YYYY-MM-DD; the rows are the same usage_daily roll-ups the platform keeps.',
+ parameters: [query('from', str({ format: 'date' }), 'First day, YYYY-MM-DD'), query('to', str({ format: 'date' }), 'Last day, YYYY-MM-DD')],
+ responses: { 200: json('The usage rows', { type: 'object', properties: { requester: str(), usage: arrayOf({ type: 'object' }) } }), ...errors([401, 403, 422]) },
+});
+
// ── The seller API: a standalone OpenAI-compatible endpoint (the design's SDK gap) ───────────
// ── The seller API: a standalone OpenAI-compatible endpoint (the design's SDK gap) ───────────
const sellerEndpointOffer = {
const sellerEndpointOffer = {
type: 'object', required: ['id', 'offer_id', 'base_url', 'models', 'hours', 'concurrency', 'price', 'audience'],
type: 'object', required: ['id', 'offer_id', 'base_url', 'models', 'hours', 'concurrency', 'price', 'audience'],
@@ -106,7 +106,7 @@ function routePinRequest(value, principal, wf) {
@@ -106,7 +106,7 @@ function routePinRequest(value, principal, wf) {
return { provider, model };
return { provider, model };
}
}
-function createRuns({ db, registry, engine, cache, quotas, config, clock = { now: () => Date.now() }, log = console, userModules = null, credentials = null, preferences: personPreferences = null, tiers = null, projectSecrets = null, pool = null, runBudgets = null, credits = null, community = null, presets = null, receipts = null, abuse = null }) {
+function createRuns({ db, registry, engine, cache, quotas, config, clock = { now: () => Date.now() }, log = console, userModules = null, credentials = null, preferences: personPreferences = null, tiers = null, projectSecrets = null, pool = null, runBudgets = null, credits = null, community = null, presets = null, receipts = null, abuse = null, webhooks = null }) {
const inflight = new Map(); // run id -> { controller, promise, release, caller }
const inflight = new Map(); // run id -> { controller, promise, release, caller }
const queue = []; // run ids waiting for the worker
const queue = []; // run ids waiting for the worker
let active = 0;
let active = 0;
@@ -563,6 +563,12 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -563,6 +563,12 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
});
});
// The event (ai.call.recorded) is queued in its own transaction, like every other event here.
// The event (ai.call.recorded) is queued in its own transaction, like every other event here.
await db.tx(async () => { await events.callRecorded(rec); });
await db.tx(async () => { await events.callRecorded(rec); });
+ // Exchange webhooks (server/exchange/webhooks.js): the usage event, and the earnings event when a
+ // community offer served the run. They are rows delivered after this write, never a reason to fail it.
+ if (webhooks && typeof webhooks.usage === 'function') {
+ try { await webhooks.usage({ row, record: rec, outcome }); }
+ catch (err) { log.warn(`[runs] ${row.id}: the exchange webhook was not queued: ${err && err.message}`); }
+ }
return { payer, prices, money, tokens };
return { payer, prices, money, tokens };
} catch (err) {
} catch (err) {
log.warn(`[runs] ${row.id}: the usage record was not written: ${err && err.message}`);
log.warn(`[runs] ${row.id}: the usage record was not written: ${err && err.message}`);
new file mode 100644
new file mode 100644
@@ -0,0 +1,143 @@
@@ -0,0 +1,143 @@
+'use strict';
+// The Exchange MCP server (the design's "For builders", item 7): list live models, run a completion on the person's
+// own account, read their usage, and refuse a call without a token (or with a token the Exchange refuses) before it
+// can spend anything. The transport is a local fake Exchange; no test reaches the network.
+const assert = require('assert');
+const http = require('node:http');
+const { suite } = require('./helpers');
+const { createExchangeMcp } = require('../server/mcp/exchange');
+
+const t = suite('exchange-mcp');
+const TOKEN = 'ovk_test_person_token_0123456789';
+const PERSON = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A1';
+
+function fakeExchange() {
+ const calls = [];
+ const server = http.createServer((req, res) => {
+ let raw = '';
+ req.on('data', (c) => { raw += c; });
+ req.on('end', () => {
+ let body = null;
+ try { body = raw ? JSON.parse(raw) : null; } catch { body = raw; }
+ calls.push({ method: req.method, url: req.url, headers: req.headers, body });
+ const send = (status, value) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(value)); };
+ if (req.url.startsWith('/api/v1/exchange/catalog')) {
+ return send(200, {
+ generated_at: '2026-11-07T12:00:00Z', window_days: 7, count: 1, free_available: true,
+ models: [{
+ slug: 'deepseek/deepseek-flash', name: 'DeepSeek Flash', family: 'DeepSeek', kind: 'chat',
+ uncensored: false, context: 128000, free_now: true,
+ offers: [{ provider: 'deepseek', model: 'deepseek-chat', status: 'up', trust: 'first-party',
+ price: { in: 0.14, out: 0.42, cached: 0.014, per: '1M tokens', currency: 'USD' },
+ speed: { p50_ms: 320, p95_ms: 900 }, uptime: 0.997, requests_7d: 1200, free_tier: true,
+ privacy: { label: 'private' } }],
+ }],
+ });
+ }
+ if (req.url.startsWith('/v1/chat/completions')) {
+ if (req.headers.authorization !== `Bearer ${TOKEN}`) return send(401, { detail: 'bad token' });
+ return send(200, {
+ id: 'chatcmpl_1', object: 'chat.completion', model: body.model,
+ choices: [{ index: 0, message: { role: 'assistant', content: 'Hello from the Exchange' }, finish_reason: 'stop' }],
+ usage: { prompt_tokens: 4, completion_tokens: 3 },
+ x_openvibe: { run_id: 'run_1', cost_usd: 0, receipt_id: 'rcpt_1' },
+ });
+ }
+ if (req.url.startsWith('/api/v1/exchange/me/usage')) {
+ if (req.headers.authorization !== `Bearer ${TOKEN}`) return send(401, { detail: 'bad token' });
+ return send(200, { requester: `user:${PERSON}`, usage: [{ day: '2026-11-07', requests: 2, tokens_in: 10, tokens_out: 5, cost_usd: 0.000004 }] });
+ }
+ return send(404, { detail: 'not found' });
+ });
+ });
+ return new Promise((resolve) => {
+ server.listen(0, '127.0.0.1', () => {
+ resolve({ url: `http://127.0.0.1:${server.address().port}`, calls, close: () => new Promise((r) => { server.closeAllConnections?.(); server.close(() => r()); }) });
+ });
+ });
+}
+
+let fake;
+let mcp;
+const call = (id, name, args) => mcp.handle({ jsonrpc: '2.0', id, method: 'tools/call', params: { name, arguments: args } });
+
+t.test('initialize and tools/list', async () => {
+ fake = await fakeExchange();
+ mcp = createExchangeMcp({ token: TOKEN, baseUrl: fake.url });
+ const init = await mcp.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test' } } });
+ assert.strictEqual(init.result.protocolVersion, '2024-11-05');
+ assert.strictEqual(init.result.serverInfo.name, 'openvibe-exchange');
+ const list = await mcp.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list' });
+ assert.deepStrictEqual(list.result.tools.map((x) => x.name).sort(), ['exchange_chat', 'exchange_models', 'exchange_usage']);
+ assert.strictEqual(await mcp.handle({ jsonrpc: '2.0', method: 'notifications/initialized' }), null);
+ assert.strictEqual((await mcp.handle({ jsonrpc: '2.0', id: 3, method: 'nope' })).error.code, -32601);
+});
+
+t.test('models: live prices, speed and uptime, without a token', async () => {
+ const before = fake.calls.length;
+ const out = await call(4, 'exchange_models', { query: 'deepseek' });
+ assert.ok(!out.result.isError, JSON.stringify(out));
+ const data = JSON.parse(out.result.content[0].text);
+ assert.strictEqual(data.models.length, 1);
+ const offer = data.models[0].offers[0];
+ assert.deepStrictEqual(offer.price, { in: 0.14, out: 0.42, cached: 0.014, per: '1M tokens' });
+ assert.deepStrictEqual(offer.speed, { p50_ms: 320, p95_ms: 900 });
+ assert.strictEqual(offer.uptime, 0.997);
+ assert.strictEqual(fake.calls.length, before + 1);
+ assert.strictEqual(fake.calls[before].headers.authorization, `Bearer ${TOKEN}`, 'the token rides along when there is one');
+ const anonCatalog = await createExchangeMcp({ token: '', baseUrl: fake.url }).handle({ jsonrpc: '2.0', id: 41, method: 'tools/call', params: { name: 'exchange_models', arguments: {} } });
+ assert.ok(!anonCatalog.result.isError, 'the public catalog answers without a token too');
+});
+
+t.test('chat: the person\'s allowance, or their own stored provider key, with their token', async () => {
+ const before = fake.calls.length;
+ const out = await call(5, 'exchange_chat', { model: 'openvibe/auto', messages: [{ role: 'user', content: 'hi' }], max_tokens: 32, credential: 'deepseek' });
+ assert.ok(!out.result.isError, JSON.stringify(out));
+ const answer = JSON.parse(out.result.content[0].text);
+ assert.strictEqual(answer.choices[0].message.content, 'Hello from the Exchange');
+ assert.strictEqual(answer.x_openvibe.run_id, 'run_1');
+ const sent = fake.calls[before];
+ assert.strictEqual(sent.url, '/v1/chat/completions');
+ assert.strictEqual(sent.method, 'POST');
+ assert.strictEqual(sent.headers.authorization, `Bearer ${TOKEN}`);
+ assert.strictEqual(sent.headers['x-openvibe-credential'], 'deepseek');
+ assert.deepStrictEqual(sent.body, { model: 'openvibe/auto', messages: [{ role: 'user', content: 'hi' }], stream: false, max_tokens: 32 });
+});
+
+t.test('usage: the person\'s own rows', async () => {
+ const out = await call(6, 'exchange_usage', { days: 7 });
+ assert.ok(!out.result.isError, JSON.stringify(out));
+ const data = JSON.parse(out.result.content[0].text);
+ assert.strictEqual(data.requester, `user:${PERSON}`);
+ assert.strictEqual(data.usage[0].requests, 2);
+ const sent = fake.calls[fake.calls.length - 1];
+ assert.match(sent.url, /^\/api\/v1\/exchange\/me\/usage\?days=7$/);
+ assert.strictEqual(sent.headers.authorization, `Bearer ${TOKEN}`);
+});
+
+t.test('auth refusal: no token is refused before any request; a 401 is refused back', async () => {
+ const anon = createExchangeMcp({ token: '', baseUrl: fake.url });
+ const before = fake.calls.length;
+ const noToken = await anon.handle({ jsonrpc: '2.0', id: 7, method: 'tools/call', params: { name: 'exchange_chat', arguments: { model: 'openvibe', messages: [{ role: 'user', content: 'hi' }] } } });
+ assert.strictEqual(noToken.result.isError, true);
+ assert.match(noToken.result.content[0].text, /not signed in/);
+ assert.strictEqual(fake.calls.length, before, 'nothing was attempted');
+
+ const wrong = createExchangeMcp({ token: 'ovk_wrong', baseUrl: fake.url });
+ const refused = await wrong.handle({ jsonrpc: '2.0', id: 8, method: 'tools/call', params: { name: 'exchange_chat', arguments: { model: 'openvibe', messages: [{ role: 'user', content: 'hi' }] } } });
+ assert.strictEqual(refused.result.isError, true);
+ assert.match(refused.result.content[0].text, /refused the token \(401\)/);
+ assert.ok(!refused.result.content[0].text.includes('ovk_wrong'), 'the token is never echoed');
+});
+
+t.test('an invalid tool argument is refused, not sent', async () => {
+ const before = fake.calls.length;
+ const out = await call(9, 'exchange_chat', { model: 'openvibe', messages: [] });
+ assert.strictEqual(out.result.isError, true);
+ assert.match(out.result.content[0].text, /messages/);
+ assert.strictEqual(fake.calls.length, before);
+});
+
+t.test('stop', async () => { await fake.close(); });
+
+t.run();
new file mode 100644
new file mode 100644
@@ -0,0 +1,232 @@
@@ -0,0 +1,232 @@
+'use strict';
+// Exchange webhooks (the design's "For builders", item 7): a signed delivery, a retry with a backoff, a delivery log,
+// a refused private receiver, and the three events wired into the run and the seller API. The signature is checked
+// with openvibe-sdk/exchange's own verifier -- the header a receiver will use.
+const assert = require('assert');
+const { boot, request, token, suite, ALL } = require('./helpers');
+const { testDb } = require('./db');
+const { createWebhooks, judgeUrl } = require('../server/exchange/webhooks');
+const { verifyWebhook } = require('openvibe-sdk/exchange');
+
+const t = suite('exchange-webhooks');
+const OWNER = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A1';
+const silent = { log() {}, warn() {}, error() {} };
+const cfg = { webhooks: { enabled: true, baseDelayMs: 1000, maxDelayMs: 60000, maxAttempts: 3, timeoutMs: 1000, tickMs: 250 } };
+
+function recorder(responses = []) {
+ const calls = [];
+ const transport = async (send) => {
+ calls.push(send);
+ const next = responses.shift();
+ if (next instanceof Error) throw next;
+ return next || { status: 200, headers: {}, body: 'ok' };
+ };
+ return { calls, transport };
+}
+
+let store;
+let ms = Date.parse('2026-11-07T12:00:00Z');
+const clock = { now: () => ms };
+
+t.test('a receiver URL goes through the egress guard, at registration', () => {
+ const bad = [
+ ['http://hooks.example.com/x', /https/],
+ ['https://127.0.0.1/x', /IP address/],
+ ['https://169.254.169.254/latest', /IP address/],
+ ['https://[::1]/x', /IP address/],
+ ['https://localhost/x', /public host name/],
+ ['https://hooks.example.com:8080/x', /port 443 or 8443/],
+ ['https://hooks.example.com/x?token=1', /query or fragment/],
+ ];
+ for (const [url, re] of bad) {
+ assert.throws(() => judgeUrl(url), (err) => err.code === 'exchange.webhook_url' && re.test(err.detail), url);
+ }
+ assert.strictEqual(judgeUrl('https://hooks.example.com/exchange').toString(), 'https://hooks.example.com/exchange');
+});
+
+t.test('a subscription: the secret is shown once, the list never carries it, removal is the owner\'s', async () => {
+ const dbh = await testDb();
+ store = createWebhooks({ db: dbh.db, config: cfg, clock, log: silent, transport: recorder().transport });
+ const created = await store.create({ owner: OWNER, input: { url: 'https://hooks.example.com/exchange', events: ['usage'] } });
+ assert.match(created.id, /^whk_/);
+ assert.match(created.secret, /^whsec_/);
+ assert.deepStrictEqual(created.events, ['usage']);
+ const listed = await store.list(OWNER);
+ assert.strictEqual(listed.length, 1);
+ assert.ok(!('secret' in listed[0]), 'the list never carries the secret');
+ assert.deepStrictEqual(await store.deliveries({ id: created.id, owner: OWNER }), []);
+ await assert.rejects(() => store.remove({ id: created.id, owner: 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2B2' }), (err) => err.code === 'exchange.webhook_not_found');
+ assert.deepStrictEqual(await store.remove({ id: created.id, owner: OWNER }), { id: created.id, removed: true });
+ assert.deepStrictEqual(await store.list(OWNER), []);
+ await dbh.close();
+});
+
+t.test('signature round-trip with the SDK verifier, and a delivery log a developer can read', async () => {
+ const dbh = await testDb();
+ const rec = recorder();
+ store = createWebhooks({ db: dbh.db, config: cfg, clock, log: silent, transport: rec.transport });
+ const sub = await store.create({ owner: OWNER, input: { url: 'https://hooks.example.com/exchange', events: ['usage'] } });
+ const queued = await store.emit({ owner: OWNER, type: 'usage', source: 'run_1', data: { run_id: 'run_1', tokens: { input: 3, output: 2 } } });
+ assert.strictEqual(queued, 1);
+ assert.strictEqual(rec.calls.length, 0, 'the event is a row: nothing is sent before the worker runs');
+ const out = await store.deliverDue();
+ assert.deepStrictEqual(out, { sent: 1, delivered: 1, failed: 0 });
+ assert.strictEqual(rec.calls.length, 1);
+ const sent = rec.calls[0];
+ assert.strictEqual(sent.method, 'POST');
+ assert.strictEqual(sent.url, 'https://hooks.example.com/exchange');
+ assert.strictEqual(sent.headers['X-OpenVibe-Event'], 'usage');
+ assert.match(sent.headers['X-OpenVibe-Signature-V2'], /^t=\d+,v2=[0-9a-f]{64}$/);
+ assert.strictEqual(verifyWebhook(sent.body, sent.headers['X-OpenVibe-Signature-V2'], sub.secret, { now: ms }), true);
+ assert.strictEqual(verifyWebhook(`${sent.body} `, sent.headers['X-OpenVibe-Signature-V2'], sub.secret, { now: ms }), false, 'the raw bytes are signed');
+ assert.strictEqual(verifyWebhook(sent.body, sent.headers['X-OpenVibe-Signature-V2'], 'whsec_wrong', { now: ms }), false);
+ const payload = JSON.parse(sent.body);
+ assert.strictEqual(payload.type, 'usage');
+ assert.strictEqual(payload.data.run_id, 'run_1');
+ assert.ok(!sent.body.includes(sub.secret), 'the secret never rides in the body');
+ const log = await store.deliveries({ id: sub.id, owner: OWNER });
+ assert.strictEqual(log.length, 1);
+ assert.strictEqual(log[0].status, 'delivered');
+ assert.strictEqual(log[0].attempts, 1);
+ assert.strictEqual(log[0].last_status, 200);
+ assert.deepStrictEqual(log[0].payload, payload);
+ assert.strictEqual((await store.deliverDue()).sent, 0, 'a delivered row is not sent again');
+ await dbh.close();
+});
+
+t.test('a 503 is retried with a growing backoff; a 404 fails at once', async () => {
+ const dbh = await testDb();
+ const rec = recorder([{ status: 503, headers: {}, body: 'busy' }, { status: 200, headers: {}, body: 'ok' }, { status: 404, headers: {}, body: 'gone' }]);
+ store = createWebhooks({ db: dbh.db, config: { webhooks: { ...cfg.webhooks, maxAttempts: 5 } }, clock, log: silent, transport: rec.transport });
+ const sub = await store.create({ owner: OWNER, input: { url: 'https://hooks.example.com/exchange' } });
+ await store.emit({ owner: OWNER, type: 'usage', source: 'run_2', data: { run_id: 'run_2' } });
+ assert.deepStrictEqual(await store.deliverDue(), { sent: 1, delivered: 0, failed: 0 });
+ let log = (await store.deliveries({ id: sub.id, owner: OWNER }))[0];
+ assert.strictEqual(log.status, 'pending');
+ assert.strictEqual(log.attempts, 1);
+ assert.strictEqual(log.last_status, 503);
+ assert.strictEqual(Date.parse(log.next_attempt_at) - ms, 1000, 'the first retry waits baseDelayMs');
+ assert.strictEqual((await store.deliverDue()).sent, 0, 'not due yet');
+ ms += 1001;
+ assert.deepStrictEqual(await store.deliverDue(), { sent: 1, delivered: 1, failed: 0 });
+ log = (await store.deliveries({ id: sub.id, owner: OWNER }))[0];
+ assert.strictEqual(log.attempts, 2);
+ assert.strictEqual(log.status, 'delivered');
+
+ await store.emit({ owner: OWNER, type: 'earnings', source: 'run_3', data: { credits_micro: 5 } });
+ assert.deepStrictEqual(await store.deliverDue(), { sent: 1, delivered: 0, failed: 1 });
+ log = (await store.deliveries({ id: sub.id, owner: OWNER }))[0];
+ assert.strictEqual(log.event_type, 'earnings');
+ assert.strictEqual(log.status, 'failed');
+ assert.strictEqual(log.attempts, 1, 'a 404 is not retried');
+ await dbh.close();
+});
+
+t.test('a community answer queues earnings to the seller and the project that registered the offer', async () => {
+ const dbh = await testDb();
+ const rec = recorder();
+ const store2 = createWebhooks({ db: dbh.db, config: cfg, clock, log: silent, transport: rec.transport });
+ const PROJECT = 'prj_01J8Z3Q4R5S6T7V8W9X0Y1Z2P1';
+ await store2.create({ owner: OWNER, input: { url: 'https://hooks.example.com/owner' } });
+ await store2.create({ owner: PROJECT, input: { url: 'https://hooks.example.com/project' } });
+ const row = {
+ id: 'run_community', requester_type: 'user', requester_id: OWNER,
+ explain: JSON.stringify({ community: { kind: 'endpoint', machine_id: 'endpoint:ep_1', offer_id: 'endpoint:ep_1', project_id: PROJECT,
+ owner: OWNER, model: 'qwen3:32b', credits_micro: 500, earning_status: 'pending', self_served: false, kudos: 0, tokens_in: 12, tokens_out: 8 } }),
+ };
+ const record = { subject: OWNER, provider: 'community-endpoint:ep_1', model: 'qwen3:32b', tokens_input: 12, tokens_output: 8,
+ tokens_reasoning: 0, tokens_cache_read: 0, tokens_cache_write: 0, list_cost_usd: 0, charged_usd: 0, fee_usd: 0, cache_saved_usd: 0,
+ paid_by: 'network_allowance', latency_ms: 10, ttft_ms: 2, at: '2026-11-07T12:00:00Z' };
+ assert.strictEqual(await store2.usage({ row, record, outcome: 'succeeded' }), 3, 'usage to the owner, earnings to the owner and the project');
+ await store2.deliverDue();
+ const events = rec.calls.map((c) => ({ event: c.headers['X-OpenVibe-Event'], url: c.url, body: JSON.parse(c.body) }));
+ assert.strictEqual(events.filter((e) => e.event === 'usage').length, 1);
+ const earnings = events.filter((e) => e.event === 'earnings');
+ assert.strictEqual(earnings.length, 2);
+ assert.deepStrictEqual(earnings.map((e) => e.url).sort(), ['https://hooks.example.com/owner', 'https://hooks.example.com/project']);
+ assert.strictEqual(earnings[0].body.data.offer_id, 'endpoint:ep_1');
+ assert.strictEqual(earnings[0].body.data.credits_micro, 500);
+ assert.strictEqual(earnings[0].body.data.earning_status, 'pending');
+ await dbh.close();
+});
+
+// ── The wiring: a run and an offer removal each queue their event ─────────────────────────────
+let h;
+let rec;
+
+t.test('boot with a webhook store on a socket the tests drive', async () => {
+ rec = recorder();
+ h = await boot({ webhooks: (db) => createWebhooks({ db, config: cfg, log: silent, transport: rec.transport }) });
+});
+
+t.test('a completed run queues a signed usage event for the person', async () => {
+ const person = token('dana', ALL, { sub: OWNER, actorType: 'user' });
+ const created = await request(h.base, 'POST', '/api/v1/exchange/webhooks', { tok: person, body: { url: 'https://hooks.example.com/exchange', events: ['usage', 'offer.down'] } });
+ assert.strictEqual(created.status, 201, created.text);
+ const secret = created.body.webhook.secret;
+ const chat = await request(h.base, 'POST', '/v1/chat/completions', { tok: person, body: { model: 'openvibe', messages: [{ role: 'user', content: 'say hi' }] } });
+ assert.strictEqual(chat.status, 200, chat.text);
+ const out = await h.webhooks.deliverDue();
+ assert.ok(out.sent >= 1, 'the run queued a delivery');
+ const call = rec.calls.find((c) => c.headers['X-OpenVibe-Event'] === 'usage');
+ assert.ok(call, 'a usage delivery');
+ assert.strictEqual(verifyWebhook(call.body, call.headers['X-OpenVibe-Signature-V2'], secret), true);
+ const payload = JSON.parse(call.body);
+ assert.strictEqual(payload.data.run_id, chat.headers.get('x-openvibe-run-id'));
+ assert.strictEqual(payload.data.outcome, 'succeeded');
+ assert.ok(payload.data.tokens.output >= 0);
+ assert.ok(!payload.data.prompt && !payload.data.answer, 'never the prompt or the answer');
+
+ // The developer reads the same delivery, and the caller's own usage, with their token.
+ const log = await request(h.base, 'GET', `/api/v1/exchange/webhooks/${created.body.webhook.id}/deliveries`, { tok: person });
+ assert.strictEqual(log.status, 200, log.text);
+ assert.strictEqual(log.body.deliveries[0].event_type, 'usage');
+ assert.strictEqual(log.body.deliveries[0].status, 'delivered');
+ assert.ok(!log.text.includes(secret), 'the delivery log never carries the secret');
+ const mine = await request(h.base, 'GET', '/api/v1/exchange/me/usage?days=7', { tok: person });
+ assert.strictEqual(mine.status, 200, mine.text);
+ assert.strictEqual(mine.body.requester, `user:${OWNER}`);
+ assert.ok(Array.isArray(mine.body.usage));
+ const other = token('eli', ALL, { sub: 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2B2', actorType: 'user' });
+ const notMine = await request(h.base, 'GET', `/api/v1/exchange/webhooks/${created.body.webhook.id}/deliveries`, { tok: other });
+ assert.strictEqual(notMine.status, 404, notMine.text);
+});
+
+t.test('removing an endpoint offer queues offer.down', async () => {
+ const person = token('dana', ALL, { sub: OWNER, actorType: 'user' });
+ const created = await request(h.base, 'POST', '/api/v1/exchange/seller/offers', { tok: person, body: {
+ base_url: 'https://models.example/v1', models: ['qwen3:32b'],
+ price: { in_per_million: 10, out_per_million: 30 }, hours: { start: '00:00', end: '23:59', tz: 'UTC' },
+ concurrency: 2, audience: 'everyone',
+ } });
+ assert.strictEqual(created.status, 201, created.text);
+ const removed = await request(h.base, 'DELETE', `/api/v1/exchange/seller/offers/${created.body.offer.id}`, { tok: person });
+ assert.strictEqual(removed.status, 200, removed.text);
+ await h.webhooks.deliverDue();
+ const call = rec.calls.find((c) => c.headers['X-OpenVibe-Event'] === 'offer.down');
+ assert.ok(call, 'an offer.down delivery');
+ const payload = JSON.parse(call.body);
+ assert.strictEqual(payload.type, 'offer.down');
+ assert.strictEqual(payload.data.offer_id, `endpoint:${created.body.offer.id}`);
+ assert.deepStrictEqual(payload.data.models, ['qwen3:32b']);
+});
+
+t.test('a service token cannot register a webhook', async () => {
+ const service = token('live', ['ai.run.create', 'ai.app.run']);
+ const out = await request(h.base, 'POST', '/api/v1/exchange/webhooks', { tok: service, body: { url: 'https://hooks.example.com/x' } });
+ assert.strictEqual(out.status, 403, out.text);
+ assert.strictEqual(out.body.code, 'exchange.webhook_owner');
+});
+
+t.test('the private receiver is refused by the API', async () => {
+ const person = token('dana', ALL, { sub: OWNER, actorType: 'user' });
+ for (const url of ['https://127.0.0.1/hook', 'https://localhost/hook', 'http://hooks.example.com/hook']) {
+ const out = await request(h.base, 'POST', '/api/v1/exchange/webhooks', { tok: person, body: { url } });
+ assert.strictEqual(out.status, 422, out.text);
+ assert.strictEqual(out.body.code, 'exchange.webhook_url');
+ }
+});
+
+t.test('stop', async () => { await h.stop(); });
+
+t.run();
@@ -45,7 +45,7 @@ function tmpDir() {
@@ -45,7 +45,7 @@ function tmpDir() {
// A boot on a dir used before is a restart on the same database: its database stays open.
// A boot on a dir used before is a restart on the same database: its database stays open.
const restartable = new Map();
const restartable = new Map();
-async function boot({ env = {}, dir, clock, fetchImpl, credentialFetch, research, machineModels, log, variantEvidence, community, communityEndpoints } = {}) {
+async function boot({ env = {}, dir, clock, fetchImpl, credentialFetch, research, machineModels, log, variantEvidence, community, communityEndpoints, webhooks } = {}) {
const reuse = dir !== undefined;
const reuse = dir !== undefined;
if (dir === undefined) dir = tmpDir();
if (dir === undefined) dir = tmpDir();
const fullEnv = {
const fullEnv = {
@@ -61,7 +61,8 @@ async function boot({ env = {}, dir, clock, fetchImpl, credentialFetch, research
@@ -61,7 +61,8 @@ async function boot({ env = {}, dir, clock, fetchImpl, credentialFetch, research
const researcher = research || require('../server/research').createResearch({ config: config.research, env: {}, fetchImpl: require('./fixtures/research-fetch').createResearchFetch().fetchImpl, log: silent });
const researcher = research || require('../server/research').createResearch({ config: config.research, env: {}, fetchImpl: require('./fixtures/research-fetch').createResearchFetch().fetchImpl, log: silent });
// An injected endpoint store needs the migrated database, so a factory is called with it before the app boots.
// An injected endpoint store needs the migrated database, so a factory is called with it before the app boots.
const endpointStore = typeof communityEndpoints === 'function' ? communityEndpoints(testdb.db) : communityEndpoints;
const endpointStore = typeof communityEndpoints === 'function' ? communityEndpoints(testdb.db) : communityEndpoints;
- const h = await start({ config, db: testdb.db, env: fullEnv, log: log || silent, clock, fetchImpl, credentialFetch, research: researcher, machineModels, variantEvidence, community, communityEndpoints: endpointStore });
+ const webhookStore = typeof webhooks === 'function' ? webhooks(testdb.db) : webhooks;
+ const h = await start({ config, db: testdb.db, env: fullEnv, log: log || silent, clock, fetchImpl, credentialFetch, research: researcher, machineModels, variantEvidence, community, communityEndpoints: endpointStore, webhooks: webhookStore });
const base = `http://127.0.0.1:${h.server.address().port}`;
const base = `http://127.0.0.1:${h.server.address().port}`;
return { ...h, base, dir, env: fullEnv, testdb, stop: async () => { await h.close(); if (!reuse) await testdb.close(); } };
return { ...h, base, dir, env: fullEnv, testdb, stop: async () => { await h.close(); if (!reuse) await testdb.close(); } };
}
}