openvibers/openvibe.ai Commits

Exchange: a public seller API for community endpoints (#327)

OP OpenVibers committed 7 days ago · 8a584274ff · 16 files, +1128 −38

* Exchange: a public seller API for community endpoints

A standalone OpenAI-compatible server can sell on the Exchange, not only an
OpenVibe Node machine (the design's SDK gap: the Network offer document has no
slot for a server address, and there was no public registration).

- POST/GET/DELETE /api/v1/exchange/seller/offers on api.openvibe.exchange, with
  the seller's Network developer key; the owner is the person the key acts for (a
  developer app's project owner, read from Network). Documented in /openapi.json,
  and nginx allows the writes under that one prefix.
- server/community/endpoints.js reaches the address only through the egress guard:
  https, a host name (never an IP literal), the DNS answer checked inside the
  connection, redirects judged hop by hop, a timeout, a response cap, and a fresh
  request that carries no buyer identity in any header or body field.
- The endpoint joins the community router: the same canaries, the model
  fingerprint, ~1% spot re-runs, free tier only until verified, the seller's
  concurrency, and the self-dealing rule (including the project that registered
  it). Its answers are labelled community-endpoint:<id> / COMMUNITY_ENDPOINT, and
  the receipt says offer.community "endpoint".

* Merge remote-tracking branch 'origin/main' into exchange-endpoint-seller

# Conflicts:
#	server/app.js
#	server/index.js
#	server/openapi.js
#	server/runs.js

* Exchange: an endpoint offer is part of the person's account data

The account-data guard found the new table: a community endpoint is the
person's own capacity, so it goes in their export and is removed with the
account (the offer then stops receiving traffic too).

Changes

ddiff --git a/deploy/nginx/api.openvibe.exchange.conf b/deploy/nginx/api.openvibe.exchange.conf
iindex 3d38513..fe4cded 100644
---- a/deploy/nginx/api.openvibe.exchange.conf
++++ b/deploy/nginx/api.openvibe.exchange.conf
@@@ -82,6 +82,21 @@ server {
proxy_set_header CF-Connecting-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
}
++ # The seller API (design EXCHANGE-REMAKE-2026-11-06, the SDK gap): a standalone OpenAI-compatible server is
++ # registered, listed and removed with the seller's own developer key. Writes are allowed here and nowhere else
++ # under /api/v1/exchange/.
++ location ^~ /api/v1/exchange/seller {
++ limit_req zone=ovai_apps burst=10 nodelay;
++ limit_req_status 429;
++ limit_except GET POST DELETE { deny all; }
++ proxy_pass http://127.0.0.1:4700;
++ proxy_http_version 1.1;
++ proxy_set_header Host $host;
++ proxy_set_header X-Real-IP $remote_addr;
++ proxy_set_header X-Forwarded-For $remote_addr;
++ proxy_set_header CF-Connecting-IP $remote_addr;
++ proxy_set_header X-Forwarded-Proto $scheme;
++ }
location = /openapi.json {
limit_except GET { deny all; }
proxy_pass http://127.0.0.1:4700;
ddiff --git a/docs/community-offers.md b/docs/community-offers.md
iindex 23d4842..1439803 100644
---- a/docs/community-offers.md
++++ b/docs/community-offers.md
@@@ -21,6 +21,9 @@ capacity per request — because the machine sees the prompt.
hours, a credit price and an audience.
4. **AI** (`server/community/`) reads `GET /internal/ai/offers`, verifies the machine, and places only opted-in requests.
++A second source needs no Node at all: a standalone OpenAI-compatible server is registered by its own address on the
++public seller API (see **Community endpoints** below) and joins the same list, verification and ledger.
++
## Opting in
`POST /v1/chat/completions` / `POST /v1/messages` (and the direct runs API) take either header
@@@ -31,6 +34,49 @@ The answer carries `x_openvibe.community` (machine, owner, model, price, verific
response header `X-OpenVibe-Community: 1`. A failure on the community machine falls back to the ordinary network, and
that answer carries no community label.
++## Community endpoints
++
++The design's SDK gap: any server that speaks the OpenAI Chat Completions API can sell, not only a Node machine. The
++seller registers it with their own Network developer key on the Exchange's public API host, documented in
++`/openapi.json`:
++
++```
++POST /api/v1/exchange/seller/offers ai.app.run (a developer key), or a person's own token → 201 { offer }
++GET /api/v1/exchange/seller/offers the caller's own offers, with their verification
++DELETE /api/v1/exchange/seller/offers/:id remove one (its verification and earnings stay as history)
++```
++
++The body: `base_url` (https), `models` (1–40), `price` (`in_per_million`/`out_per_million` in credits, 0 volunteers),
++`hours` (`start`, `end`, `tz`), `concurrency` (1–8), `audience` (`everyone` | `followers` | `group`), `group`, `name`.
++The owner is never in the body: it is the person the key acts for. A developer app's owner is its **project owner**,
++read from Network (`GET /internal/projects/:project_id`, `network.project.read`, five-minute cache); a first-party
++service token is refused.
++
++**Reaching it.** AI calls `POST <base_url>/chat/completions` only through `server/community/endpoints.js`:
++
++- https only, no credentials in the URL, port 443 or 8443;
++- never an IP literal (`127.0.0.1`, `10.0.0.0/8`, `169.254.169.254`, `[::1]`, and the IPv6 forms that wrap one) and never
++ a name that can only be local (`.local`, `.internal`, `localhost`);
++- the DNS answer is judged inside the connection's own lookup (`openvibe-shared/egress`), so the socket connects to the
++ checked address and a second answer cannot swap it; a name answering a private address is refused;
++- redirects are followed by hand, at most two, 307/308 only, and every hop is judged again — a redirect to a private
++ address is refused;
++- a timeout (`AI_COMMUNITY_ENDPOINT_TIMEOUT_MS`) and a response byte cap (`AI_COMMUNITY_ENDPOINT_MAX_BYTES`) on every
++ call; a non-200, non-JSON or non-completion answer is a failure, and the router falls back;
++- the request is built fresh: `model`, `messages`, `max_tokens`, `temperature`, `tools`, `stream: false`. Never the
++ buyer's subject, `on_behalf_of`, run id, key, cookies or forwarding headers (`X-OV-*`, `X-Forwarded-*`). The buyer's
++ own `user` field is not passed through either. No bearer token is sent: the endpoint must accept the POST from
++ OpenVibe as it is (put it behind an unguessable base path if you want it hard to find);
++- the seller's declared `concurrency` is enforced in-process: over it the call is busy and the router falls back.
++
++**Same trust as a Node machine.** The endpoint joins `server/community/index.js`'s offer list, so it gets the same three
++canaries, the same model fingerprint (the `model` its answer reports must be the declared one), the same ~1% spot
++re-runs against a verified peer, the free tier only until verified, and the self-dealing rule (the owner, a merged
++account, or the project that registered it earns 0). Its answers carry `provider: community-endpoint:<id>`,
++`provider_access: COMMUNITY_ENDPOINT`, `community.kind: "endpoint"` and the privacy label `community`, so a receipt says
++plainly a community endpoint answered. It sees the prompt: only requests that opt in to community capacity reach it.
++An endpoint's uptime is observed from its own calls, not from a reporter.
++
## Trust before traffic
- **Canaries**: three known-answer prompts. A wrong answer marks the offer `failed`, reputation 0, and it is never
@@@ -71,7 +117,11 @@ list (models, price, hours, health; never an owner subject or machine id).
- `AI_COMMUNITY` (default on): with no `OV_OAUTH_CLIENT_SECRET` or `OV_BOT_INTERNAL_URL` the list is empty.
- `AI_COMMUNITY_REFRESH_MS` (30000), `AI_COMMUNITY_CANARY_MAX_AGE_MS` (6h), `AI_COMMUNITY_CANARY_TIMEOUT_MS` (30000),
`AI_COMMUNITY_SPOT_RATE` (0.01).
--- Network grants AI `network.registry.read` and `network.follows.read` (the `followers` audience).
++- `AI_COMMUNITY_ENDPOINTS` (default on), `AI_COMMUNITY_ENDPOINT_TIMEOUT_MS` (60000),
++ `AI_COMMUNITY_ENDPOINT_MAX_BYTES` (2 MiB), `AI_COMMUNITY_ENDPOINT_MAX_REDIRECTS` (2),
++ `AI_COMMUNITY_ENDPOINT_MAX_PER_OWNER` (20).
++- Network grants AI `network.registry.read`, `network.follows.read` (the `followers` audience) and
++ `network.project.read` (a developer app's owner for the seller API).
## Tests
@@@ -79,5 +129,9 @@ list (models, price, hours, health; never an owner subject or machine id).
- `node test/community-run.test.js` — the run path: no opt-in reaches nothing; with it the answer is labelled; a failed
machine falls back to the network.
- `node test/community-api.test.js` — the dashboard route and the public offers route.
++- `node test/community-endpoints.test.js` — the egress guard (IP literals, a private DNS answer, a redirect to one, no
++ buyer identity, the timeout/concurrency), canaries, fingerprint, free tier and self-dealing for an endpoint.
++- `node test/community-endpoint-api.test.js` — the seller API: the owner from the developer key, refusals, list and
++ remove.
- Network: `node test/run.js ai-offers`; Bot: `node test/community-ai.test.js`; Node: `python3 -m unittest discover -s
plugins/ai/tests`.
ddiff --git a/migrations/0035_endpoint_offers.sql b/migrations/0035_endpoint_offers.sql
nnew file mode 100644
iindex 0000000..30ffc2a
---- /dev/null
++++ b/migrations/0035_endpoint_offers.sql
@@@ -0,0 +1,27 @@
++-- phase: expand
++-- Community endpoint offers (design EXCHANGE-REMAKE-2026-11-06, "Gap found while building the SDK"). A seller
++-- registers a standalone OpenAI-compatible server by its own https address, with their Network developer key, on the
++-- public seller API. Only AI's own egress guard reaches the address (https, a public DNS answer, never an IP literal,
++-- timeouts, a response cap, and never the buyer's identity in a header). The verification state, spot re-runs, the
++-- reputation and the seller ledger live in community_offers/community_usage: an endpoint is verified exactly as a
++-- Node machine is (canaries, the model fingerprint, about 1% spot re-runs, free tier only until verified,
++-- self-dealing earns 0). Nothing here stores a prompt, an answer or a buyer.
++CREATE TABLE IF NOT EXISTS community_endpoint_offers (
++ id text COLLATE "C" PRIMARY KEY, -- ep_<ULID>
++ owner_subject text COLLATE "C" NOT NULL CHECK (owner_subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),
++ project_id text COLLATE "C", -- the developer app's project (a person's own token has none)
++ app_id text COLLATE "C", -- the app that registered it (null for a person)
++ created_by text COLLATE "C" NOT NULL, -- app_…, or the person's token sub
++ name text COLLATE "C",
++ base_url text COLLATE "C" NOT NULL, -- https origin and path, no trailing slash, never an address
++ models jsonb NOT NULL,
++ hours jsonb NOT NULL,
++ concurrency integer NOT NULL CHECK (concurrency BETWEEN 1 AND 8),
++ price_in numeric(20, 6) NOT NULL CHECK (price_in >= 0),
++ price_out numeric(20, 6) NOT NULL CHECK (price_out >= 0),
++ audience text COLLATE "C" NOT NULL CHECK (audience IN ('everyone', 'followers', 'group')),
++ group_id text COLLATE "C",
++ created_at timestamptz NOT NULL,
++ updated_at timestamptz NOT NULL
++);
++CREATE INDEX IF NOT EXISTS community_endpoint_offers_owner ON community_endpoint_offers (owner_subject, created_at DESC);
ddiff --git a/server/account-data.js b/server/account-data.js
iindex 455edf8..3a61ca9 100644
---- a/server/account-data.js
++++ b/server/account-data.js
@@@ -54,6 +54,10 @@ const TABLES = [
// counter; listing them here is what makes the guard (and the export count) know they exist.
{ table: 'abuse_daily_usage', subject: 'subject', value: (usr) => `user:${usr}`, file: null, erase: { keep: 'erased by extraErase after the device rows are read' } },
{ table: 'abuse_devices', subject: 'subject', file: null, erase: { keep: 'erased by extraErase after the shared counter is found' } },
++ // A community endpoint (the design's SDK gap) is the person's own capacity: it goes in their export and is
++ // removed when the account goes, so it stops receiving traffic too.
++ { table: 'community_endpoint_offers', subject: 'owner_subject', file: 'community-endpoint-offers.json', order: 'created_at',
++ columns: ['id', 'name', 'base_url', 'models', 'hours', 'concurrency', 'price_in', 'price_out', 'audience', 'group_id', 'created_at', 'updated_at'] },
// The person's own quota (server/quota.js): a limit that only means something while the account lives.
{ table: 'quotas', subject: 'scope_id', value: (usr) => `user:${usr}`, file: null },
// What the person used, counted against their allowance: the spend ledger, kept.
ddiff --git a/server/api/seller.js b/server/api/seller.js
nnew file mode 100644
iindex 0000000..4f4e278
---- /dev/null
++++ b/server/api/seller.js
@@@ -0,0 +1,69 @@
++'use strict';
++/**
++ * The public seller API (design EXCHANGE-REMAKE-2026-11-06, "Gap found while building the SDK"): a standalone
++ * OpenAI-compatible server can sell to the Exchange with the seller's own Network developer key, not only an
++ * OpenVibe Node machine. Served on the Exchange's public API host (api.openvibe.exchange) and documented in
++ * /openapi.json.
++ *
++ * POST /api/v1/exchange/seller/offers ai.app.run (a developer key), or a person's own token
++ * body: base_url, models, price, hours, concurrency, audience, group
++ * → 201 { offer }
++ * GET /api/v1/exchange/seller/offers the caller's own offers, with their verification
++ * DELETE /api/v1/exchange/seller/offers/:id remove one (its verification and earnings stay as history)
++ *
++ * The owner is the person the key acts for: a person's own subject, or a developer app's project owner (read from
++ * Network with network.project.read). A first-party service token is refused. The key is never a buyer credential:
++ * AI reaches the endpoint only through server/community/endpoints.js's egress guard.
++ */
++const express = require('express');
++const { CAPS } = require('../auth');
++const { sendError } = require('../util');
++
++function sellerRouter({ endpoints, community = 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 : {});
++ 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); }
++ };
++
++ r.post('/api/v1/exchange/seller/offers', allow, wrap(async (req, res) => {
++ const who = await endpoints.ownerFor(req.principal);
++ const offer = await endpoints.create({ owner: who.owner, projectId: who.projectId, appId: who.appId, createdBy: req.principal.sub, input: body(req) });
++ res.status(201).set('Cache-Control', 'no-store');
++ return { offer };
++ }));
++
++ r.get('/api/v1/exchange/seller/offers', allow, wrap(async (req, res) => {
++ const who = await endpoints.ownerFor(req.principal);
++ res.set('Cache-Control', 'no-store');
++ const offers = await endpoints.list(who.owner);
++ // The verification is the same the /sell dashboard shows: canaries, spot re-runs, reputation and free-only.
++ let dashboard = null;
++ if (community && typeof community.sell === 'function') {
++ try { dashboard = await community.sell(who.owner); } catch (err) { log.warn(`[ai] seller dashboard ${who.owner}: ${err.message}`); }
++ }
++ const byOffer = new Map(((dashboard && dashboard.offers) || []).map((o) => [o.offer_id, o]));
++ return {
++ owner: who.owner,
++ offers: offers.map((o) => {
++ const state = byOffer.get(o.offer_id);
++ return { ...o, verification: state ? state.verification : null, reputation: state ? state.reputation : 0,
++ free_only: state ? state.free_only !== false : true, verified: state ? state.verified === true : false };
++ }),
++ };
++ }));
++
++ 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 });
++ }));
++
++ return r;
++}
++
++module.exports = { sellerRouter };
ddiff --git a/server/app.js b/server/app.js
iindex 47b67e9..7557b9a 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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, presets = null, receipts = null, balance = null, gpus = null, abuse = 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, presets = null, receipts = null, balance = null, gpus = null, abuse = 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') });
@@@ -117,6 +117,9 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
// 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 }));
++ // 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 }));
// GET /release.json (ADR-016) and POST /release-metrics (open tabs' update reports into /metrics).
release.mount(app, { registry: metrics.registry });
ddiff --git a/server/community/endpoints.js b/server/community/endpoints.js
nnew file mode 100644
iindex 0000000..ab22224
---- /dev/null
++++ b/server/community/endpoints.js
@@@ -0,0 +1,376 @@
++'use strict';
++/**
++ * Community endpoints (design EXCHANGE-REMAKE-2026-11-06, "Gap found while building the SDK"): a standalone
++ * OpenAI-compatible server can sell to the Exchange, not only an OpenVibe Node machine. A seller registers its own
++ * https address with their Network developer key on the public seller API (server/api/seller.js); AI stores the
++ * offer here, reads it beside Network's Node offers, and reaches it through this module only.
++ *
++ * The egress guard, on every registration, every dispatch and every redirect hop:
++ * - https only, no credentials in the URL, no port but 443 or 8443;
++ * - never an IP literal, and never a name that can only mean this machine or a private network;
++ * - the DNS answer is judged inside the connection's own lookup (openvibe-shared/egress), so the socket connects
++ * to the checked address and a second answer cannot swap it (no rebinding); an address that is loopback,
++ * RFC 1918, link-local (169.254.169.254), CGNAT, documentation, benchmarking, multicast, or an IPv6 form that
++ * wraps one of those is refused;
++ * - redirects are followed by hand, at most two, by 307/308 only, and every hop is judged again;
++ * - a timeout and a response byte cap on every call;
++ * - the request is built fresh: the model, the conversation, the output limit and the tools. Never the buyer's
++ * subject, on_behalf_of, run id, key, cookies or forwarding headers.
++ *
++ * Trust is the Node machine's (server/community/index.js): the same canaries, the same model fingerprint, the same
++ * ~1% spot re-runs, the free tier only until verified, and the self-dealing rule. An endpoint is a community source:
++ * it sees the prompt, and only requests that opt in ever reach it.
++ */
++const net = require('net');
++const https = require('https');
++const { ids } = require('openvibe-contracts');
++const egress = require('openvibe-shared/egress');
++const { createServiceTokenClient } = require('openvibe-sdk/auth');
++const { AiError, parseJson } = require('../util');
++
++const USR = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++const PROJECT = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
++const ID = /^ep_[0-9A-HJKMNP-TV-Z]{26}$/;
++const HHMM = /^([01][0-9]|2[0-3]):[0-5][0-9]$/;
++const AUDIENCES = new Set(['everyone', 'followers', 'group']);
++const MAX_MODELS = 40;
++const MAX_MODEL = 200;
++const MAX_PRICE = 1000000;
++const MAX_MESSAGES = 200;
++const MAX_CONTENT = 65536;
++const MAX_BYTES = 2 * 1024 * 1024;
++const ALLOWED_PORTS = new Set(['', '443', '8443']);
++
++const isPrice = (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= MAX_PRICE;
++const roundPrice = (v) => Math.round(Number(v) * 1e6) / 1e6;
++
++/**
++ * The one URL rule. Returns a URL whose pathname has no trailing slash (an OpenAI base URL such as
++ * https://host/v1), or throws AiError 422 `community.endpoint_url` with a message that never echoes the host.
++ */
++function judgeBaseUrl(raw) {
++ let u;
++ try { u = new URL(String(raw == null ? '' : raw)); } catch { throw new AiError(422, 'community.endpoint_url', 'base_url must be an https URL'); }
++ if (u.protocol !== 'https:') throw new AiError(422, 'community.endpoint_url', 'base_url must be https');
++ if (u.username || u.password) throw new AiError(422, 'community.endpoint_url', 'credentials in base_url are not allowed');
++ if (u.search || u.hash) throw new AiError(422, 'community.endpoint_url', 'base_url carries no query or fragment');
++ if (!ALLOWED_PORTS.has(u.port)) throw new AiError(422, 'community.endpoint_url', 'base_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 (Actor #297).
++ if (net.isIP(host)) throw new AiError(422, 'community.endpoint_url', 'base_url must name a host, not an IP address');
++ if (egress.isInternalName(host)) throw new AiError(422, 'community.endpoint_url', 'base_url must be a public host name');
++ u.hostname = host;
++ u.pathname = u.pathname.replace(/\/+$/, '') || '';
++ return u;
++}
++
++/** A conversation as the OpenAI Chat Completions body takes it: text only, the caps the machine path uses. */
++const textOf = (c) => (typeof c === 'string' ? c : Array.isArray(c) ? c.filter((p) => p && p.type === 'text').map((p) => String(p.text || '')).join('\n') : '').slice(0, MAX_CONTENT);
++
++/** Validate a registration body. → the stored fields; throws AiError 422 `community.endpoint_invalid`. */
++function checkInput(input) {
++ const b = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
++ const allowed = new Set(['base_url', 'models', 'price', 'hours', 'concurrency', 'audience', 'group', 'name']);
++ for (const k of Object.keys(b)) if (!allowed.has(k)) throw new AiError(422, 'community.endpoint_invalid', `unknown field ${k}`);
++ const url = judgeBaseUrl(b.base_url);
++ if (!Array.isArray(b.models) || b.models.length < 1 || b.models.length > MAX_MODELS) {
++ throw new AiError(422, 'community.endpoint_invalid', `models must be an array of 1 to ${MAX_MODELS} model ids`);
++ }
++ const models = [...new Set(b.models.map((m) => String(m == null ? '' : m).trim()).filter(Boolean))];
++ if (models.length !== b.models.length || models.some((m) => m.length > MAX_MODEL)) {
++ throw new AiError(422, 'community.endpoint_invalid', `models must be unique strings of at most ${MAX_MODEL} characters`);
++ }
++ const h = b.hours && typeof b.hours === 'object' && !Array.isArray(b.hours) ? b.hours : {};
++ if (!HHMM.test(String(h.start)) || !HHMM.test(String(h.end))) throw new AiError(422, 'community.endpoint_invalid', 'hours.start and hours.end are HH:MM');
++ const tz = h.tz == null || h.tz === '' ? 'UTC' : String(h.tz);
++ if (tz.length > 64) throw new AiError(422, 'community.endpoint_invalid', 'hours.tz is at most 64 characters');
++ try { new Intl.DateTimeFormat('en-GB', { timeZone: tz }); } catch { throw new AiError(422, 'community.endpoint_invalid', 'hours.tz is not a known time zone'); }
++ if (!Number.isInteger(b.concurrency) || b.concurrency < 1 || b.concurrency > 8) {
++ throw new AiError(422, 'community.endpoint_invalid', 'concurrency must be an integer from 1 to 8');
++ }
++ let price;
++ if (typeof b.price === 'number') price = { in_per_million: b.price, out_per_million: b.price };
++ else if (b.price && typeof b.price === 'object' && !Array.isArray(b.price)) {
++ const pin = b.price.in_per_million != null ? b.price.in_per_million : b.price.in;
++ const pout = b.price.out_per_million != null ? b.price.out_per_million : b.price.out;
++ price = { in_per_million: pin, out_per_million: pout };
++ } else throw new AiError(422, 'community.endpoint_invalid', 'price is a number or {in_per_million, out_per_million}');
++ if (!isPrice(price.in_per_million) || !isPrice(price.out_per_million)) {
++ throw new AiError(422, 'community.endpoint_invalid', `price must be 0 to ${MAX_PRICE} credits per million tokens`);
++ }
++ const audience = b.audience == null ? 'everyone' : String(b.audience);
++ if (!AUDIENCES.has(audience)) throw new AiError(422, 'community.endpoint_invalid', 'audience must be everyone, followers or group');
++ let group = null;
++ if (audience === 'group') {
++ group = String(b.group == null ? '' : b.group).trim();
++ if (!group || group.length > 80) throw new AiError(422, 'community.endpoint_invalid', 'audience group needs a group id of 1 to 80 characters');
++ }
++ const name = b.name == null ? null : String(b.name).trim();
++ if (name && name.length > 100) throw new AiError(422, 'community.endpoint_invalid', 'name is at most 100 characters');
++ return {
++ base_url: url.toString().replace(/\/+$/, ''), models,
++ hours: { start: String(h.start), end: String(h.end), tz },
++ concurrency: b.concurrency, price: { in_per_million: roundPrice(price.in_per_million), out_per_million: roundPrice(price.out_per_million) },
++ audience, group, name: name || null,
++ };
++}
++
++/** A jsonb column as its value: the driver may hand back the parsed object or its text. */
++const jsonOf = (v, fallback) => (v && typeof v === 'object' ? v : parseJson(v, fallback));
++
++/** The seller's own view of an offer (never a token: this API has none). */
++function viewOf(row) {
++ if (!row) return null;
++ const models = jsonOf(row.models, []);
++ const hours = jsonOf(row.hours, {});
++ return {
++ id: row.id, offer_id: `endpoint:${row.id}`, name: row.name || null, base_url: row.base_url,
++ models: Array.isArray(models) ? models : [], hours: hours && typeof hours === 'object' ? hours : {},
++ concurrency: Number(row.concurrency),
++ price: { in_per_million: roundPrice(row.price_in), out_per_million: roundPrice(row.price_out) },
++ audience: row.audience, group: row.group_id || null,
++ created_at: row.created_at, updated_at: row.updated_at,
++ };
++}
++
++/** The offer the community router places on: the shape a Network doc normalizes to, plus its kind and address. */
++function offerOf(row) {
++ const view = viewOf(row);
++ return {
++ offer_id: view.offer_id, machine_id: `endpoint:${row.id}`, owner: row.owner_subject, kind: 'endpoint',
++ endpoint: { id: row.id, base_url: row.base_url }, projectId: row.project_id || null,
++ models: view.models, hours: view.hours, concurrency: view.concurrency, price: view.price,
++ audience: view.audience, group: view.group, ready: true, health: 'up', name: view.name,
++ };
++}
++
++function createEndpointOffers({ db, config, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, tokens = null, lookup = null, transport = null }) {
++ const cfg = (config && config.community && config.community.endpoints) || {};
++ const network = String((config && config.networkInternalUrl) || '').replace(/\/+$/, '');
++ const secret = env.OV_OAUTH_CLIENT_SECRET || '';
++ const clientId = (config && config.console && config.console.clientId) || env.OV_OAUTH_CLIENT_ID || 'ai';
++ let projectTokens = tokens;
++ if (!projectTokens && secret && network) {
++ projectTokens = createServiceTokenClient({ tokenUrl: `${network}/oauth/token`, clientId, clientSecret: secret, scope: { 'openvibe.network': 'network.project.read' }, fetch: fetchImpl });
++ }
++ const maxPerOwner = Math.max(1, Math.min(100, Number(cfg.maxPerOwner) || 20));
++ const timeoutMs = Math.max(5000, Math.min(120000, Number(cfg.timeoutMs) || 60000));
++ const maxBytes = Math.max(65536, Math.min(16 * 1024 * 1024, Number(cfg.maxBytes) || MAX_BYTES));
++ const maxRedirects = Math.max(0, Math.min(3, cfg.maxRedirects == null ? 2 : Number(cfg.maxRedirects)));
++ const safeLookup = egress.createSafeLookup({ ...(lookup ? { lookup } : {}) });
++
++ const active = new Map(); // offer id -> calls in flight
++ const owners = new Map(); // project id -> { at, owner } (5 minutes)
++ const nowIso = () => new Date(clock.now()).toISOString();
++
++ /** The person a seller acts for. A person's own token is themselves; an app's project owner comes from Network. */
++ async function ownerFor(principal) {
++ if (!principal) throw new AiError(401, 'token.missing', 'a seller is a person or a developer app');
++ if (principal.kind === 'user') {
++ const id = principal.subject && principal.subject.id;
++ if (!USR.test(String(id))) throw new AiError(422, 'community.endpoint_owner', 'the person has no subject');
++ return { owner: String(id), projectId: null, appId: null };
++ }
++ if (principal.kind === 'app') {
++ const projectId = String(principal.projectId || '');
++ if (!PROJECT.test(projectId)) throw new AiError(422, 'community.endpoint_owner', 'the app has no project');
++ const hit = owners.get(projectId);
++ if (hit && clock.now() - hit.at < 300000 && hit.owner) return { owner: hit.owner, projectId, appId: principal.appId || null };
++ if (!projectTokens || !network) throw new AiError(503, 'community.unavailable', 'the seller API cannot read project owners here');
++ let res;
++ try {
++ const token = await projectTokens.getToken({ audience: 'openvibe.network' });
++ res = await fetchImpl(`${network}/internal/projects/${encodeURIComponent(projectId)}`, {
++ headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, signal: AbortSignal.timeout(8000),
++ });
++ } catch (err) {
++ throw new AiError(503, 'community.unavailable', `Network did not answer for the project: ${String(err && err.message || err).slice(0, 200)}`);
++ }
++ if (res.status === 404) throw new AiError(403, 'community.endpoint_owner', 'the developer app has no project');
++ if (!res.ok) throw new AiError(503, 'community.unavailable', `Network answered ${res.status} for the project`);
++ const body = await res.json().catch(() => null);
++ const owner = body && body.project && body.project.owner && body.project.owner.id;
++ if (!USR.test(String(owner))) throw new AiError(503, 'community.unavailable', 'the project has no owner');
++ owners.set(projectId, { at: clock.now(), owner: String(owner) });
++ return { owner: String(owner), projectId, appId: principal.appId || null };
++ }
++ throw new AiError(403, 'community.endpoint_owner', 'a seller is a person or a developer app, not a service');
++ }
++
++ const rows = () => db.prepare('SELECT * FROM community_endpoint_offers ORDER BY created_at DESC');
++
++ /** Every registered endpoint as a community offer, for the router's offer list. */
++ async function liveOffers() {
++ if (!db) return [];
++ const list = await rows().all();
++ return list.map(offerOf);
++ }
++
++ async function list(owner) {
++ if (!USR.test(String(owner))) throw new AiError(422, 'community.endpoint_owner', 'owner must be a usr_ subject');
++ return (await rows().all()).filter((r) => r.owner_subject === owner).map(viewOf);
++ }
++
++ async function create({ owner, projectId = null, appId = null, createdBy = null, input }) {
++ if (!USR.test(String(owner))) throw new AiError(422, 'community.endpoint_owner', 'owner must be a usr_ subject');
++ const checked = checkInput(input);
++ const existing = (await rows().all()).filter((r) => r.owner_subject === owner);
++ if (existing.length >= maxPerOwner) throw new AiError(409, 'community.endpoint_limit', `at most ${maxPerOwner} endpoint offers per seller`);
++ const at = nowIso();
++ const row = {
++ id: `ep_${ids.ulid()}`, owner, projectId, appId, createdBy: createdBy || (appId || owner), checked, at,
++ };
++ await db.prepare(`INSERT INTO community_endpoint_offers (id, owner_subject, project_id, app_id, created_by, name, base_url, models, hours,
++ concurrency, price_in, price_out, audience, group_id, created_at, updated_at)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
++ .run(row.id, owner, projectId, appId, row.createdBy, checked.name, checked.base_url, JSON.stringify(checked.models), JSON.stringify(checked.hours),
++ checked.concurrency, checked.price.in_per_million, checked.price.out_per_million, checked.audience, checked.group, at, at);
++ return viewOf(await db.prepare('SELECT * FROM community_endpoint_offers WHERE id = ?').get(row.id));
++ }
++
++ /** Remove an offer. Its verification and ledger rows stay: they are history and earnings, not the live offer. */
++ async function remove({ id, owner }) {
++ if (!ID.test(String(id))) throw new AiError(404, 'community.endpoint_not_found', `no endpoint offer ${id}`);
++ if (!USR.test(String(owner))) throw new AiError(422, 'community.endpoint_owner', 'owner must be a usr_ subject');
++ const found = await db.prepare('SELECT id FROM community_endpoint_offers WHERE id = ? AND owner_subject = ?').get(String(id), String(owner));
++ if (!found) throw new AiError(404, 'community.endpoint_not_found', `no endpoint offer ${id}`);
++ await db.prepare('DELETE FROM community_endpoint_offers WHERE id = ?').run(String(id));
++ return { id: String(id), removed: true };
++ }
++
++ /** A registration of the seller's own, by id (the verify route uses it). */
++ async function own({ id, owner }) {
++ if (!ID.test(String(id))) return null;
++ const row = await db.prepare('SELECT * FROM community_endpoint_offers WHERE id = ? AND owner_subject = ?').get(String(id), String(owner));
++ return row ? offerOf(row) : null;
++ }
++
++ /** One response, without following anything: the transport seam tests script (redirect chains included). */
++ function once(target, { body, timeoutMs: callTimeout, signal }) {
++ const send = {
++ method: 'POST',
++ url: target.toString(),
++ headers: {
++ 'Content-Type': 'application/json',
++ Accept: 'application/json',
++ 'User-Agent': 'OpenVibe.AI/0.1 (+https://ai.openvibe.services)',
++ 'Content-Length': Buffer.byteLength(body),
++ },
++ body, lookup: safeLookup, timeoutMs: callTimeout, 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: send.headers, lookup: safeLookup, timeout: callTimeout, 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, 'community.endpoint_too_large', `the endpoint 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, 'community.endpoint_too_large', `the endpoint answered more than ${maxBytes} bytes`)); }
++ chunks.push(c);
++ });
++ res.on('error', (err) => fail(new AiError(502, 'community.endpoint_unavailable', `the endpoint's answer failed: ${String(err && err.message || err).slice(0, 200)}`)));
++ res.on('end', () => ok({ status, headers: res.headers, body: Buffer.concat(chunks).toString('utf8') }));
++ });
++ req.on('error', (err) => fail(new AiError(502, 'community.endpoint_unavailable', err && err.code === 'EGRESS_DENIED'
++ ? 'the endpoint resolves to a non-public address'
++ : `the endpoint could not be reached: ${String(err && err.message || err).slice(0, 200)}`)));
++ req.on('timeout', () => fail(new AiError(504, 'community.endpoint_timeout', `the endpoint did not answer within ${callTimeout}ms`)));
++ const timer = setTimeout(() => fail(new AiError(504, 'community.endpoint_timeout', `the endpoint did not answer within ${callTimeout}ms`)), callTimeout);
++ if (signal) {
++ const onAbort = () => fail(new AiError(409, 'run.cancelled', 'cancelled'));
++ if (signal.aborted) return onAbort();
++ signal.addEventListener('abort', onAbort, { once: true });
++ req.on('close', () => signal.removeEventListener('abort', onAbort));
++ }
++ req.on('close', () => clearTimeout(timer));
++ req.end(body);
++ });
++ }
++
++ /** POST one chat completion, redirects judged hop by hop. → { status, headers, body } */
++ async function send(rawUrl, body, { timeoutMs: callTimeout = timeoutMs, signal = null } = {}) {
++ let target = judgeBaseUrl(rawUrl);
++ for (let hop = 0; hop <= maxRedirects; hop++) {
++ const res = await once(target, { body, timeoutMs: callTimeout, signal });
++ if (res.status < 300 || res.status >= 400) return res;
++ const location = res.headers && res.headers.location;
++ if (!location) throw new AiError(502, 'community.endpoint_redirect', 'the endpoint redirected without a location');
++ if (![307, 308].includes(res.status)) throw new AiError(502, 'community.endpoint_redirect', `the endpoint answered ${res.status} to a POST; only 307 and 308 are followed`);
++ let next;
++ try { next = judgeBaseUrl(new URL(String(location), target).toString()); } catch (err) {
++ throw new AiError(502, 'community.endpoint_redirect', 'the endpoint redirected to an address that is not allowed');
++ }
++ if (hop === maxRedirects) throw new AiError(502, 'community.endpoint_redirect', 'the endpoint redirected too many times');
++ target = next;
++ }
++ throw new AiError(502, 'community.endpoint_redirect', 'the endpoint redirected too many times');
++ }
++
++ /** The answer in the shape server/community/index.js reads from a machine. */
++ function parseAnswer(text) {
++ let data;
++ try { data = JSON.parse(text || ''); } catch { throw new AiError(502, 'community.endpoint_response', 'the endpoint did not answer JSON'); }
++ const choice = data && Array.isArray(data.choices) ? data.choices[0] : null;
++ const message = choice && choice.message && typeof choice.message === 'object' ? choice.message : null;
++ if (!message) throw new AiError(502, 'community.endpoint_response', 'the endpoint did not answer a chat completion');
++ const content = typeof message.content === 'string' ? message.content : Array.isArray(message.content)
++ ? message.content.filter((p) => p && p.type === 'text').map((p) => String(p.text || '')).join('') : '';
++ const answer = { role: 'assistant', content: content.slice(0, MAX_CONTENT) };
++ if (Array.isArray(message.tool_calls) && message.tool_calls.length) {
++ answer.tool_calls = message.tool_calls.slice(0, 32).filter((c) => c && c.function && typeof c.function.name === 'string')
++ .map((c, i) => ({ id: String(c.id || `call_${i + 1}`).slice(0, 200), type: 'function',
++ function: { name: String(c.function.name).slice(0, 64), arguments: typeof c.function.arguments === 'string' ? c.function.arguments.slice(0, MAX_CONTENT) : JSON.stringify(c.function.arguments || {}) } }));
++ if (!answer.tool_calls.length) delete answer.tool_calls;
++ }
++ const usage = data.usage && typeof data.usage === 'object' ? data.usage : {};
++ return {
++ text: answer.content, message: answer,
++ model: typeof data.model === 'string' ? data.model : null,
++ usage: { input: Math.max(0, Math.trunc(Number(usage.prompt_tokens) || 0)), output: Math.max(0, Math.trunc(Number(usage.completion_tokens) || 0)), cached: 0 },
++ finish: choice.finish_reason || (answer.tool_calls ? 'tool_calls' : 'stop'),
++ };
++ }
++
++ /**
++ * One chat completion on a seller's endpoint. The seller's declared concurrency is honoured here: over it, the
++ * call is busy and the router falls back, rather than piling requests on the server.
++ */
++ async function chat(offer, { model, messages, maxTokens = 512, temperature = null, tools = null, timeoutMs: callTimeout = timeoutMs, signal = null } = {}) {
++ const id = offer && offer.endpoint && offer.endpoint.id;
++ if (!id) throw new AiError(502, 'community.endpoint_unavailable', 'not an endpoint offer');
++ const cap = Math.max(1, Number(offer.concurrency) || 1);
++ const inFlight = active.get(id) || 0;
++ if (inFlight >= cap) throw new AiError(503, 'community.busy', 'the endpoint is at its concurrency limit');
++ active.set(id, inFlight + 1);
++ try {
++ const list = (Array.isArray(messages) ? messages : []).slice(0, MAX_MESSAGES).map((m) => {
++ const one = { role: ['system', 'user', 'assistant', 'tool'].includes(m && m.role) ? m.role : 'user', content: textOf(m && m.content) };
++ if (one.role === 'assistant' && Array.isArray(m.tool_calls) && m.tool_calls.length) one.tool_calls = m.tool_calls.slice(0, 32);
++ if (one.role === 'tool' && m.tool_call_id) one.tool_call_id = String(m.tool_call_id).slice(0, 200);
++ return one;
++ });
++ const request = { model: String(model || ''), messages: list, max_tokens: Math.max(1, Math.min(Number(maxTokens) || 512, 32768)), stream: false };
++ if (temperature != null && Number.isFinite(Number(temperature))) request.temperature = Number(temperature);
++ if (Array.isArray(tools) && tools.length) { request.tools = tools; request.tool_choice = 'auto'; }
++ const res = await send(`${offer.endpoint.base_url}/chat/completions`, JSON.stringify(request), { timeoutMs: callTimeout, signal });
++ if (res.status !== 200) throw new AiError(502, 'community.endpoint_unavailable', `the endpoint answered HTTP ${res.status}`);
++ return parseAnswer(res.body);
++ } finally {
++ const left = (active.get(id) || 1) - 1;
++ if (left <= 0) active.delete(id); else active.set(id, left);
++ }
++ }
++
++ return { enabled: Boolean(db), liveOffers, list, create, remove, ownerFor, chat, judgeBaseUrl };
++}
++
++module.exports = { createEndpointOffers, judgeBaseUrl };
ddiff --git a/server/community/index.js b/server/community/index.js
iindex 8a58e26..7da6232 100644
---- a/server/community/index.js
++++ b/server/community/index.js
@@@ -21,7 +21,7 @@
* A volunteer (price 0) earns kudos in an append-only ledger; they never expire, buy queue priority and a bigger free
* output allowance, and are never cash.
*
-- * createCommunity({ db, config, env, machines, clock, fetchImpl, log, tokens, random })
++ * createCommunity({ db, config, env, machines, endpoints, clock, fetchImpl, log, tokens, random })
* .enabled
* .offers() the live offers (refreshed from Network at most refreshMs)
* .choose({ ctx, operation }) one eligible {offer, model, key}, or null
@@@ -118,7 +118,7 @@ function similar(a, b) {
return same / Math.max(x.size, y.size) >= 0.5;
}
--function createCommunity({ db, config, env = process.env, machines = null, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, tokens = null, random = Math.random }) {
++function createCommunity({ db, config, env = process.env, machines = null, endpoints = null, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, tokens = null, random = Math.random }) {
const cfg = (config && config.community) || {};
const network = String((config && config.networkInternalUrl) || '').replace(/\/+$/, '');
const secret = env.OV_OAUTH_CLIENT_SECRET || '';
@@@ -135,7 +135,11 @@ function createCommunity({ db, config, env = process.env, machines = null, clock
if (!identityTokens && secret && network) {
identityTokens = createServiceTokenClient({ tokenUrl: `${network}/oauth/token`, clientId, clientSecret: secret, scope: { 'openvibe.network': 'identity.subject.resolve' }, fetch: fetchImpl });
}
-- const enabled = Boolean(cfg.enabled && db && machines && network && registryTokens);
++ // A Node offer comes from Network and is reached through Bot; a community endpoint is registered here and reached
++ // through its own https address. Each works without the other.
++ const registryEnabled = Boolean(cfg.enabled && db && machines && network && registryTokens);
++ const endpointsEnabled = Boolean(cfg.enabled && db && endpoints && endpoints.enabled);
++ const enabled = registryEnabled || endpointsEnabled;
let cache = { at: 0, offers: [] };
let refreshing = null;
const followersCache = new Map();
@@@ -167,23 +171,37 @@ function createCommunity({ db, config, env = process.env, machines = null, clock
async function fetchOffers() {
if (!enabled) return [];
-- const token = await registryTokens.getToken({ audience: 'openvibe.network' });
-- let res;
-- try {
-- res = await fetchImpl(`${network}/internal/ai/offers`, { headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, signal: AbortSignal.timeout(8000) });
-- } catch (err) {
-- throw new AiError(503, 'community.unavailable', `Network did not answer for community offers: ${String(err && err.message || err).slice(0, 200)}`);
++ let offers = [];
++ if (registryEnabled) {
++ try {
++ const token = await registryTokens.getToken({ audience: 'openvibe.network' });
++ let res;
++ try {
++ res = await fetchImpl(`${network}/internal/ai/offers`, { headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, signal: AbortSignal.timeout(8000) });
++ } catch (err) {
++ throw new AiError(503, 'community.unavailable', `Network did not answer for community offers: ${String(err && err.message || err).slice(0, 200)}`);
++ }
++ const body = await res.json().catch(() => null);
++ if (!res.ok) throw new AiError(503, 'community.unavailable', `Network answered ${res.status} for community offers`);
++ offers = ((body && body.offers) || []).map(normalize).filter(Boolean);
++ } catch (err) {
++ // With endpoints registered here, Network being away must not take them down.
++ if (!endpointsEnabled) throw err;
++ log.warn(`[community] ${err.message}`);
++ }
++ }
++ if (endpointsEnabled) {
++ try { offers = offers.concat(await endpoints.liveOffers()); } catch (err) { log.warn(`[community] endpoints: ${err.message}`); }
}
-- const body = await res.json().catch(() => null);
-- if (!res.ok) throw new AiError(503, 'community.unavailable', `Network answered ${res.status} for community offers`);
-- const offers = ((body && body.offers) || []).map(normalize).filter(Boolean);
await noteHealth(offers);
return offers;
}
-- /** Keep a simple uptime denominator: each refresh is one observation of each offer and model. */
++ /** Keep a simple uptime denominator: each refresh is one observation of each Node offer and model. A community
++ * endpoint has no reporter, so its observations are its own calls (noteDispatch). */
async function noteHealth(offers) {
for (const offer of offers) {
++ if (offer.kind === 'endpoint') continue;
for (const model of offer.models) {
try {
const key = keyOf(offer, model);
@@@ -211,6 +229,22 @@ function createCommunity({ db, config, env = process.env, machines = null, clock
return await refreshing;
}
++ /** One observation of a community endpoint: its answer arrived or it did not. Machines report their own health. */
++ async function noteDispatch(offer, model, ok) {
++ if (!offer || offer.kind !== 'endpoint') return;
++ const key = keyOf(offer, model);
++ const at = nowIso(clock);
++ try {
++ await db.prepare(`INSERT INTO community_offers (offer_key, offer_id, machine_id, owner_subject, model, models, price_in, price_out, status, checks, up_checks, created_at, updated_at)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'new', 1, ?, ?, ?)
++ ON CONFLICT (offer_key) DO UPDATE SET models = excluded.models, price_in = excluded.price_in, price_out = excluded.price_out,
++ checks = community_offers.checks + 1, up_checks = community_offers.up_checks + excluded.up_checks, updated_at = excluded.updated_at`)
++ .run(key, offer.offer_id, offer.machine_id, offer.owner, model, JSON.stringify(offer.models), offer.price.in_per_million, offer.price.out_per_million,
++ ok ? 1 : 0, at, at);
++ await refreshReputation(key);
++ } catch (err) { log.warn(`[community] endpoint health ${key}: ${err.message}`); }
++ }
++
async function stateOf(key) {
return await db.prepare('SELECT * FROM community_offers WHERE offer_key = ?').get(key);
}
@@@ -250,8 +284,20 @@ function createCommunity({ db, config, env = process.env, machines = null, clock
await refreshReputation(key);
}
-- /** Low-level: one chat completion on the person's machine, through Bot, as its owner. */
++ /** Low-level: one chat completion on the person's machine (through Bot), or on a registered endpoint (through the
++ * egress guard), as its owner. */
async function dispatch(offer, model, messages, { maxTokens = 512, temperature = null, tools = null, timeoutMs = 60000, requestId = null } = {}) {
++ if (offer.kind === 'endpoint') {
++ try {
++ return await endpoints.chat(offer, {
++ model, messages, maxTokens, temperature, tools,
++ timeoutMs: Math.max(5000, Math.min(300000, timeoutMs)), signal: AbortSignal.timeout(Math.max(5000, timeoutMs) + 10000),
++ });
++ } catch (err) {
++ if (err && err.code === 'community.busy') throw new AiError(503, 'community.busy', String(err.message || 'the endpoint is busy'));
++ throw err;
++ }
++ }
if (!machines || !machines.enabled) throw new AiError(503, 'community.unavailable', 'OpenVibe.Bot is not configured, so a community machine cannot be reached');
try {
return await machines.chat({
@@@ -278,8 +324,10 @@ function createCommunity({ db, config, env = process.env, machines = null, clock
let answer;
try {
answer = await dispatch(offer, model, canary.messages, { maxTokens: 64, temperature: 0, timeoutMs: cfg.canaryTimeoutMs || 30000, requestId: `canary-${key}` });
++ await noteDispatch(offer, model, true);
} catch (err) {
if (err && (err.code === 'community.busy' || err.code === 'community.not_open')) throw err;
++ await noteDispatch(offer, model, false);
await markCanary(key, { ok: false, reason: `canary_call: ${String(err && err.message || err).slice(0, 200)}` });
throw new AiError(502, 'community.verification_failed', `community machine ${offer.machine_id} failed its canary: ${String(err && err.message || err).slice(0, 200)}`);
}
@@@ -410,7 +458,10 @@ function createCommunity({ db, config, env = process.env, machines = null, clock
/** About 1%: the same prompt on a trusted machine with the same model, compared. No trusted peer, no spot. */
async function spotCheck(chosen, messages, answer) {
if (!(cfg.spotRate > 0) || random() >= cfg.spotRate) return;
-- const peers = (await offers()).filter((o) => o.offer_id !== chosen.offer.offer_id && o.health === 'up' && o.ready && o.models.includes(chosen.model));
++ const peers = (await offers()).filter((o) => o.offer_id !== chosen.offer.offer_id
++ // A second registration of the same endpoint is not an independent machine.
++ && !(o.endpoint && chosen.offer.endpoint && o.endpoint.base_url === chosen.offer.endpoint.base_url)
++ && o.health === 'up' && o.ready && o.models.includes(chosen.model));
const trusted = [];
for (const peer of peers) {
const s = await stateOf(keyOf(peer, chosen.model));
@@@ -441,12 +492,14 @@ function createCommunity({ db, config, env = process.env, machines = null, clock
* - until the machine has `trustDays` of verified service a seller earns at most the daily cap;
* - a volunteer (price 0) earns kudos instead, never cash.
*/
-- async function recordUsage(chosen, { requestId, usage, verified, requester = null, latencyMs = null, chargeId = null }) {
++ async function recordUsage(chosen, { requestId, usage, verified, requester = null, projectId = null, latencyMs = null, chargeId = null }) {
const tin = Math.max(0, Math.trunc(Number(usage && usage.input) || 0));
const tout = Math.max(0, Math.trunc(Number(usage && usage.output) || 0));
const owner = await survivor(chosen.offer.owner);
const person = requester ? await survivor(requester) : null;
-- const selfServed = Boolean(person && owner && person === owner);
++ // Self-dealing: the owner themselves, an account merged into it, or the project that registered the endpoint.
++ const selfServed = Boolean((person && owner && person === owner)
++ || (projectId && chosen.offer.projectId && String(projectId) === String(chosen.offer.projectId)));
let credits = verified && !selfServed ? Math.round(tin * chosen.offer.price.in_per_million + tout * chosen.offer.price.out_per_million) : 0;
let earning = !verified ? 'free' : selfServed ? 'self' : (chargeId ? 'spendable' : 'pending');
let capped = false;
@@@ -506,11 +559,14 @@ function createCommunity({ db, config, env = process.env, machines = null, clock
const timeoutMs = Math.max(5000, Math.min(Number(req.timeoutMs) || 60000, 300000));
const requestId = String(ctx.runId || `community-${clock.now()}`);
const requester = requesterOf(ctx);
-- const selfServed = Boolean(requester && (await survivor(requester)) === (await survivor(chosen.offer.owner)));
++ const selfServed = Boolean((requester && (await survivor(requester)) === (await survivor(chosen.offer.owner)))
++ || (ctx.projectId && chosen.offer.projectId && String(ctx.projectId) === String(chosen.offer.projectId)));
++ const isEndpoint = chosen.offer.kind === 'endpoint';
++ const source = isEndpoint ? `community-endpoint:${chosen.offer.endpoint.id}` : `community:${chosen.offer.machine_id}`;
const facts = {
-- resolved_model: `community/${chosen.model}`, revision: null, deployment: `community:${chosen.offer.machine_id}`,
-- provider_access: 'COMMUNITY', rate_version: 'community', policy: 'community',
-- community: { machine_id: chosen.offer.machine_id, owner: chosen.offer.owner, model: chosen.model, price: chosen.offer.price,
++ resolved_model: `community/${chosen.model}`, revision: null, deployment: source,
++ provider_access: isEndpoint ? 'COMMUNITY_ENDPOINT' : 'COMMUNITY', rate_version: 'community', policy: 'community',
++ community: { kind: isEndpoint ? 'endpoint' : 'machine', machine_id: chosen.offer.machine_id, 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') {
@@@ -525,14 +581,16 @@ function createCommunity({ db, config, env = process.env, machines = null, clock
temperature: req.temperature == null ? null : Number(req.temperature),
tools, timeoutMs, requestId,
});
++ await noteDispatch(chosen.offer, chosen.model, true);
} catch (err) {
if (err && (err.code === 'community.busy' || err.code === 'community.not_open')) return null;
++ await noteDispatch(chosen.offer, chosen.model, false);
throw err;
}
const usage = answer.usage || { input: 0, output: 0, cached: 0 };
await spotCheck(chosen, messages, answer);
const latencyMs = clock.now() - t0;
-- const recorded = await recordUsage(chosen, { requestId, usage, verified: chosen.status !== 'new', requester, latencyMs,
++ const recorded = await recordUsage(chosen, { requestId, usage, verified: chosen.status !== 'new', requester, projectId: ctx.projectId || null, latencyMs,
chargeId: ctx.requesterChargeId || null });
const text = answer.text || '';
const message = answer.message || { role: 'assistant', content: text };
@@@ -545,14 +603,14 @@ function createCommunity({ db, config, env = process.env, machines = null, clock
credits_micro: recorded.credits_micro, earning_status: recorded.earning_status, self_served: recorded.self_served, kudos: recorded.kudos };
const explain = {
objective: 'community', reasons: [recorded.self_served
-- ? 'self-served: the requester owns this machine, so the answer earns no credits'
-- : `a community machine offered ${chosen.model}${chosen.offer.price.in_per_million || chosen.offer.price.out_per_million ? ` at ${chosen.offer.price.in_per_million}/${chosen.offer.price.out_per_million} credits per million` : ' for free'}`],
-- selected: `community:${chosen.offer.machine_id}/${chosen.model}`, paid_by: 'community', candidates: [],
-- attempts: [{ key: 'community', provider: `community:${chosen.offer.machine_id}`, model: chosen.model, why: 'an opted-in request placed on a verified community machine', status: 'ok', error: null }],
++ ? 'self-served: the requester owns this offer, so the answer earns no credits'
++ : `${isEndpoint ? 'a community endpoint' : 'a community machine'} offered ${chosen.model}${chosen.offer.price.in_per_million || chosen.offer.price.out_per_million ? ` at ${chosen.offer.price.in_per_million}/${chosen.offer.price.out_per_million} credits per million` : ' for free'}`],
++ selected: `${source}/${chosen.model}`, paid_by: 'community', candidates: [],
++ attempts: [{ key: 'community', provider: source, model: chosen.model, why: isEndpoint ? 'an opted-in request placed on a verified community endpoint' : 'an opted-in request placed on a verified community machine', status: 'ok', error: null }],
community, openvibe: facts,
};
return {
-- result, provider: `community:${chosen.offer.machine_id}`, providerKind: 'community', model: answer.model || chosen.model,
++ result, provider: source, providerKind: 'community', model: answer.model || chosen.model,
fallbackUsed: false, usage, cost: 0, latencyMs, ttftMs: null, synthetic: false, tried: [], startedAt: t0, explain,
};
}
@@@ -617,7 +675,7 @@ function createCommunity({ db, config, env = process.env, machines = null, clock
const kudos = Math.max(0, Number(kudosRow.balance) || 0);
const offerViews = await Promise.all(list.map(async (o) => {
const openNow = o.hours ? withinHours(o, clock.now()) : null;
-- const out = { offer_id: o.offer_id, machine_id: o.machine_id, models: o.models, hours: o.hours, concurrency: o.concurrency,
++ const out = { offer_id: o.offer_id, kind: o.kind || 'machine', machine_id: o.machine_id, models: o.models, hours: o.hours, concurrency: o.concurrency,
price: o.price, audience: o.audience, group: o.group, health: o.health, open_now: openNow, ready: o.ready && openNow !== false,
verification: null, reputation: 0, free_only: true, verified: false,
tokens_per_s: Math.max(0, ...served.filter((x) => x.offer_key.startsWith(`${o.offer_id}|`)).map((x) => x.tokens_per_s || 0)) || null };
@@@ -671,7 +729,7 @@ function createCommunity({ db, config, env = process.env, machines = null, clock
const openNow = withinHours(o, clock.now());
return {
// The public id is a stable hash: Exchange can list and link an offer without publishing which machine it is.
-- offer_id: `community_${sha256(o.offer_id).slice(0, 16)}`, models: o.models, hours: o.hours, concurrency: o.concurrency,
++ offer_id: `community_${sha256(o.offer_id).slice(0, 16)}`, kind: o.kind || 'machine', models: o.models, hours: o.hours, concurrency: o.concurrency,
price: o.price, audience: o.audience, health: o.health, open_now: openNow, ready: o.ready && openNow,
};
});
ddiff --git a/server/config.js b/server/config.js
iindex baa21fa..6a810da 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -429,6 +429,17 @@ function load(env = process.env) {
trustDays: Math.max(1, Math.min(365, int(env.AI_COMMUNITY_TRUST_DAYS, 7))),
dailyEarningCapMicro: Math.max(0, Math.min(1e15, int(env.AI_COMMUNITY_DAILY_CAP_CREDITS, 100) * 1e6)),
kudosPerThousandTokens: Math.max(1, Math.min(1000, int(env.AI_COMMUNITY_KUDOS_PER_1K, 1))),
++ // A community endpoint is a standalone OpenAI-compatible server registered by its own https address with
++ // the seller's Network developer key (server/community/endpoints.js). AI reaches it only through the egress
++ // guard: https, a public DNS answer, no IP literal, a timeout, a response byte cap, and never the buyer's
++ // identity in a header. maxPerOwner bounds one seller's registrations.
++ endpoints: {
++ enabled: bool(env.AI_COMMUNITY_ENDPOINTS, true),
++ timeoutMs: Math.max(5000, Math.min(120000, int(env.AI_COMMUNITY_ENDPOINT_TIMEOUT_MS, 60000))),
++ maxBytes: Math.max(65536, Math.min(16 * 1024 * 1024, int(env.AI_COMMUNITY_ENDPOINT_MAX_BYTES, 2 * 1024 * 1024))),
++ maxRedirects: Math.max(0, Math.min(3, int(env.AI_COMMUNITY_ENDPOINT_MAX_REDIRECTS, 2))),
++ maxPerOwner: Math.max(1, Math.min(100, int(env.AI_COMMUNITY_ENDPOINT_MAX_PER_OWNER, 20))),
++ },
},
// ── Tier budgets (T6 step 8, server/govern.js): openvibe-sdk/govern in Valkey, off unless AI_GOVERN_TIERS=1.
ddiff --git a/server/exchange/receipts.js b/server/exchange/receipts.js
iindex 534b9b6..220384c 100644
---- a/server/exchange/receipts.js
++++ b/server/exchange/receipts.js
@@@ -93,6 +93,8 @@ function buildReceipt({ run, explain = null, usage = {}, price = null, privacy =
resolved_model: (x.openvibe && x.openvibe.resolved_model) || null,
// The host's own model answered (local / local2): the receipt says so, beside the provider name.
local: Boolean(x.provider_local),
++ // A community answer says which kind of community source it was: a person's machine or a registered endpoint.
++ community: (x.openvibe && x.openvibe.community && x.openvibe.community.kind) || null,
},
price: {
in_per_mtok: round(price && price.in),
@@@ -125,6 +127,7 @@ function compact(receipt) {
run_id: receipt.run_id,
offer: offer.provider ? `${offer.provider}${offer.model ? `/${offer.model}` : ''}` : null,
local: Boolean(offer.local),
++ community: offer.community || null,
price: { in: receipt.price && receipt.price.in_per_mtok, out: receipt.price && receipt.price.out_per_mtok, cached: receipt.price && receipt.price.cached_per_mtok },
tokens: { in: receipt.usage && receipt.usage.tokens_in, out: receipt.usage && receipt.usage.tokens_out, cached: receipt.usage && receipt.usage.tokens_cached },
cost_usd: receipt.cost_usd,
ddiff --git a/server/index.js b/server/index.js
iindex 920cdc3..4afc273 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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, 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, 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.
@@@ -65,7 +65,12 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
const machines = machineModels || require('./machines').createMachineModels({ config, env, fetchImpl });
// Community AI offers (the design's "Offer to the network"; plan E4): read Network's user-owned offers, verify a
// machine before traffic, and keep its seller ledger. Empty without a Network service secret or Bot.
-- const community = givenCommunity || require('./community').createCommunity({ db, config, env, machines, clock, fetchImpl, log });
++ // A community endpoint (the design's SDK gap) is registered here with the seller's own developer key and reached
++ // through the egress guard; it joins the same offer list, verification and ledger.
++ const endpoints = givenEndpoints || (config.community.endpoints && config.community.endpoints.enabled === false
++ ? null
++ : require('./community/endpoints').createEndpointOffers({ db, config, env, 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.
const abuse = givenAbuse || require('./abuse').createAbuse({ db, config, env, clock, fetchImpl, log, registry, community });
// BENCH-11: Bench's ratings as routing evidence, and the opted-in tie rule inside placement.
@@@ -152,7 +157,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
clientSecret: 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, presets, receipts, balance, gpus, abuse });
++ 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 });
let jwks = null;
let keyLoaded = Promise.resolve(null);
if (!keys.pinned && config.nodeEnv !== 'test') {
@@@ -241,7 +246,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
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, credits: creditsRef, settings, vault, community, gpus, abuse, close };
++ return { config, db, registry, pool, quotas, tiers, cache, fetcher, engine, runs, keys, keyLoaded, auth, app, server, bench, benchEvents, memory, memoryJobs, credits: creditsRef, settings, vault, community, endpoints, gpus, abuse, close };
}
if (require.main === module) {
ddiff --git a/server/openapi.js b/server/openapi.js
iindex 504679e..3613d9a 100644
---- a/server/openapi.js
++++ b/server/openapi.js
@@@ -360,6 +360,56 @@ route('get', '/exchange/usage', {
} }) },
});
++// ── 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'],
++ properties: {
++ id: str({ pattern: '^ep_[0-9A-HJKMNP-TV-Z]{26}$' }),
++ offer_id: str({ description: 'The offer id the router and the receipt use: endpoint:<id>' }),
++ name: nullable('string'),
++ base_url: str({ description: 'The https base URL the router calls; /chat/completions is appended. Port 443 or 8443, a host name (never an IP address).' }),
++ models: arrayOf(str()), hours: { type: 'object', properties: { start: str(), end: str(), tz: str() } },
++ concurrency: integer({ minimum: 1, maximum: 8 }),
++ price: { type: 'object', properties: { in_per_million: { type: 'number' }, out_per_million: { type: 'number' } } },
++ audience: str({ enum: ['everyone', 'followers', 'group'] }), group: nullable('string'),
++ created_at: str({ format: 'date-time' }), updated_at: str({ format: 'date-time' }),
++ verification: nullable('object', { description: 'Canaries, spot re-runs, reputation and free-only, as the /sell dashboard shows them' }),
++ reputation: integer({ minimum: 0, maximum: 100 }), free_only: { type: 'boolean' }, verified: { type: 'boolean' },
++ },
++};
++const sellerEndpointInput = {
++ type: 'object', required: ['base_url', 'models', 'price', 'hours', 'concurrency'],
++ properties: {
++ base_url: str({ description: 'https, a public host name (no IP literal), port 443 or 8443, an optional base path such as /v1' }),
++ models: { type: 'array', items: str(), minItems: 1, maxItems: 40 },
++ price: { description: 'Credits per million tokens: a number for both sides, or {in_per_million, out_per_million}; 0 volunteers the model', oneOf: [
++ { type: 'number', minimum: 0, maximum: 1000000 },
++ { type: 'object', required: ['in_per_million', 'out_per_million'], properties: { in_per_million: { type: 'number', minimum: 0, maximum: 1000000 }, out_per_million: { type: 'number', minimum: 0, maximum: 1000000 } } },
++ ] },
++ hours: { type: 'object', required: ['start', 'end'], properties: { start: str({ pattern: '^([01][0-9]|2[0-3]):[0-5][0-9]$' }), end: str({ pattern: '^([01][0-9]|2[0-3]):[0-5][0-9]$' }), tz: str() } },
++ concurrency: integer({ minimum: 1, maximum: 8 }),
++ audience: str({ enum: ['everyone', 'followers', 'group'] }),
++ group: str({ description: 'Required when audience is group' }),
++ name: str({ maxLength: 100 }),
++ },
++};
++route('post', '/exchange/seller/offers', {
++ operationId: 'createSellerEndpointOffer', summary: 'Register a standalone OpenAI-compatible server to sell', tags: ['exchange'], security: SERVICE_OR_APP,
++ description: 'ai.app.run (a Network developer key), or a person\'s own token; a first-party service token is refused. The owner is the person the key acts for: an app\'s project owner, or the person themselves. The router reaches base_url only through the egress guard: https, a public DNS answer, never an IP literal, a timeout, a response byte cap, and never the buyer\'s identity in a header. A new endpoint serves the free tier only until its canaries, model fingerprint and spot re-runs verify it, exactly as a Node machine does; a request on the seller\'s own account is self-served and earns 0. An answer from it is labelled a community endpoint on the receipt (provider community-endpoint:<id>, provider_access COMMUNITY_ENDPOINT) and only requests that opt in to community capacity reach it.',
++ requestBody: body(sellerEndpointInput),
++ responses: { 201: json('The registered offer', { type: 'object', required: ['offer'], properties: { offer: sellerEndpointOffer } }), 409: problem, 422: problem, ...errors([401, 403, 503]) },
++});
++route('get', '/exchange/seller/offers', {
++ operationId: 'listSellerEndpointOffers', summary: 'The caller\'s own endpoint offers, with their verification', tags: ['exchange'], security: SERVICE_OR_APP,
++ description: 'ai.app.run, or a person\'s own token. Only the caller\'s offers: an app sees the offers of its project owner. The verification is the same the /sell dashboard shows.',
++ responses: { 200: json('The offers', { type: 'object', required: ['owner', 'offers'], properties: { owner: str({ pattern: '^usr_[0-9A-HJKMNP-TV-Z]{26}$' }), offers: arrayOf(sellerEndpointOffer) } }), ...errors([401, 403, 503]) },
++});
++route('delete', '/exchange/seller/offers/{id}', {
++ operationId: 'deleteSellerEndpointOffer', summary: 'Remove one of the caller\'s endpoint offers', tags: ['exchange'], security: SERVICE_OR_APP,
++ description: 'ai.app.run, or a person\'s own token. The offer stops receiving traffic on the next offer refresh (within 30 seconds); its verification and earnings stay as history.',
++ parameters: [path('id', str({ pattern: '^ep_[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('post', '/exchange/tryit/challenge', {
operationId: 'exchangeTryitChallenge', summary: 'A proof-of-work challenge for the anonymous Try it', tags: ['exchange'], security: [],
description: 'Public. The anonymous Try it (no account, no Balance) must send a solved challenge in x-ov-pow: <challenge>:<nonce>, where sha256("<challenge>:<nonce>") has `bits` leading zero bits. The challenge is single use and expires; AI stores only its hash. The response also sets the signed, HttpOnly `ovai_device` cookie (when it is not already present), which counts accounts per device. A first-party service may forward the client address in x-ov-client-ip.',
ddiff --git a/server/runs.js b/server/runs.js
iindex ec8e7dc..c5ba14c 100644
---- a/server/runs.js
++++ b/server/runs.js
@@@ -581,7 +581,8 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
? explain.attempts[0].provider : null;
const record = (provider ? await registry.getProvider(provider) : null)
|| (credentialProvider ? await registry.getProvider(credentialProvider) : null);
-- const communityAnswer = Boolean(provider && String(provider).startsWith('community:')) || Boolean(explain && explain.community);
++ // A community machine (`community:`) or a registered community endpoint (`community-endpoint:`) sees the prompt.
++ const communityAnswer = Boolean(provider && /^community[:-]/.test(String(provider))) || Boolean(explain && explain.community);
return communityAnswer
? privacy.labelFor({ record: { metadata: { data_policy: { trust: 'community' } } } })
: privacy.labelFor({ record, credential: byok ? { provider: credentialProvider } : null });
ddiff --git a/test/community-endpoint-api.test.js b/test/community-endpoint-api.test.js
nnew file mode 100644
iindex 0000000..5f147ec
---- /dev/null
++++ b/test/community-endpoint-api.test.js
@@@ -0,0 +1,132 @@
++'use strict';
++// The public seller API (design EXCHANGE-REMAKE-2026-11-06, the SDK gap): a developer key registers, lists and removes
++// a standalone OpenAI-compatible endpoint offer on the Exchange's public API. The owner is the app's project owner,
++// read from Network; a first-party service token is refused; the seller never names an owner.
++const assert = require('assert');
++const { boot, request, token, suite } = require('./helpers');
++const { createEndpointOffers } = require('../server/community/endpoints');
++
++const t = suite('community-endpoint-api');
++const OWNER = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A1';
++const APP = '01J8Z3Q4R5S6T7V8W9X0Y1Z2A3';
++const PROJECT = '01J8Z3Q4R5S6T7V8W9X0Y1Z2P1';
++const silent = { log() {}, warn() {}, error() {} };
++
++const INPUT = {
++ 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',
++};
++
++function appToken(caps = ['ai.app.run']) {
++ return token('app', caps, {
++ sub: `app:app_${APP}`, actorType: 'app', ns: [`prj_${PROJECT}`, `app.prj_${PROJECT}.*`],
++ extra: { project_id: `prj_${PROJECT}`, env: 'production' },
++ });
++}
++
++function endpointStore(db, { project = OWNER } = {}) {
++ return createEndpointOffers({
++ db,
++ config: { networkInternalUrl: 'http://network.test', console: { clientId: 'ai' }, community: { enabled: true, endpoints: { enabled: true } } },
++ env: {}, clock: { now: () => Date.parse('2026-11-06T12:00:00Z') }, log: silent,
++ tokens: { getToken: async () => 'test-token' },
++ fetchImpl: async (url) => (String(url).includes('/internal/projects/')
++ ? (project ? { ok: true, status: 200, json: async () => ({ project: { id: `prj_${PROJECT}`, owner: { type: 'user', id: project } } }) }
++ : { ok: false, status: 404, json: async () => null })
++ : { ok: false, status: 404, json: async () => null }),
++ });
++}
++
++let h;
++t.test('boot', async () => {
++ h = await boot({ communityEndpoints: (db) => endpointStore(db) });
++});
++
++t.test('the seller API is guarded: a token is required, and a service token is not a seller', async () => {
++ const anon = await request(h.base, 'POST', '/api/v1/exchange/seller/offers', { body: INPUT });
++ assert.strictEqual(anon.status, 401, anon.text);
++ const service = await request(h.base, 'POST', '/api/v1/exchange/seller/offers', { tok: token('exchange', ['ai.run.create']), body: INPUT });
++ assert.strictEqual(service.status, 403, service.text);
++ assert.strictEqual(service.body.code, 'community.endpoint_owner');
++ const appWithoutCap = await request(h.base, 'POST', '/api/v1/exchange/seller/offers', { tok: appToken(['ai.run.create']), body: INPUT });
++ assert.strictEqual(appWithoutCap.status, 403, appWithoutCap.text);
++ assert.strictEqual(appWithoutCap.body.code, 'capability.denied');
++});
++
++t.test('a developer key registers its own endpoint offer, and the owner is the project owner', async () => {
++ const created = await request(h.base, 'POST', '/api/v1/exchange/seller/offers', { tok: appToken(), body: INPUT });
++ assert.strictEqual(created.status, 201, created.text);
++ const offer = created.body.offer;
++ assert.match(offer.id, /^ep_[0-9A-HJKMNP-TV-Z]{26}$/);
++ assert.strictEqual(offer.offer_id, `endpoint:${offer.id}`);
++ assert.strictEqual(offer.base_url, 'https://models.example/v1');
++ assert.deepStrictEqual(offer.models, ['qwen3:32b']);
++ assert.deepStrictEqual(offer.hours, { start: '00:00', end: '23:59', tz: 'UTC' });
++ assert.deepStrictEqual(offer.price, { in_per_million: 10, out_per_million: 30 });
++ assert.strictEqual(offer.concurrency, 2);
++ assert.strictEqual(offer.audience, 'everyone');
++
++ const listed = await request(h.base, 'GET', '/api/v1/exchange/seller/offers', { tok: appToken() });
++ assert.strictEqual(listed.status, 200, listed.text);
++ assert.strictEqual(listed.body.owner, OWNER, 'the owner is the project\'s person, never the app');
++ assert.strictEqual(listed.body.offers.length, 1);
++ assert.strictEqual(listed.body.offers[0].id, offer.id);
++
++ const removed = await request(h.base, 'DELETE', `/api/v1/exchange/seller/offers/${offer.id}`, { tok: appToken() });
++ assert.strictEqual(removed.status, 200, removed.text);
++ assert.deepStrictEqual(removed.body, { id: offer.id, removed: true });
++
++ const after = await request(h.base, 'GET', '/api/v1/exchange/seller/offers', { tok: appToken() });
++ assert.strictEqual(after.status, 200);
++ assert.deepStrictEqual(after.body.offers, []);
++});
++
++t.test('an address the egress guard would refuse is refused at registration, with the seller\'s key', async () => {
++ for (const [base_url, re] of [['http://models.example/v1', /https/], ['https://127.0.0.1/v1', /IP address/],
++ ['https://169.254.169.254/latest', /IP address/], ['https://[::1]/v1', /IP address/], ['https://localhost/v1', /public host name/]]) {
++ const out = await request(h.base, 'POST', '/api/v1/exchange/seller/offers', { tok: appToken(), body: { ...INPUT, base_url } });
++ assert.strictEqual(out.status, 422, out.text);
++ assert.strictEqual(out.body.code, 'community.endpoint_url');
++ assert.match(out.body.detail, re);
++ }
++});
++
++t.test('a key removes its own offers only', async () => {
++ const other = await h.endpoints.create({ owner: 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2B2', input: { ...INPUT, base_url: 'https://other.example/v1' } });
++ const notMine = await request(h.base, 'DELETE', `/api/v1/exchange/seller/offers/${other.id}`, { tok: appToken() });
++ assert.strictEqual(notMine.status, 404, notMine.text);
++ assert.strictEqual(notMine.body.code, 'community.endpoint_not_found');
++
++ const created = await request(h.base, 'POST', '/api/v1/exchange/seller/offers', { tok: appToken(), body: INPUT });
++ assert.strictEqual(created.status, 201, created.text);
++ const removed = await request(h.base, 'DELETE', `/api/v1/exchange/seller/offers/${created.body.offer.id}`, { tok: appToken() });
++ assert.strictEqual(removed.status, 200, removed.text);
++ const again = await request(h.base, 'DELETE', `/api/v1/exchange/seller/offers/${created.body.offer.id}`, { tok: appToken() });
++ assert.strictEqual(again.status, 404, again.text);
++});
++
++t.test('Exchange lists an endpoint publicly without its address or owner', async () => {
++ const created = await request(h.base, 'POST', '/api/v1/exchange/seller/offers', { tok: appToken(), body: INPUT });
++ assert.strictEqual(created.status, 201, created.text);
++ await h.community.offers({ force: true });
++ const listed = await request(h.base, 'GET', '/api/v1/community/offers');
++ assert.strictEqual(listed.status, 200, listed.text);
++ const offer = listed.body.offers.find((o) => o.kind === 'endpoint');
++ assert.ok(offer, 'the endpoint is in the public list');
++ assert.match(offer.offer_id, /^community_[0-9a-f]{16}$/);
++ assert.strictEqual(offer.base_url, undefined, 'the address is never public');
++ assert.strictEqual(offer.owner, undefined);
++ assert.deepStrictEqual(offer.models, ['qwen3:32b']);
++});
++
++t.test('a project Network does not know is refused, never guessed', async () => {
++ const h2 = await boot({ communityEndpoints: (db) => endpointStore(db, { project: null }) });
++ try {
++ const out = await request(h2.base, 'POST', '/api/v1/exchange/seller/offers', { tok: appToken(), body: INPUT });
++ assert.strictEqual(out.status, 403, out.text);
++ assert.strictEqual(out.body.code, 'community.endpoint_owner');
++ } finally { await h2.stop(); }
++});
++
++t.run();
ddiff --git a/test/community-endpoints.test.js b/test/community-endpoints.test.js
nnew file mode 100644
iindex 0000000..22875cd
---- /dev/null
++++ b/test/community-endpoints.test.js
@@@ -0,0 +1,280 @@
++'use strict';
++// Community endpoints (the design's SDK gap): a standalone OpenAI-compatible server is registered by its own https
++// address, and AI reaches it only through the egress guard. The tests are the guard's own: https only, no IP literal
++// (127.0.0.1, 10/8, 169.254.169.254, [::1], a NAT64 spelling), no private DNS answer, no redirect to one; a timeout and
++// a response cap; and never the buyer's identity in a header. Then the same trust as a Node machine: the canaries, the
++// model fingerprint, the free tier only until verified, and self-dealing earns 0.
++const assert = require('assert');
++const { boot, request, token, suite, ALL } = require('./helpers');
++const { createEndpointOffers, judgeBaseUrl } = require('../server/community/endpoints');
++const { createCommunity } = require('../server/community');
++const { buildReceipt } = require('../server/exchange/receipts');
++
++const t = suite('community-endpoints');
++const OWNER = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A1';
++const OTHER = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2B2';
++const silent = { log() {}, warn() {}, error() {} };
++
++const INPUT = {
++ 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',
++};
++
++function cfg(over = {}) {
++ return { networkInternalUrl: 'http://network.test', console: { clientId: 'ai' }, community: { enabled: true, refreshMs: 1000, canaryMaxAgeMs: 21600000, spotRate: 0, canaryTimeoutMs: 5000, trustDays: 7, dailyEarningCapMicro: 100000000, kudosPerThousandTokens: 1, ...over } };
++}
++
++function networkFetch(owner = OWNER) {
++ return async (url) => {
++ if (String(url).includes('/internal/projects/')) return { ok: true, status: 200, json: async () => ({ project: { id: 'prj_01J8Z3Q4R5S6T7V8W9X0Y1Z2P1', owner: { type: 'user', id: owner } } }) };
++ return { ok: false, status: 404, json: async () => null };
++ };
++}
++
++/** A scripted OpenAI endpoint: canaries answer correctly; anything else echoes, or says what the test wants. */
++function fakeEndpoint({ canary = true, model = 'qwen3:32b', answer = null } = {}) {
++ const calls = [];
++ const transport = async ({ url, headers, body }) => {
++ const request = JSON.parse(body);
++ calls.push({ url, headers, request });
++ const last = String((request.messages[request.messages.length - 1] || {}).content || '');
++ let text;
++ if (last.includes('OPENVIBE-CANARY')) text = 'OPENVIBE-CANARY-7F3A';
++ else if (last.includes('17 + 25')) text = canary ? '42' : '41';
++ else if (last.includes('canary-ok')) text = 'canary-ok';
++ else text = answer != null ? answer : `You said: ${last}`;
++ return {
++ status: 200, headers: {},
++ body: JSON.stringify({ model, choices: [{ index: 0, message: { role: 'assistant', content: text }, finish_reason: 'stop' }],
++ usage: { prompt_tokens: 10, completion_tokens: 5 } }),
++ };
++ };
++ return { calls, transport };
++}
++
++function storeFor(db, { transport = null, lookup = null, owner = OWNER, fetchImpl = networkFetch(owner), over = {} } = {}) {
++ let ms = Date.parse('2026-11-06T12:00:00Z');
++ return createEndpointOffers({
++ db, config: cfg(over), env: {}, fetchImpl, transport, lookup,
++ clock: { now: () => ms }, log: silent, tokens: { getToken: async () => 'test-token' },
++ });
++}
++
++t.test('the URL guard is https, a public host name, 443/8443 — never an address', async () => {
++ for (const bad of ['http://models.example/v1', 'https://127.0.0.1/v1', 'https://10.0.0.1/v1', 'https://169.254.169.254/latest',
++ 'https://[::1]/v1', 'https://[64:ff9b::7f00:1]/v1', 'https://localhost/v1', 'https://models.local/v1',
++ 'https://user:[email protected]/v1', 'https://models.example:8080/v1', 'https://models.example/v1?x=1']) {
++ assert.throws(() => judgeBaseUrl(bad), /https|IP address|public host|credentials|port 443|query or fragment/, bad);
++ }
++ assert.strictEqual(String(judgeBaseUrl('https://models.example/v1')), 'https://models.example/v1');
++ assert.strictEqual(judgeBaseUrl('https://models.example/').pathname, '/');
++ assert.strictEqual(judgeBaseUrl('https://models.example:8443/v1').port, '8443');
++});
++
++t.test('the registration checks models, hours, time zone, price, concurrency and audience', async () => {
++ const h = await boot({ env: { AI_COMMUNITY: 'false' } });
++ try {
++ const store = storeFor(h.db);
++ const bad = [
++ [{ ...INPUT, models: [] }, /models/], [{ ...INPUT, models: ['a', 'a'] }, /models/],
++ [{ ...INPUT, hours: { start: '25:00', end: '01:00', tz: 'UTC' } }, /hours/],
++ [{ ...INPUT, hours: { start: '00:00', end: '01:00', tz: 'Not/AZone' } }, /time zone/],
++ [{ ...INPUT, concurrency: 9 }, /concurrency/], [{ ...INPUT, price: { in_per_million: -1, out_per_million: 0 } }, /price/],
++ [{ ...INPUT, audience: 'nobody' }, /audience/], [{ ...INPUT, audience: 'group' }, /group/],
++ [{ ...INPUT, owner: OWNER }, /unknown field/],
++ ];
++ for (const [input, re] of bad) await assert.rejects(() => store.create({ owner: OWNER, input }), re);
++ const offer = await store.create({ owner: OWNER, input: { ...INPUT, audience: 'group', group: 'g1' } });
++ assert.strictEqual(offer.offer_id, `endpoint:${offer.id}`);
++ assert.deepStrictEqual(offer.models, ['qwen3:32b']);
++ assert.strictEqual(offer.hours.tz, 'UTC');
++ assert.deepStrictEqual(offer.price, { in_per_million: 10, out_per_million: 30 });
++ assert.strictEqual(offer.group, 'g1');
++ } finally { await h.stop(); }
++});
++
++t.test('a name that answers a private address is refused by the connection lookup', async () => {
++ const h = await boot({ env: { AI_COMMUNITY: 'false' } });
++ try {
++ const lookup = (host, options, cb) => cb(null, [{ address: '10.1.2.3', family: 4 }]);
++ const store = storeFor(h.db, { lookup });
++ await store.create({ owner: OWNER, input: INPUT });
++ const offer = (await store.liveOffers())[0];
++ await assert.rejects(() => store.chat(offer, { model: 'qwen3:32b', messages: [{ role: 'user', content: 'hi' }] }), /non-public address/);
++ } finally { await h.stop(); }
++});
++
++t.test('a redirect is judged again: to a private address it is refused', async () => {
++ const h = await boot({ env: { AI_COMMUNITY: 'false' } });
++ try {
++ for (const location of ['https://127.0.0.1/v1/chat/completions', 'https://[::1]/v1/chat/completions', 'https://169.254.169.254/v1/chat/completions']) {
++ const transport = async () => ({ status: 307, headers: { location }, body: '' });
++ const store = storeFor(h.db, { transport });
++ await store.create({ owner: OWNER, input: INPUT });
++ const offer = (await store.liveOffers())[0];
++ await assert.rejects(() => store.chat(offer, { model: 'qwen3:32b', messages: [{ role: 'user', content: 'hi' }] }), /not allowed|non-public/, location);
++ }
++ // 301/302 are not followed for a POST at all.
++ const transport = async () => ({ status: 302, headers: { location: 'https://other.example/v1/chat/completions' }, body: '' });
++ const store = storeFor(h.db, { transport });
++ await store.create({ owner: OWNER, input: INPUT });
++ const offer = (await store.liveOffers())[0];
++ await assert.rejects(() => store.chat(offer, { model: 'qwen3:32b', messages: [{ role: 'user', content: 'hi' }] }), /307 and 308/);
++ } finally { await h.stop(); }
++});
++
++t.test('a dispatch carries no buyer identity and no caller field beyond the request itself', async () => {
++ const h = await boot({ env: { AI_COMMUNITY: 'false' } });
++ try {
++ const fake = fakeEndpoint();
++ const store = storeFor(h.db, { transport: fake.transport });
++ await store.create({ owner: OWNER, input: INPUT });
++ const offer = (await store.liveOffers())[0];
++ await store.chat(offer, { model: 'qwen3:32b', messages: [{ role: 'user', content: 'hi' }] });
++ const sent = fake.calls[0];
++ assert.deepStrictEqual(Object.keys(sent.headers).sort(), ['Accept', 'Content-Length', 'Content-Type', 'User-Agent']);
++ assert.ok(!/x-ov|x-openvibe|cookie|forwarded|authorization|openai-organization/i.test(Object.keys(sent.headers).join(' ')));
++ assert.deepStrictEqual(Object.keys(sent.request).sort(), ['max_tokens', 'messages', 'model', 'stream']);
++ assert.strictEqual(sent.request.user, undefined, 'the caller\'s user field is never passed through');
++ assert.strictEqual(sent.request.stream, false);
++ assert.ok(sent.url.endsWith('/v1/chat/completions'));
++ } finally { await h.stop(); }
++});
++
++t.test('a registered endpoint joins the router: canaries first, free tier until verified, then a labelled answer', async () => {
++ const h = await boot({ env: { AI_COMMUNITY: 'false' } });
++ try {
++ const fake = fakeEndpoint();
++ const store = storeFor(h.db, { transport: fake.transport });
++ await store.create({ owner: OWNER, input: INPUT });
++ const community = createCommunity({
++ db: h.db, config: cfg(), env: {}, endpoints: store, fetchImpl: networkFetch(), log: silent,
++ tokens: { getToken: async () => 'test-token' }, clock: { now: () => Date.parse('2026-11-06T12:00:00Z') }, random: () => 1,
++ });
++ const out = await community.execute({ operation: 'chat', req: { messages: [{ role: 'user', content: 'hello' }], maxTokens: 64 },
++ ctx: { communityOptIn: true, creditsRequired: true, runId: 'r1', onBehalfOf: OTHER } });
++ assert.ok(out, 'an endpoint offer is eligible');
++ assert.match(out.provider, /^community-endpoint:ep_/);
++ assert.strictEqual(out.explain.openvibe.provider_access, 'COMMUNITY_ENDPOINT');
++ assert.strictEqual(out.explain.openvibe.deployment, out.provider);
++ assert.strictEqual(out.explain.community.kind, 'endpoint');
++ assert.strictEqual(out.explain.community.verification, 'verified');
++ assert.strictEqual(out.explain.community.free_tier, true);
++ assert.strictEqual(out.result.text, 'You said: hello');
++ // Three canary calls, then the request.
++ assert.strictEqual(fake.calls.length, 4);
++ const state = await h.db.prepare('SELECT * FROM community_offers WHERE machine_id = ?').get(out.explain.community.machine_id);
++ assert.strictEqual(state.status, 'verified');
++ assert.ok(Number(state.canary_passes) >= 1, 'one round of three canaries passed');
++ const receipt = buildReceipt({ run: { id: 'run_1' }, explain: out.explain, usage: out.usage, provider: out.provider, model: 'qwen3:32b' });
++ assert.strictEqual(receipt.offer.provider_access, 'COMMUNITY_ENDPOINT');
++ assert.strictEqual(receipt.offer.community, 'endpoint', 'the receipt says a community endpoint answered');
++ assert.match(receipt.offer.provider, /^community-endpoint:ep_/);
++ } finally { await h.stop(); }
++});
++
++t.test('a wrong canary answer or a fingerprint mismatch fails verification and stops traffic', async () => {
++ const h = await boot({ env: { AI_COMMUNITY: 'false' } });
++ try {
++ for (const fake of [fakeEndpoint({ canary: false }), fakeEndpoint({ model: 'some-other-model' })]) {
++ const store = storeFor(h.db, { transport: fake.transport });
++ const created = await store.create({ owner: OWNER, input: INPUT });
++ const community = createCommunity({
++ db: h.db, config: cfg(), env: {}, endpoints: store, fetchImpl: networkFetch(), log: silent,
++ tokens: { getToken: async () => 'test-token' }, clock: { now: () => Date.parse('2026-11-06T12:00:00Z') }, random: () => 1,
++ });
++ await assert.rejects(() => community.execute({ operation: 'chat', req: { messages: [{ role: 'user', content: 'hello' }] },
++ ctx: { communityOptIn: true, creditsRequired: true, runId: 'r2', onBehalfOf: OTHER } }), /canary|answers as/);
++ const row = await h.db.prepare('SELECT status FROM community_offers WHERE offer_id = ?').get(`endpoint:${created.id}`);
++ assert.strictEqual(row.status, 'failed');
++ }
++ } finally { await h.stop(); }
++});
++
++t.test('a request on the seller\'s own account is self-served and earns 0', async () => {
++ const h = await boot({ env: { AI_COMMUNITY: 'false' } });
++ try {
++ const fake = fakeEndpoint();
++ const store = storeFor(h.db, { transport: fake.transport });
++ await store.create({ owner: OWNER, input: INPUT });
++ const community = createCommunity({
++ db: h.db, config: cfg(), env: {}, endpoints: store, fetchImpl: networkFetch(), log: silent,
++ tokens: { getToken: async () => 'test-token' }, clock: { now: () => Date.parse('2026-11-06T12:00:00Z') }, random: () => 1,
++ });
++ const out = await community.execute({ operation: 'chat', req: { messages: [{ role: 'user', content: 'hello' }] },
++ ctx: { communityOptIn: true, creditsRequired: true, runId: 'r3', onBehalfOf: OWNER } });
++ assert.strictEqual(out.explain.community.self_served, true);
++ assert.strictEqual(out.explain.community.credits_micro, 0);
++ assert.strictEqual(out.explain.community.earning_status, 'self');
++ } finally { await h.stop(); }
++});
++
++t.test('a request that opts in reaches the endpoint through the OpenAI route and is labelled', async () => {
++ const fake = fakeEndpoint();
++ const h = await boot({ communityEndpoints: (db) => storeFor(db, { transport: fake.transport }) });
++ try {
++ await h.endpoints.create({ owner: OWNER, input: INPUT });
++ const r = await request(h.base, 'POST', '/v1/chat/completions', {
++ tok: token('live', ALL), headers: { 'x-openvibe-community': '1' },
++ body: { model: 'openvibe/auto', messages: [{ role: 'user', content: 'hi' }] },
++ });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.headers.get('x-openvibe-community'), '1');
++ assert.match(r.body.x_openvibe.provider, /^community-endpoint:ep_/);
++ assert.strictEqual(r.body.x_openvibe.community.kind, 'endpoint');
++ assert.strictEqual(r.body.x_openvibe.community.verification, 'verified');
++ assert.strictEqual(r.body.choices[0].message.content, 'You said: hi');
++ } finally { await h.stop(); }
++});
++
++t.test('about 1% of endpoint answers are re-run on a trusted peer and compared', async () => {
++ const h = await boot({ env: { AI_COMMUNITY: 'false' } });
++ try {
++ const calls = [];
++ const transport = async ({ url, body }) => {
++ const request = JSON.parse(body);
++ calls.push(request);
++ const last = String((request.messages[request.messages.length - 1] || {}).content || '');
++ const canary = last.includes('OPENVIBE-CANARY') ? 'OPENVIBE-CANARY-7F3A' : last.includes('17 + 25') ? '42' : last.includes('canary-ok') ? 'canary-ok' : null;
++ const text = canary || (String(url).includes('a.example') ? 'You said: hello' : 'an answer that shares no words at all with that one');
++ return { status: 200, headers: {}, body: JSON.stringify({ model: 'qwen3:32b', choices: [{ index: 0, message: { role: 'assistant', content: text }, finish_reason: 'stop' }], usage: { prompt_tokens: 5, completion_tokens: 5 } }) };
++ };
++ const store = storeFor(h.db, { transport });
++ // A is cheaper, so it answers; B is verified first, so it is the trusted peer for the spot re-run.
++ const a = await store.create({ owner: OWNER, input: { ...INPUT, base_url: 'https://a.example/v1', price: 10 } });
++ const b = await store.create({ owner: OWNER, input: { ...INPUT, base_url: 'https://b.example/v1', price: 20 } });
++ const offers = await store.liveOffers();
++ const offerB = offers.find((o) => o.endpoint.id === b.id);
++ const community = createCommunity({
++ db: h.db, config: cfg({ spotRate: 0.01 }), env: {}, endpoints: store, fetchImpl: networkFetch(), log: silent,
++ tokens: { getToken: async () => 'test-token' }, clock: { now: () => Date.parse('2026-11-06T12:00:00Z') }, random: () => 0,
++ });
++ await community.verify(offerB, 'qwen3:32b');
++ await assert.rejects(() => community.execute({ operation: 'chat', req: { messages: [{ role: 'user', content: 'hello' }] },
++ ctx: { communityOptIn: true, creditsRequired: true, runId: 'r4', onBehalfOf: OTHER } }), /disagreed with a trusted/);
++ const row = await h.db.prepare('SELECT status FROM community_offers WHERE offer_id = ?').get(`endpoint:${a.id}`);
++ assert.strictEqual(row.status, 'failed', 'a spot mismatch fails the offer');
++ } finally { await h.stop(); }
++});
++
++t.test('the seller\'s concurrency is respected: over it the router falls back', async () => {
++ const h = await boot({ env: { AI_COMMUNITY: 'false' } });
++ try {
++ let release;
++ const gate = new Promise((r) => { release = r; });
++ const transport = async () => {
++ await gate;
++ return { status: 200, headers: {}, body: JSON.stringify({ model: 'qwen3:32b', choices: [{ index: 0, message: { role: 'assistant', content: 'ok' } }], usage: {} }) };
++ };
++ const store = storeFor(h.db, { transport, over: {} });
++ await store.create({ owner: OWNER, input: { ...INPUT, concurrency: 1 } });
++ const offer = (await store.liveOffers())[0];
++ const first = store.chat(offer, { model: 'qwen3:32b', messages: [{ role: 'user', content: 'one' }] });
++ await assert.rejects(() => store.chat(offer, { model: 'qwen3:32b', messages: [{ role: 'user', content: 'two' }] }), /concurrency limit/);
++ release();
++ assert.strictEqual((await first).text, 'ok');
++ } finally { await h.stop(); }
++});
++
++t.run();
ddiff --git a/test/helpers.js b/test/helpers.js
iindex 3430c09..39dc28a 100644
---- a/test/helpers.js
++++ b/test/helpers.js
@@@ -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 } = {}) {
++async function boot({ env = {}, dir, clock, fetchImpl, credentialFetch, research, machineModels, log, variantEvidence, community, communityEndpoints } = {}) {
const reuse = dir !== undefined;
if (dir === undefined) dir = tmpDir();
const fullEnv = {
@@@ -59,7 +59,9 @@ async function boot({ env = {}, dir, clock, fetchImpl, credentialFetch, research
if (reuse) restartable.set(dir, testdb);
// Research reads a canned web (test/fixtures/research-fetch.js) unless a test brings its own: no test reaches the internet.
const researcher = research || require('../server/research').createResearch({ config: config.research, env: {}, fetchImpl: require('./fixtures/research-fetch').createResearchFetch().fetchImpl, log: silent });
-- const h = await start({ config, db: testdb.db, env: fullEnv, log: log || silent, clock, fetchImpl, credentialFetch, research: researcher, machineModels, variantEvidence, community });
++ // 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 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(); } };
}