openvibers/openvibe.exchange Commits

Pool your machines on /sell, and the pool's offer card (#104)

OP OpenVibers committed 6 days ago · e336217a5e · 11 files, +601 −21

* Pool your machines on /sell, and the pool's offer card

The pooled machines design (2026-11-06), part 4. Under the single-machine card, a signed-in person with two or
more of their machines sees "Pool your machines" with the design's four steps as stacked cards: choose the
machines (each with its hardware, free VRAM and RAM, and Node's measured link in words; another network is
greyed with its reason), what fits (measured speeds only, "not measured yet" otherwise), approve on each machine
("Waiting for approval on Laptop" until it approves on itself), and offer it (the same price, audience and
idle-only controls, the privacy sentence, and nothing is offered until every member approves). "Stop pooling"
says what happens.

Exchange reads the person's machines and pools from Network with the person's own token (GET /api/v1/machines,
GET /api/v1/machine-pools; POST to propose and to revoke) and the live community offers from AI
(GET /api/v1/community/offers). It measures no link and keeps no pool. The offer card on model pages and
/models says "Community pool (N machines)" and only the count; the machine names show to the owner on /sell.

Tests: test/pools.test.js covers the one-line explainer with fewer than two machines, a greyed machine on
another network, a failed link with its number and limit, nothing offered before every approval, the public card
with the count only, proposing and stopping through Network, and no invented number when nothing was measured.
Checked at 390 px in dark and light with headless Chrome: scrollWidth 390, every pool control 44 px.

* Merge main: the pool label beside the refusal rate on an offer row

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 3dab5a3..803fb14 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,18 @@ What changed in OpenVibe.Exchange, newest first.
## Unreleased
++- **Pooled machines (`/sell`).** Under the single-machine card, a signed-in person with two or more of their
++ machines sees "Pool your machines": the four steps of the design as stacked cards. **Choose the machines** lists
++ each machine with its GPU, free VRAM and RAM and Node's measured link in words ("2 ms, 940 Mb/s: good"; a failed
++ link says the number and the limit, "40 ms; a pool needs under 5 ms"), and a machine on another network is
++ greyed with its reason. **What fits** is Node's fit table: a measured speed, or "not measured yet". **Approve on
++ each machine** says "Waiting for approval on Laptop" until that machine approves on itself. **Offer it** shows the
++ same price, audience and idle-only controls as one machine, with the privacy sentence (the head sees the prompt,
++ the others only numbers in transit), and nothing is offered until every member approves. "Stop pooling" says what
++ it does. The machines, pools, links and fit come from Network and AI with the person's own token: Exchange
++ measures nothing and keeps no pool. The offer card on model pages and `/models` says "Community pool (N
++ machines)" and only the count; the owner sees the machine names on `/sell`.
++
- **Private AI.** Strict privacy is a switch on `/chat` and on the API page's keys. It sends AI the `private` preset:
only offers that keep nothing and train on nothing (or OpenVibe's own deployments) may answer, the router refuses
with a plain message when none can, and every answer's receipt carries its label (Private, No training, Anonymized,
ddiff --git a/README.md b/README.md
iindex 7b7fb9e..84f3d15 100644
---- a/README.md
++++ b/README.md
@@@ -19,7 +19,7 @@ for a person — and keeps only two things of its own: a person's chats and thei
| `/models`, `/m/<org>/<model>` | Every model and every offer that serves it: price in, out and cached per million, measured p50/p95, uptime, data policy, a **Try it** box, curl and the SDK line. |
| `/chat` | One model, or up to three side by side; system prompt, temperature, history in the account. The shared conversation UI (`openvibe-shared/thread`, `ov-thread.js`). |
| `/api` | The endpoint, model ids (`deepseek/deepseek-chat`, `openvibe/auto`), keys from Network, aggregate usage. |
--| `/sell` | OpenVibe Node's "Offer to the network", the dashboard, and what is **not** sold here (personal plans, personal API keys), said plainly. |
++| `/sell` | OpenVibe Node's "Offer to the network", "Pool your machines" for a signed-in person (the four steps: choose the machines, what fits, approve on each, offer it), the dashboard, and what is **not** sold here (personal plans, personal API keys), said plainly. |
| `/uncensored` | The 18+ gate on the account, the abliterated models and the hard floor. |
| `/ai` | Which OpenVibe products run which models through the same router, and what each costs a day, with the product's own mark. |
| `/status` | Uptime per offer, the model changelog and incidents. |
@@@ -35,8 +35,15 @@ signed-in person is `private, no-store`; a public page is `public, max-age=60`.
never an offer.
- `GET .../api/v1/exchange/usage?days=30` — aggregate requests, tokens and spend by day, model and product
(`usage_daily`; never a prompt, an answer or a person).
--- `POST .../api/v1/runs` with `workflow: ai.complete` and `on_behalf_of` — one chat turn or Try it, on the
-- person's allowance, with this service's token (`ai.run.create`, namespace `ai.complete`).
++- `POST .../api/v1/runs` with `workflow: ai.complete` and `on_behalf_of` — one chat
++ turn or Try it, on the person's allowance, with this service's token (`ai.run.create`, namespace `ai.complete`).
++- `GET .../api/v1/community/offers` — the live community offers; a pool says how many machines serve it (never
++ which ones), so the offer card can say "Community pool (N machines)" without naming a machine.
++- `GET https://openvibe.network/api/v1/machines` and `/api/v1/machine-pools` — the person's own machines, their
++ pools and the machines they can pool, with the person's Network token: names, free VRAM and RAM, the local
++ network key, and each member's approval and readiness. `POST /api/v1/machine-pools` proposes a pool and
++ `POST /api/v1/machine-pools/:id/revoke` stops it. Node measures the links and the fit; Exchange shows what
++ Network carries and says "not measured yet" when it carries nothing.
AI unreachable is said, not papered over: the catalog read keeps the last good copy for five minutes and the
pages carry an honest notice; no number is invented.
ddiff --git a/public/css/exchange.css b/public/css/exchange.css
iindex 0ec3874..ce8a7d2 100644
---- a/public/css/exchange.css
++++ b/public/css/exchange.css
@@@ -604,3 +604,33 @@ body.ex-companion-shell > main.ex { flex: 1; min-height: 0; display: flex; }
.ex-back { display: inline-flex; align-items: center; min-height: 44px; }
.ex-menu-body input[type=file] { min-height: 44px; }
.ex-memory-lock a { display: inline-flex; align-items: center; min-height: 44px; }
++
++/* Pooled machines (design 2026-11-06, part 4): four stacked steps, every control 44 px on a phone, nothing wider
++ than the screen. The numbers are Node's measurements; a machine that cannot join is greyed, never hidden. */
++.ex-pool-steps { display: grid; gap: 14px; margin-top: 14px; }
++.ex-pool-step { padding: 14px 16px; border-radius: 16px; border: 1px solid var(--border, #1f2d47); background: var(--bg-primary, #0a0f1c); min-width: 0; }
++.ex-pool-h { margin: 0 0 10px; font-size: 1rem; font-weight: 800; overflow-wrap: anywhere; }
++.ex-pool-form { display: grid; gap: 12px; }
++.ex-pool-machines { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
++.ex-pool-machine { display: grid; gap: 4px; padding: 10px 12px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #0f1729); min-width: 0; }
++.ex-pool-machine.is-off { opacity: .6; }
++.ex-pool-pick { display: flex; align-items: center; gap: 10px; min-height: 44px; font-weight: 700; overflow-wrap: anywhere; }
++.ex-pool-pick input { width: 20px; height: 20px; flex: none; }
++.ex-pool-detail, .ex-pool-link, .ex-pool-why, .ex-pool-note, .ex-pool-head { margin: 0; font-size: .9rem; line-height: 1.5; color: var(--text-secondary, #96a7c2); overflow-wrap: anywhere; }
++.ex-pool-why, .ex-pool-note { color: var(--text-muted, #7386a3); }
++.ex-pool-link.is-good { color: var(--success-text, #22c55e); font-weight: 700; }
++.ex-pool-link.is-bad { color: var(--error, #ef4444); font-weight: 700; }
++.ex-pool-head b { color: var(--text-primary, #e6edf7); }
++.ex-pool-approvals { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
++.ex-pool-approval { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; min-height: 44px; padding: 6px 10px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); }
++.ex-pool-controls { display: grid; gap: 10px; margin: 0 0 10px; }
++.ex-pool-controls dt { font-weight: 800; font-size: .82rem; letter-spacing: .04em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
++.ex-pool-controls dd { margin: 2px 0 0; line-height: 1.5; color: var(--text-secondary, #96a7c2); }
++.ex-pool-stop { margin-top: 12px; }
++.ex-pool .ex-field select, .ex-pool .ex-btn, .ex-pool .ovk-btn { min-height: 44px; }
++.ex-pool .ex-field select { width: 100%; max-width: 100%; }
++@media (max-width: 560px) {
++ .ex-pool-step { padding: 12px; }
++ .ex-pool-approval { align-items: flex-start; }
++ .ex-pool .ex-field select { width: 100%; }
++}
ddiff --git a/server/ai.js b/server/ai.js
iindex 9623b4b..1e86f3b 100644
---- a/server/ai.js
++++ b/server/ai.js
@@@ -4,6 +4,8 @@
*
* catalog() GET /api/v1/exchange/catalog the models, offers, prices, speed and uptime (public)
* usage(days) GET /api/v1/exchange/usage requests, tokens and spend, by day, model and product (public)
++ * communityOffers() GET /api/v1/community/offers the live community offers (public): a pool says how many
++ * machines serve it, never which ones
* chat({...}) POST /api/v1/runs one ai.complete run, on behalf of the person, with this
* service's token (ai.run.create, ns ai.complete)
*
@@@ -15,6 +17,7 @@ const { createServiceTokenClient } = require('openvibe-sdk/auth');
const CATALOG_MS = 30_000;
const USAGE_MS = 60_000;
++const COMMUNITY_MS = 30_000;
async function jsonOf(res) {
const text = await res.text();
@@@ -31,6 +34,7 @@ function createAi({ config, fetchImpl = globalThis.fetch, log = console, now = (
const empty = (what) => ({ generated_at: new Date(now()).toISOString(), unavailable: true, count: 0, models: [], families: [], by_day: [], by_model: [], by_product: [], today: null, totals: null, what });
let catalogHit = { at: 0, value: null };
let usageHit = new Map();
++ let communityHit = { at: 0, value: null };
let warnedAt = 0;
const warned = (what, err) => {
@@@ -78,6 +82,24 @@ function createAi({ config, fetchImpl = globalThis.fetch, log = console, now = (
}
}
++ /**
++ * The live community offers, as AI lets anyone read them: a pool's `pool` names its members only by stable
++ * pseudonym and its count, so the public card shows "Community pool (N machines)" and never a machine.
++ */
++ async function communityOffers() {
++ if (communityHit.value && now() - communityHit.at < COMMUNITY_MS) return communityHit.value;
++ try {
++ const body = await get('/api/v1/community/offers', 'community offers');
++ const value = { generated_at: body.generated_at || new Date(now()).toISOString(), offers: Array.isArray(body.offers) ? body.offers : [] };
++ communityHit = { at: now(), value };
++ return value;
++ } catch (err) {
++ warned('community offers', err);
++ if (communityHit.value && now() - communityHit.at < 5 * 60_000) return { ...communityHit.value, stale: true };
++ return { generated_at: new Date(now()).toISOString(), unavailable: true, offers: [] };
++ }
++ }
++
/** One completion, as the person: the run's quota and free allowance are theirs (on_behalf_of). */
async function chat({ model, messages, temperature = null, maxTokens = 1024, subject, signal = null, policy = null, preset = null, max_cost_usd = null }) {
if (!tokens) throw Object.assign(new Error('AI is not configured for this service'), { code: 'ai.not_configured', status: 503 });
@@@ -248,7 +270,7 @@ function createAi({ config, fetchImpl = globalThis.fetch, log = console, now = (
async function ownCreate(token, body) { return ownCall('POST', '/api/v1/own-endpoints', { token, body }); }
async function ownStop(token, id) { return ownCall('POST', `/api/v1/own-endpoints/${encodeURIComponent(id)}/stop`, { token }); }
-- return { catalog, usage, chat, presetsFor, setKeyPrivacy, ownedProjects, ownStatus, ownList, ownCreate, ownStop, configured: Boolean(tokens) };
++ return { catalog, usage, communityOffers, chat, presetsFor, setKeyPrivacy, ownedProjects, ownStatus, ownList, ownCreate, ownStop, configured: Boolean(tokens) };
}
/**
ddiff --git a/server/app.js b/server/app.js
iindex 3089c13..3d34a67 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -28,6 +28,7 @@ const { createKeyStore } = require('./auth/keys');
const { createSso } = require('./auth/sso');
const { createPrincipal } = require('./http/principal');
const { createAi } = require('./ai');
++const { createNetwork } = require('./network');
const { createBench } = require('./bench');
const { createOpenApi } = require('./openapi');
const { createArenaStore } = require('./arena/store');
@@@ -74,6 +75,8 @@ async function createApp(opts = {}) {
const principal = createPrincipal({ config, keys });
const ai = opts.ai || createAi({ config, fetchImpl: opts.aiFetchImpl || globalThis.fetch, log, now: s.now });
++ // Network, read and changed with the person's own token: their machines and their machine pools.
++ const network = opts.network || createNetwork({ config, fetchImpl, log });
const bench = opts.bench || createBench({ config, fetchImpl: opts.benchFetchImpl || globalThis.fetch, log, now: s.now });
const arenaStore = createArenaStore(s);
const history = createCatalogHistory({ s, log });
@@@ -106,7 +109,7 @@ async function createApp(opts = {}) {
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Exchange cannot answer account events'); });
const openapi = createOpenApi({ config, fetchImpl: opts.openapiFetch || globalThis.fetch, log });
-- const ctx = { config, s, keys, sso, principal, ai, bench, arenaStore, history, openapi, chats, adult, adultAccount, characters, companionChats, groups, voice, sharing, accountData, accountSend, memory, notes, lorebooks, locker, noteTaker, log };
++ const ctx = { config, s, keys, sso, principal, ai, network, bench, arenaStore, history, openapi, chats, adult, adultAccount, characters, companionChats, groups, voice, sharing, accountData, accountSend, memory, notes, lorebooks, locker, noteTaker, log };
const app = express();
app.disable('x-powered-by');
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 481f91a..a8338d9 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -34,7 +34,7 @@ const RETIRED_PATHS = ['/listings', '/listings/*', '/submit', '/sandbox', '/sand
const textOf = (s) => esc(String(s || '')).replace(/\n/g, '<br>');
function createPageRoutes(ctx) {
-- const { config, ai, history, openapi, chats, adult, sharing, log } = ctx;
++ const { config, ai, network, history, openapi, chats, adult, sharing, log } = ctx;
const r = asyncRouter();
r.use(express.urlencoded({ extended: false, limit: '64kb' }));
const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
@@@ -45,6 +45,8 @@ function createPageRoutes(ctx) {
title, index: false,
body: raw(`<div class="ex-wrap ex-narrow">${kit.empty({ icon: 'person', title, text, action: { href: `/auth/login?next=${encodeURIComponent(next || req.originalUrl)}`, label: 'Sign in with OpenVibe' } })}</div>`),
}, status);
++ // The live community offers (AI's public read): a pool says how many machines serve it, never which ones.
++ const communityOffers = async () => (typeof ai.communityOffers === 'function' ? ai.communityOffers() : null);
// The catalogue is also the day's changelog row: the first read of a UTC day records what every model
// costs, so /status can say what is new, gone or repriced without keeping a second catalogue.
const catalogOf = async () => {
@@@ -89,7 +91,7 @@ function createPageRoutes(ctx) {
// ── Models ───────────────────────────────────────────────
r.get('/models', async (req, res) => {
-- const catalog = await catalogOf();
++ const [catalog, community] = await Promise.all([catalogOf(), communityOffers()]);
const q = typeof req.query.q === 'string' ? req.query.q.slice(0, 120) : '';
const family = typeof req.query.family === 'string' ? req.query.family.slice(0, 60) : '';
const sort = ['cheap', 'fast'].includes(req.query.sort) ? req.query.sort : '';
@@@ -97,7 +99,7 @@ function createPageRoutes(ctx) {
index: true, cache: PUBLIC_CACHE, agentLayer: true,
title: q ? `Models: ${q}` : 'Models',
description: 'Every model on OpenVibe.Exchange with its live offers: price in and out per million tokens, measured speed and uptime, context length and data policy.',
-- body: raw(views.modelsPage({ catalog, q, family, sort })),
++ body: raw(views.modelsPage({ catalog, q, family, sort, community })),
});
});
@@@ -108,7 +110,7 @@ function createPageRoutes(ctx) {
const modelPath = (m) => `/m/${m.slug.split('/').map(encodeURIComponent).join('/')}`;
async function renderModel(req, res, { result = null, problem = null, text = '', status = 200 } = {}) {
-- const catalog = await catalogOf();
++ const [catalog, community] = await Promise.all([catalogOf(), communityOffers()]);
const model = await modelFrom(catalog, req.params.org, req.params.model);
if (!model) {
if (catalog.unavailable) {
@@@ -125,7 +127,7 @@ function createPageRoutes(ctx) {
index: true, cache: req.method === 'GET' ? PUBLIC_CACHE : null, agentLayer: true,
title: model.name, description: `${model.name}: price, speed, uptime and data policy of every offer.`,
jsonLd: [jsonLd], head: '',
-- body: raw(views.modelPage({ model, viewer: req.viewer, result, problem, text, adult: isAdult })),
++ body: raw(views.modelPage({ model, viewer: req.viewer, result, problem, text, adult: isAdult, community })),
}, status);
}
@@@ -135,11 +137,11 @@ function createPageRoutes(ctx) {
if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
const text = String((req.body && req.body.text) || '').slice(0, config.chat.maxChars);
if (!text.trim()) return renderModel(req, res, { problem: 'Write something to send.', status: 422 });
-- const catalog = await catalogOf();
++ const [catalog, community] = await Promise.all([catalogOf(), communityOffers()]);
const model = await modelFrom(catalog, req.params.org, req.params.model);
if (!model) return renderModel(req, res);
if (model.uncensored && !(await adult.confirmed(owner(req)))) {
-- return page(req, res, { title: model.name, index: false, body: raw(views.modelPage({ model, viewer: req.viewer, problem: 'Confirm you are 18 or over first.', adult: false })) }, 403);
++ return page(req, res, { title: model.name, index: false, body: raw(views.modelPage({ model, viewer: req.viewer, problem: 'Confirm you are 18 or over first.', adult: false, community })) }, 403);
}
const best = model.offers.find((o) => o.id) || model.offers[0];
const modelId = (best && best.id) || 'openvibe/auto';
@@@ -629,14 +631,66 @@ ${m.meta && m.meta.preset ? `<dt>Preset</dt><dd>${esc(m.meta.preset)}</dd>` : ''
});
// ── Sell ─────────────────────────────────────────────────
-- r.get('/sell', async (req, res) => {
-- const [catalog, usage] = await Promise.all([catalogOf(), ai.usage(30)]);
++ // The person's machines and pools come from Network with their own token (never a service token); the live
++ // community offers come from AI's public read. Exchange measures nothing and keeps no pool of its own.
++ async function sellState(req) {
++ const [catalog, usage, community] = await Promise.all([catalogOf(), ai.usage(30), communityOffers()]);
++ let pool = null;
++ if (signedIn(req)) {
++ const token = req.token || (req.viewer && req.viewer.token);
++ try {
++ const [machines, pools] = await Promise.all([network.machines(token), network.pools(token)]);
++ pool = { machines, ...pools };
++ } catch (err) {
++ log.warn('[OpenVibe.Exchange] your machines read failed:', (err && err.message) || err);
++ pool = { machines: [], candidates: [], pools: [], error: 'Your machines could not be read right now. Reload in a moment.' };
++ }
++ }
++ return { catalog, usage, community, pool };
++ }
++
++ async function renderSell(req, res, { error = null } = {}) {
++ const state = await sellState(req);
++ if (error && state.pool) state.pool = { ...state.pool, error };
return page(req, res, {
index: true, cache: PUBLIC_CACHE, agentLayer: true,
title: 'Sell your compute',
description: 'Turn a GPU or a spare machine into credits with OpenVibe Node: offer models, hours, concurrency and a price. What is not sold here, said plainly.',
-- body: raw(views.sellPage({ catalog, usage, viewer: req.viewer })),
++ body: raw(views.sellPage({ ...state, viewer: req.viewer, signedIn: signedIn(req) })),
});
++ }
++
++ r.get('/sell', (req, res) => renderSell(req, res));
++
++ // "1. Choose the machines": Network holds the pool (it refuses another owner's machine, another network, or a
++ // machine already in a pool), and each machine approves it on itself afterwards.
++ r.post('/sell/pool', async (req, res) => {
++ if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Fsell');
++ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
++ const machines = [...new Set([].concat((req.body && req.body.machines) || [])
++ .map(String).filter((id) => /^dev_[0-9A-HJKMNP-TV-Z]{26}$/.test(id)))].slice(0, 8);
++ if (machines.length < 2) return renderSell(req, res, { error: 'Choose two or more of your machines on the same network.' });
++ const head = /^dev_[0-9A-HJKMNP-TV-Z]{26}$/.test(String((req.body && req.body.head) || '')) ? String(req.body.head) : null;
++ const token = req.token || (req.viewer && req.viewer.token);
++ try {
++ await network.proposePool(token, { machines, ...(head ? { head } : {}) });
++ return res.redirect(303, '/sell#pool');
++ } catch (err) {
++ return renderSell(req, res, { error: (err && err.message) || 'The pool could not be created.' });
++ }
++ });
++
++ // "Stop pooling": the offer goes offline and each machine drops its private link and its key.
++ r.post('/sell/pool/:id/stop', async (req, res) => {
++ if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Fsell');
++ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
++ const token = req.token || (req.viewer && req.viewer.token);
++ try {
++ await network.revokePool(token, String(req.params.id));
++ return res.redirect(303, '/sell#pool');
++ } catch (err) {
++ return renderSell(req, res, { error: (err && err.message) || 'The pool could not be stopped.' });
++ }
});
// ── Your own endpoint ────────────────────────────────────
ddiff --git a/server/network.js b/server/network.js
nnew file mode 100644
iindex 0000000..d76004d
---- /dev/null
++++ b/server/network.js
@@@ -0,0 +1,75 @@
++'use strict';
++/**
++ * OpenVibe.Network, read and changed for a person with their own Network token (never a service token).
++ *
++ * machines(token) GET /api/v1/machines their machines: names and whether each is online
++ * pools(token) GET /api/v1/machine-pools their pools, and the machines they can pool
++ * proposePool(token, body) POST /api/v1/machine-pools { machines, head? } → the new pool
++ * revokePool(token, id) POST /api/v1/machine-pools/:id/revoke
++ *
++ * Network is the pool's record (bot.machine-pool@1, Network PR #609): Exchange reads it and never measures a link
++ * or keeps a pool of its own. Node measures each link and the fit on the head; a pool record carries that read as
++ * `links` and `fit` when Network has it, and the page says "not measured yet" until then.
++ */
++function createNetwork({ config, fetchImpl = globalThis.fetch, log = console }) {
++ let warnedAt = 0;
++ const warned = (what, err) => {
++ if (Date.now() - warnedAt < 60_000) return;
++ warnedAt = Date.now();
++ log.warn(`[exchange] Network ${what} unavailable: ${String((err && err.message) || err).slice(0, 200)}`);
++ };
++
++ async function call(token, method, path, body = undefined) {
++ const headers = { Accept: 'application/json' };
++ if (token) headers.Authorization = `Bearer ${token}`;
++ if (body !== undefined) headers['Content-Type'] = 'application/json';
++ let res;
++ try {
++ res = await fetchImpl(`${config.networkUrl}${path}`, {
++ method, headers, ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
++ signal: AbortSignal.timeout(8000),
++ });
++ } catch (err) {
++ warned(path, err);
++ throw Object.assign(new Error('OpenVibe.Network is unreachable right now'), { code: 'network.unavailable', status: 503 });
++ }
++ const text = await res.text();
++ let out = null;
++ try { out = text ? JSON.parse(text) : null; } catch { /* not JSON: the status says it */ }
++ if (!res.ok) {
++ const err = new Error((out && (out.detail || out.error || out.title)) || `Network answered ${res.status}`);
++ err.code = (out && out.code) || 'network.failed';
++ err.status = res.status;
++ throw err;
++ }
++ return out || {};
++ }
++
++ /** The person's machines: [{ id, name?, online }], never another owner's. */
++ async function machines(token) {
++ const out = await call(token, 'GET', '/api/v1/machines');
++ return Array.isArray(out.machines) ? out.machines : [];
++ }
++
++ /** Their pools and the machines that announced themselves in the last five minutes (no address, no public IP). */
++ async function pools(token) {
++ const out = await call(token, 'GET', '/api/v1/machine-pools');
++ return {
++ pools: Array.isArray(out.pools) ? out.pools : [],
++ candidates: Array.isArray(out.candidates) ? out.candidates : [],
++ };
++ }
++
++ async function proposePool(token, body) {
++ const out = await call(token, 'POST', '/api/v1/machine-pools', body);
++ return out.pool || null;
++ }
++
++ async function revokePool(token, id) {
++ return call(token, 'POST', `/api/v1/machine-pools/${encodeURIComponent(id)}/revoke`);
++ }
++
++ return { machines, pools, proposePool, revokePool };
++}
++
++module.exports = { createNetwork };
ddiff --git a/server/views/exchange.js b/server/views/exchange.js
iindex f3fd5d0..17fd7ac 100644
---- a/server/views/exchange.js
++++ b/server/views/exchange.js
@@@ -267,7 +267,7 @@ function productsSummary(usage) {
}
// ── Models ─────────────────────────────────────────────────
--function modelsPage({ catalog, q = '', family = '', sort = '' }) {
++function modelsPage({ catalog, q = '', family = '', sort = '', community = null }) {
let models = chatModels(catalog);
const families = [...new Set(models.map(familyLabel))].filter((f) => f !== 'Other').sort();
if (family) models = models.filter((m) => familyLabel(m) === family);
@@@ -289,12 +289,13 @@ ${kit.find({ action: '/models', name: 'q', placeholder: 'Find a model or a provi
${families.length ? `<div class="ex-chips" role="group" aria-label="Family">${familyLinks}</div>` : ''}
${unavailable(catalog)}
${panel({ title: '', body: modelList(models, { empty: 'No model matches that search. Try a family or a provider.' }) })}
++${communityPools(community)}
<p class="ex-more"><a href="/status">Uptime and incidents →</a></p>
</div>`;
}
// ── One model ──────────────────────────────────────────────
--function modelPage({ model, viewer, result = null, problem = null, text = '', adult = false }) {
++function modelPage({ model, viewer, result = null, problem = null, text = '', adult = false, community = null }) {
const offers = model.offers || [];
const best = cheapest(model);
const badges = [model.uncensored ? kit.badge('18+', 'bad') : '', model.community ? kit.badge('Community', 'accent') : '', offers.some(freeTier) ? kit.badge('Free to try') : (offers.some(freePaused) ? kit.badge('Free paused') : '')].join('');
@@@ -317,9 +318,14 @@ function modelPage({ model, viewer, result = null, problem = null, text = '', ad
];
const rows = offers.map((o) => {
const p = privacyOf(o);
++ // A pool's head is the offer: the label says how many machines serve it, never which ones (the count is public).
++ const pools = poolOffersFor(community, o.model);
++ const communityBadge = pools.length
++ ? kit.badge(poolLabel((pools[0].pool && pools[0].pool.machines && pools[0].pool.machines.length) || 0), 'accent')
++ : (o.trust === 'community' ? kit.badge('Community', 'accent') : '');
const refuses = refusalText(o);
return {
-- provider: `<a class="ex-offer-name" href="#try">${esc(providerLabel(o))}</a>${o.trust === 'community' ? ` ${kit.badge('Community', 'accent')}` : ''}${freeTier(o) ? ` ${kit.badge('Free to try')}` : (freePaused(o) ? ` ${kit.badge('Free paused')}` : '')}`,
++ provider: `<a class="ex-offer-name" href="#try">${esc(providerLabel(o))}</a> ${communityBadge}${freeTier(o) ? ` ${kit.badge('Free to try')}` : (freePaused(o) ? ` ${kit.badge('Free paused')}` : '')}`,
price: o.price ? `<b>${money(o.price.in)}</b> / ${money(o.price.out)}` : '<span class="ex-muted">free capacity</span>',
speed: `${ms(o.speed.p50_ms)} <small>p50</small> · ${ms(o.speed.p95_ms)} <small>p95</small>`,
uptime: o.uptime == null ? '–' : pct(o.uptime),
@@@ -493,10 +499,165 @@ function modelLabel(id) {
return known[bare] || require('../ai').displayName(bare);
}
++// ── Pooled machines ────────────────────────────────────────
++// The pooled machines design (2026-11-06), part 4: "Pool your machines" under the single-machine card on /sell,
++// and the pool's offer card. Exchange reads the person's machines, their pools, the link checks and the fit table
++// from Network and AI; it never measures a link and never keeps a pool of its own. A number appears only when
++// Node measured it; a model nobody measured says "not measured yet".
++const NEAR_MS = 5; // a pool needs under 5 ms round trip
++const NEAR_MBPS = 300; // 1 Gb/s for models over 30B; 300 Mb/s below
++const gb = (mb) => (mb == null || !Number.isFinite(Number(mb)) ? '–' : `${(Number(mb) / 1024).toFixed(Number(mb) < 1024 ? 1 : 0)} GB`);
++const poolLabel = (n) => `Community pool (${Number(n) || 0} machine${Number(n) === 1 ? '' : 's'})`;
++const isPoolOffer = (o) => Boolean(o && (o.kind === 'pool' || (o.pool && Array.isArray(o.pool.machines)))) && Boolean(o.pool);
++
++/** The public pool offers that serve a model: AI names the members only by pseudonym, so only the count is shown. */
++function poolOffersFor(community, modelName) {
++ return ((community && community.offers) || []).filter((o) => isPoolOffer(o) && (o.models || []).includes(modelName));
++}
++
++/** A link as Node measured it, in words: the number and the limit when it fails. Null when nobody measured it.
++ * Without an id, every link is read: the head answers over all of them, so its worst one is its own. */
++function linkWords(links, id) {
++ const mine = (links || []).filter((l) => l && (id == null || l.machine === id) && l.rtt_ms != null && l.mbps != null && Number.isFinite(Number(l.rtt_ms)) && Number.isFinite(Number(l.mbps)));
++ if (!mine.length) return null;
++ const slow = mine.find((l) => !(Number(l.rtt_ms) < NEAR_MS));
++ if (slow) return { good: false, text: `${Math.round(Number(slow.rtt_ms))} ms; a pool needs under ${NEAR_MS} ms` };
++ const thin = mine.find((l) => !(Number(l.mbps) >= NEAR_MBPS));
++ if (thin) return { good: false, text: `${Math.round(Number(thin.mbps))} Mb/s; a pool needs ${NEAR_MBPS} Mb/s or more` };
++ const best = mine.reduce((a, b) => (Number(a.rtt_ms) <= Number(b.rtt_ms) && Number(a.mbps) >= Number(b.mbps) ? a : b));
++ return { good: true, text: `${Math.round(Number(best.rtt_ms))} ms, ${Math.round(Number(best.mbps))} Mb/s: good` };
++}
++
++/** The public card for a pool: the count and what it serves, never a machine's name. */
++function communityPools(community) {
++ const offers = ((community && community.offers) || []).filter(isPoolOffer);
++ if (!offers.length) return '';
++ const rows = offers.map((o) => ({
++ pool: `${esc(poolLabel(o.pool.machines.length))}${o.free_only ? ` ${kit.badge('Free tier only', '')}` : ''}`,
++ models: (o.models || []).map(String).join(', '),
++ price: o.price ? `${money(o.price.in_per_million)} / ${money(o.price.out_per_million)} per million` : 'volunteer (free)',
++ state: o.ready && o.open_now !== false ? 'offering now' : 'offline',
++ }));
++ return panel({
++ title: 'Community pools', id: 'pools',
++ note: 'One model served by several of a person’s machines on their own home network.',
++ body: `${kit.table({ columns: [{ key: 'pool', label: 'Pool', primary: true, html: true }, { key: 'models', label: 'Serves' }, { key: 'price', label: 'Price' }, { key: 'state', label: 'State' }], rows, label: 'Community pools' })}
++<p class="ex-prose">The pool’s head sees the prompt; the other machines see only numbers in transit. A new pool serves the free tier until the same canary checks as one machine pass.</p>`,
++ });
++}
++
++/**
++ * The pool's four steps (Opus's design, part 4). `pool` is what Network answered for this person:
++ * { machines, candidates, pools, error }. A pool record may carry `links` and `fit`, Node's own measurements on
++ * the head; when it does not, the page says "not measured yet" instead of inventing a number.
++ */
++function poolPanel({ pool, community }) {
++ const active = (pool.pools || [])[0] || null;
++ const machines = pool.machines || [];
++ const byId = new Map(machines.map((m) => [m.id, m]));
++ const nameOf = (id) => (byId.get(id) && byId.get(id).name) || 'your machine';
++ const candidates = (pool.candidates || []).filter((c) => c && c.id);
++ const links = (active && Array.isArray(active.links) ? active.links : pool.links) || [];
++
++ const head = () => {
++ if (active) return active.head;
++ const same = candidates.filter((c) => c.network === mainKey);
++ return ([...same].sort((a, b) => (Number(b.free_vram) || 0) - (Number(a.free_vram) || 0))[0] || {}).id || null;
++ };
++ // The network most of the machines are on: the one a pool would use. Another network is listed, greyed, with why.
++ const groups = new Map();
++ for (const c of candidates) {
++ const k = String(c.network || 'unknown');
++ groups.set(k, (groups.get(k) || []).concat([c]));
++ }
++ const mainKey = [...groups.entries()].sort((a, b) => b[1].length - a[1].length)[0] ? [...groups.entries()].sort((a, b) => b[1].length - a[1].length)[0][0] : null;
++ const mainMachines = candidates.filter((c) => c.network === mainKey);
++ const reference = [...mainMachines].sort((a, b) => (Number(b.free_vram) || 0) - (Number(a.free_vram) || 0))[0];
++ const headId = head();
++ const headMachine = candidates.find((c) => c.id === headId) || null;
++ const headName = nameOf(headId);
++
++ const row = (c, { selectable }) => {
++ const sameNetwork = c.network === mainKey;
++ // The head answers over every link, so its row reads the worst of them (all of them when it has no id of its own).
++ const link = sameNetwork ? linkWords(links, c.id === headId ? null : c.id) : null;
++ const detail = `${esc(c.hardware || 'hardware not reported')} · ${gb(c.free_vram)} free VRAM · ${gb(c.free_ram)} free RAM`;
++ const linkLine = !sameNetwork
++ ? `<p class="ex-pool-why">Not on the same network as ${esc(reference ? nameOf(reference.id) : 'your other machines')}: pools work on one home network, not across the internet</p>`
++ : `<p class="ex-pool-link${link ? (link.good ? ' is-good' : ' is-bad') : ''}">${link ? esc(link.text) : 'link not measured yet'}</p>`;
++ const pick = selectable && sameNetwork
++ ? `<label class="ex-pool-pick"><input type="checkbox" name="machines" value="${esc(c.id)}" checked> <span><b>${esc(nameOf(c.id))}</b></span></label>`
++ : `<div class="ex-pool-pick"><b>${esc(nameOf(c.id))}</b>${active && active.machines.some((m) => m.id === c.id && m.id === active.head) ? ' <span class="ex-kind">head</span>' : ''}</div>`;
++ return `<li class="ex-pool-machine${sameNetwork ? '' : ' is-off'}">${pick}<p class="ex-pool-detail">${detail}</p>${linkLine}</li>`;
++ };
++
++ const steps = [];
++ if (active) {
++ // A pool is never edited: a change is a new pool with new approvals (Network's rule).
++ const rows = active.machines.map((m) => {
++ const c = candidates.find((x) => x.id === m.id);
++ return c ? row(c, { selectable: false }) : `<li class="ex-pool-machine"><div class="ex-pool-pick"><b>${esc(nameOf(m.id))}</b></div><p class="ex-pool-detail">no longer announcing itself</p></li>`;
++ }).join('');
++ steps.push(`<article class="ex-pool-step"><h3 class="ex-pool-h">1. Choose the machines</h3>
++<ul class="ex-pool-machines">${rows}</ul>
++<p class="ex-pool-note">A pool is never edited: to change the head or the machines, stop it and pool again.</p>
++<form method="post" action="/sell/pool/${encodeURIComponent(active.id)}/stop" class="ex-pool-stop">
++<p class="ex-prose">The offer goes offline and each machine stops sharing its memory with the others.</p>
++${kit.button({ label: 'Stop pooling', type: 'submit', kind: 'quiet' })}
++</form></article>`);
++ } else {
++ const rows = candidates.map((c) => row(c, { selectable: true })).join('');
++ const headOptions = mainMachines
++ .sort((a, b) => (Number(b.free_vram) || 0) - (Number(a.free_vram) || 0))
++ .map((c) => `<option value="${esc(c.id)}"${headMachine && c.id === headMachine.id ? ' selected' : ''}>${esc(nameOf(c.id))}${c.free_vram != null ? ` · ${gb(c.free_vram)} free VRAM` : ''}</option>`).join('');
++ steps.push(`<article class="ex-pool-step"><h3 class="ex-pool-h">1. Choose the machines</h3>
++<form method="post" action="/sell/pool" class="ex-pool-form">
++<ul class="ex-pool-machines">${rows}</ul>
++<label class="ex-field"><span>Head (the machine that answers)</span><select name="head">${headOptions}</select></label>
++<p class="ex-pool-note">Each machine approves the pool on itself, in OpenVibe Node.</p>
++${kit.button({ label: 'Pool these machines', type: 'submit' })}
++</form></article>`);
++ }
++
++ // 2. What fits: only measured speeds; the head is the machine with the most free VRAM.
++ const fit = (active && Array.isArray(active.fit) ? active.fit : []).filter((f) => f && f.model && f.fits !== false);
++ const speedWords = (f) => (f.tokens_per_second != null && Number.isFinite(Number(f.tokens_per_second)) ? `about ${Math.round(Number(f.tokens_per_second))} tokens a second` : 'not measured yet');
++ const fitBody = fit.length
++ ? kit.table({ columns: [{ key: 'model', label: 'Model', primary: true }, { key: 'speed', label: 'Measured speed' }], rows: fit.map((f) => ({ model: String(f.model), speed: speedWords(f) })), label: 'What fits' })
++ : '<p class="ex-prose">No model has been measured on these machines yet. Run the fit check on the head in OpenVibe Node; a model that has not been measured says so here.</p>';
++ steps.push(`<article class="ex-pool-step"><h3 class="ex-pool-h">2. What fits</h3>
++<p class="ex-pool-head">Head: <b>${esc(headName)}</b>, the machine with the most free VRAM.${active ? ' Change it on your machines page: a new head is a new pool, and every machine approves it.' : ''}</p>
++${fitBody}</article>`);
++
++ // 3. Approve on each machine.
++ const members = active ? active.machines : [];
++ const approval = members.length
++ ? `<ul class="ex-pool-approvals">${members.map((m) => `<li class="ex-pool-approval"><span>${m.approved ? `<b>${esc(nameOf(m.id))}</b>` : `Waiting for approval on <b>${esc(nameOf(m.id))}</b>`}</span>${m.approved ? kit.badge('Approved', 'ok') : kit.badge('Waiting', '')}</li>`).join('')}</ul>
++<p class="ex-pool-note">Approve in OpenVibe Node on that machine, or on its machine page on OpenVibe.Network.</p>`
++ : '<p class="ex-prose">Approve on each machine, in OpenVibe Node or on its machine page on OpenVibe.Network. Each machine makes its own key there.</p>';
++ steps.push(`<article class="ex-pool-step"><h3 class="ex-pool-h">3. Approve on each machine</h3>${approval}</article>`);
++
++ // 4. Offer it: the same controls as a single machine, and nothing until every member approves.
++ const allApproved = members.length >= 2 && members.every((m) => m.approved);
++ const controls = `<dl class="ex-pool-controls">
++<div><dt>Price</dt><dd>one price for the whole pool, set with the offer on the head in OpenVibe Node.</dd></div>
++<div><dt>Who may use it</dt><dd>everyone, your followers or a group: the same audience as a single machine, applied to the pool.</dd></div>
++<div><dt>Idle only</dt><dd>the whole pool pauses while any member is in use.</dd></div>
++</dl>
++<p class="ex-pool-note">Prompts reach your head machine (${esc(headName)}); the others only see numbers in transit.</p>`;
++ steps.push(`<article class="ex-pool-step"><h3 class="ex-pool-h">4. Offer it</h3>${allApproved ? controls : '<p class="ex-prose">Nothing is offered until every member approves.</p>'}</article>`);
++
++ return panel({
++ title: 'Pool your machines', id: 'pool',
++ body: `<p class="ex-prose">Machines on the same home network can run one bigger model together. OpenVibe checks the link between them first.</p>
++<div class="ex-pool ex-pool-steps">${steps.join('')}</div>`,
++ });
++}
++
// ── Sell compute ───────────────────────────────────────────
const HOUSE_SERVER = /^local\d*$/;
--function sellPage({ catalog, usage }) {
++function sellPage({ catalog, usage, pool = null, community = null, signedIn = false }) {
// OpenVibe's own model servers on its hosts (`local`, `local2`) carry community trust so the router treats their
// privacy conservatively, but nobody sells them: only machines people offer through Node are listed here.
const offers = (catalog.models || []).flatMap((m) => m.offers.filter((o) => o.trust === 'community' && !HOUSE_SERVER.test(String(o.provider || ''))).map((o) => ({ model: m, offer: o })));
@@@ -509,9 +670,17 @@ function sellPage({ catalog, usage }) {
price: offer.price ? `${money(offer.price.in)} / ${money(offer.price.out)}` : 'volunteer (free)',
uptime: offer.uptime == null ? '–' : pct(offer.uptime),
}));
++ // The pool card sits under the single-machine one, only for a signed-in person. With fewer than two machines
++ // it is one line and the install link (part 4 of the design).
++ const poolSection = !signedIn || !pool ? '' : pool.error
++ ? panel({ title: 'Pool your machines', id: 'pool', body: kit.notice({ kind: 'warn', text: esc(pool.error) }) })
++ : (pool.candidates || []).length < 2
++ ? panel({ title: 'Pool your machines', id: 'pool', body: `<p class="ex-prose">Pooling needs two or more of your machines on the same network. Add one with <a href="https://node.openvibe.network">OpenVibe Node</a>.</p>` })
++ : poolPanel({ pool, community });
return `<div class="ex-wrap ex-page">
${kit.head({ title: 'Sell your compute', sub: 'Turn a GPU or a spare machine into credits.', actions: [kit.button({ href: 'https://node.openvibe.network', label: 'Get Node', kind: 'quiet' })] })}
${panel({ title: 'A machine on the network', id: 'seller', body: `${seller}<p class="ex-prose">Every offer shows this card: the machine, what it runs, how fast it answers, its uptime, and whether it is verified.</p>` })}
++${poolSection}
${panel({ title: 'Turn it on', body: `
<div class="ex-steps"><ol>
<li>Install <a href="https://node.openvibe.network">OpenVibe Node</a> on the machine and sign in.</li>
ddiff --git a/test/helpers/ai.js b/test/helpers/ai.js
iindex a28b991..fa2d877 100644
---- a/test/helpers/ai.js
++++ b/test/helpers/ai.js
@@@ -3,7 +3,7 @@
* A stand-in OpenVibe.AI client for the page tests: the shape of GET /api/v1/exchange/catalog and /usage, and a
* chat that answers in the run's voice. It records every chat call so a test can assert what was sent.
*/
--function createFakeAi({ chat = null } = {}) {
++function createFakeAi({ chat = null, community = [] } = {}) {
const calls = [];
const catalog = {
generated_at: '2026-11-06T09:00:00.000Z', window_days: 7, count: 3,
@@@ -66,11 +66,13 @@ function createFakeAi({ chat = null } = {}) {
by_model: [{ provider: 'deepseek', model: 'deepseek-chat', requests: 40, tokens_in: 4000, tokens_out: 1000, cost_usd: 0.006 }],
by_product: [{ product: 'live', requests: 30, tokens_in: 3000, tokens_out: 800, cost_usd: 0.005, models: [{ provider: 'deepseek', model: 'deepseek-chat', requests: 30 }] }],
};
++ const communityData = { generated_at: '2026-11-06T09:00:00.000Z', offers: community };
return {
calls,
configured: true,
async catalog() { return catalog; },
async usage() { return usage; },
++ async communityOffers() { return communityData; },
async ownStatus() { return { enabled: false, reason: 'The owner has not added a GPU provider key yet, so no endpoint can start.', models: [], idle_minutes: 10, public_base: 'https://ai.openvibe.services/api/v1/own-endpoints' }; },
async ownList() { return { endpoints: [] }; },
async chat(args) {
@@@ -80,6 +82,7 @@ function createFakeAi({ chat = null } = {}) {
},
catalogData: catalog,
usageData: usage,
++ communityData,
};
}
@@@ -87,6 +90,7 @@ const downAi = {
configured: false,
async catalog() { return { generated_at: '2026-11-06T09:00:00.000Z', unavailable: true, count: 0, models: [], families: [], what: 'catalog' }; },
async usage() { return { generated_at: '2026-11-06T09:00:00.000Z', unavailable: true, days: 30, by_day: [], by_model: [], by_product: [], today: null, totals: null, what: 'usage' }; },
++ async communityOffers() { return { generated_at: '2026-11-06T09:00:00.000Z', unavailable: true, offers: [] }; },
async chat() { const e = new Error('AI is not configured for this service'); e.code = 'ai.not_configured'; e.status = 503; throw e; },
};
ddiff --git a/test/helpers/mocks.js b/test/helpers/mocks.js
iindex d3650a9..0449d10 100644
---- a/test/helpers/mocks.js
++++ b/test/helpers/mocks.js
@@@ -42,7 +42,8 @@ async function startNetwork() {
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
const publicPem = publicKey.export({ type: 'spki', format: 'pem' });
const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });
-- const st = { users: new Map(), byUsername: new Map(), codes: new Map(), refresh: new Map(), requests: [], tokenRequests: [], lockers: new Map(), down: false };
++ const st = { users: new Map(), byUsername: new Map(), codes: new Map(), refresh: new Map(), requests: [], tokenRequests: [], lockers: new Map(), down: false,
++ machines: [], pools: [], candidates: [], poolProposals: [], poolRevoked: [] };
let issuer = null;
let nextUserId = 1;
@@@ -169,6 +170,35 @@ async function startNetwork() {
return json(200, keys);
}
++ // Network's machines and machine pools (PR #609). Exchange reads them with the person's own token; the test
++ // sets `state.machines`, `state.candidates` and `state.pools` to what the page should see.
++ if (url.pathname === '/api/v1/machines' && req.method === 'GET') {
++ const u = userFrom(req);
++ if (u === 'expired') return problem(json, 401, 'auth.expired', 'expired');
++ if (!u) return problem(json, 401, 'auth.required', 'send Authorization: Bearer <Network access token>');
++ return json(200, { machines: st.machines });
++ }
++ if (url.pathname === '/api/v1/machine-pools' && req.method === 'GET') {
++ const u = userFrom(req);
++ if (u === 'expired') return problem(json, 401, 'auth.expired', 'expired');
++ if (!u) return problem(json, 401, 'auth.required', 'send Authorization: Bearer <Network access token>');
++ return json(200, { pools: st.pools, candidates: st.candidates });
++ }
++ if (url.pathname === '/api/v1/machine-pools' && req.method === 'POST') {
++ const u = userFrom(req);
++ if (!u) return problem(json, 401, 'auth.required', 'send Authorization: Bearer <Network access token>');
++ const body = JSON.parse(raw.toString('utf8') || '{}');
++ st.poolProposals.push(body);
++ return json(201, { pool: { id: 'pool_TEST', head: body.head || (body.machines || [])[0], machines: (body.machines || []).map((id) => ({ id, approved: false, ready: false })) } });
++ }
++ const stop = /^\/api\/v1\/machine-pools\/([^/]+)\/revoke$/.exec(url.pathname);
++ if (stop && req.method === 'POST') {
++ const u = userFrom(req);
++ if (!u) return problem(json, 401, 'auth.required', 'send Authorization: Bearer <Network access token>');
++ st.poolRevoked.push(decodeURIComponent(stop[1]));
++ return json(200, { revoked: true });
++ }
++
return json(404, { error: 'not found' });
});
issuer = srv.url;
ddiff --git a/test/pools.test.js b/test/pools.test.js
nnew file mode 100644
iindex 0000000..9d5568b
---- /dev/null
++++ b/test/pools.test.js
@@@ -0,0 +1,174 @@
++'use strict';
++/**
++ * Pooled machines, part 4 (design 2026-11-06): "Pool your machines" on /sell and the pool's offer card. The page
++ * reads the person's machines, pools, link checks and fit table from Network and AI (the mock Network here); it
++ * measures nothing itself. Every number is one that was measured; the rest says "not measured yet".
++ */
++const assert = require('assert');
++const { ids } = require('openvibe-contracts');
++const { boot, check, done } = require('./helpers/boot');
++const { createFakeAi } = require('./helpers/ai');
++
++const dev = () => `dev_${ids.ulid()}`;
++const poolOffer = (models) => ({
++ offer_id: 'community_abc123', kind: 'pool', label: 'Community pool (3 machines)', models,
++ pool: { machines: ['pseudonym-a', 'pseudonym-b', 'pseudonym-c'], head: 'pseudonym-a' },
++ free_only: true, verified: false, hours: { start: '00:00', end: '00:00', tz: 'UTC' }, concurrency: 1,
++ price: { in_per_million: 1, out_per_million: 2 }, audience: 'everyone', health: { status: 'up' },
++ open_now: true, ready: true,
++});
++
++(async () => {
++ const ai = createFakeAi({ community: [poolOffer(['llama-3-8b-abliterated'])] });
++ const t = await boot({ ai });
++ const user = t.network.addUser('alice');
++ const desktop = dev(), laptop = dev(), rack = dev(), other = dev();
++ const announce = (id, network, freeVram, hardware) => ({ id, free_vram: freeVram, free_ram: 32000, hardware, network });
++
++ try {
++ await check('signed out, /sell has no pool card (it needs a person’s machines)', async () => {
++ const r = await t.get('/sell');
++ assert.strictEqual(r.status, 200, r.text);
++ assert.ok(!r.text.includes('Pool your machines'), 'no pool card when signed out');
++ });
++
++ await check('fewer than two machines: one line and the install link', async () => {
++ t.network.state.machines = [{ id: desktop, name: 'Desktop', online: true }];
++ t.network.state.candidates = [announce(desktop, 'home', 24000, 'RTX 4090')];
++ t.network.state.pools = [];
++ const r = await t.get('/sell', { as: user });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.ok(r.text.includes('Pool your machines'), 'the card is there');
++ assert.ok(r.text.includes('Pooling needs two or more of your machines on the same network. Add one with'), 'the one line');
++ assert.ok(r.text.includes('https://node.openvibe.network'), 'the install link');
++ assert.ok(!r.text.includes('1. Choose the machines'), 'no four steps with one machine');
++ });
++
++ await check('a machine on another network is greyed, with its reason', async () => {
++ t.network.state.machines = [{ id: desktop, name: 'Desktop', online: true }, { id: laptop, name: 'Laptop', online: true }];
++ t.network.state.candidates = [announce(desktop, 'home', 24000, 'RTX 4090'), announce(laptop, 'elsewhere', 8000, 'RTX 3060')];
++ const r = await t.get('/sell', { as: user });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.ok(r.text.includes('Not on the same network as Desktop: pools work on one home network, not across the internet'), r.text);
++ assert.ok(/class="ex-pool-machine is-off"/.test(r.text), 'the machine is greyed, not hidden');
++ assert.ok(r.text.includes('Desktop') && r.text.includes('Laptop'), 'both machines are listed');
++ });
++
++ await check('a failed link says the number and the limit; a good one says both numbers', async () => {
++ t.network.state.machines = [
++ { id: desktop, name: 'Desktop', online: true }, { id: laptop, name: 'Laptop', online: true }, { id: rack, name: 'Rack', online: true },
++ ];
++ t.network.state.candidates = [announce(desktop, 'home', 24000, 'RTX 4090'), announce(laptop, 'home', 8000, 'RTX 3060'), announce(rack, 'home', 16000, 'RTX 4080')];
++ t.network.state.pools = [{
++ id: 'pool_TEST', head: desktop, created_at: '2026-11-07T00:00:00.000Z',
++ machines: [{ id: desktop, approved: true, ready: true, vram_share: 0.5 }, { id: laptop, approved: true, ready: true, vram_share: 0.17 }, { id: rack, approved: true, ready: true, vram_share: 0.33 }],
++ links: [{ machine: laptop, rtt_ms: 2, mbps: 940 }, { machine: rack, rtt_ms: 40, mbps: 940 }],
++ fit: [{ model: 'llama-3-8b-abliterated', fits: true, tokens_per_second: 9 }, { model: 'qwen3-32b', fits: true, tokens_per_second: null }],
++ }];
++ const r = await t.get('/sell', { as: user });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.ok(r.text.includes('2 ms, 940 Mb/s: good'), 'the good link in words');
++ assert.ok(r.text.includes('40 ms; a pool needs under 5 ms'), 'the failed link says the number and the limit');
++ assert.ok(r.text.includes('about 9 tokens a second'), 'a measured speed');
++ assert.ok(r.text.includes('not measured yet'), 'a model nobody measured says so');
++ assert.ok(!/about 0 tokens a second/.test(r.text), 'an absent measurement is never a zero');
++ assert.ok(r.text.includes('Head: <b>Desktop</b>'), 'the head is the machine with the most free VRAM');
++ });
++
++ await check('nothing is offered until every member approves', async () => {
++ t.network.state.pools = [{
++ id: 'pool_TEST', head: desktop, created_at: '2026-11-07T00:00:00.000Z',
++ machines: [{ id: desktop, approved: true, ready: true, vram_share: 0.75 }, { id: laptop, approved: false, ready: false, vram_share: 0.25 }],
++ }];
++ const waiting = await t.get('/sell', { as: user });
++ assert.ok(waiting.text.includes('Waiting for approval on <b>Laptop</b>'), waiting.text);
++ assert.ok(waiting.text.includes('Approved'), 'the machine that approved says so');
++ assert.ok(waiting.text.includes('Nothing is offered until every member approves.'), 'nothing is offered yet');
++ assert.ok(!waiting.text.includes('one price for the whole pool'), 'the offer controls stay closed');
++
++ t.network.state.pools[0].machines = t.network.state.pools[0].machines.map((m) => ({ ...m, approved: true, ready: true }));
++ const approved = await t.get('/sell', { as: user });
++ assert.ok(approved.text.includes('one price for the whole pool'), 'the same controls as one machine, applied to the pool');
++ assert.ok(approved.text.includes('everyone, your followers or a group'), 'who may use it');
++ assert.ok(approved.text.includes('the whole pool pauses while any member is in use'), 'idle only');
++ assert.ok(approved.text.includes('Prompts reach your head machine (Desktop); the others only see numbers in transit.'), 'the privacy sentence');
++ assert.ok(!approved.text.includes('Nothing is offered until every member approves.'), 'it is offered now');
++ });
++
++ await check('the public card shows only the count, never a machine', async () => {
++ t.network.state.machines = [{ id: desktop, name: 'Workstation Alpha', online: true }];
++ const models = await t.get('/models');
++ assert.strictEqual(models.status, 200, models.text);
++ assert.ok(models.text.includes('Community pool (3 machines)'), 'the label');
++ assert.ok(models.text.includes('Free tier only'), 'a new pool says it serves the free tier only');
++ assert.ok(!models.text.includes('&lt;span'), 'no badge markup escapes as text');
++ assert.ok(!models.text.includes('Workstation Alpha'), 'no machine name in public');
++ const model = await t.get('/m/meta-llama/llama-3-8b-abliterated');
++ assert.ok(model.text.includes('Community pool (3 machines)'), 'the offer card carries the pool label');
++ assert.ok(!model.text.includes('Workstation Alpha'), 'still no machine name');
++ const owner = await t.get('/sell', { as: user });
++ assert.ok(owner.text.includes('Workstation Alpha'), 'the owner sees the machine names');
++ });
++
++ await check('creating the pool goes through Network, and stopping it says what happens', async () => {
++ t.network.state.machines = [{ id: desktop, name: 'Desktop', online: true }, { id: laptop, name: 'Laptop', online: true }];
++ t.network.state.candidates = [announce(desktop, 'home', 24000, 'RTX 4090'), announce(laptop, 'home', 8000, 'RTX 3060')];
++ t.network.state.pools = [];
++ const made = await t.get('/sell/pool', {
++ as: user, method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' },
++ body: `machines=${desktop}&machines=${laptop}&head=${desktop}`,
++ });
++ assert.strictEqual(made.status, 303, made.text);
++ assert.strictEqual(t.network.state.poolProposals.length, 1);
++ assert.deepStrictEqual(t.network.state.poolProposals[0], { machines: [desktop, laptop], head: desktop });
++
++ const one = await t.get('/sell/pool', {
++ as: user, method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: `machines=${desktop}`,
++ });
++ assert.strictEqual(one.status, 200, 'one machine is refused before Network is called');
++ assert.ok(one.text.includes('Choose two or more of your machines on the same network.'));
++
++ t.network.state.pools = [{ id: 'pool_TEST', head: desktop, created_at: '2026-11-07T00:00:00.000Z', machines: [{ id: desktop, approved: true, ready: true, vram_share: 0.6 }, { id: laptop, approved: true, ready: true, vram_share: 0.4 }] }];
++ const stop = await t.get('/sell/pool/pool_TEST/stop', {
++ as: user, method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: '',
++ });
++ assert.strictEqual(stop.status, 303, stop.text);
++ assert.deepStrictEqual(t.network.state.poolRevoked, ['pool_TEST']);
++ });
++
++ await check('no measurements: the page says not measured yet, never a number', async () => {
++ t.network.state.machines = [{ id: desktop, name: 'Desktop', online: true }, { id: laptop, name: 'Laptop', online: true }];
++ t.network.state.candidates = [announce(desktop, 'home', 24000, 'RTX 4090'), announce(laptop, 'home', 8000, 'RTX 3060')];
++ t.network.state.pools = [];
++ const r = await t.get('/sell', { as: user });
++ assert.ok(r.text.includes('link not measured yet'), 'an unmeasured link says so');
++ assert.ok(r.text.includes('No model has been measured on these machines yet'), 'an empty fit table says so');
++ assert.ok(!/\bMb\/s: good\b/.test(r.text), 'no invented speed');
++ });
++
++ await check('the pool card is gone for a stranger’s machines when Network cannot answer', async () => {
++ t.network.setDown(true);
++ const r = await t.get('/sell', { as: user });
++ assert.strictEqual(r.status, 200, 'the page still renders');
++ assert.ok(r.text.includes('Your machines could not be read right now. Reload in a moment.'), 'the read says why');
++ const anon = await t.get('/sell');
++ assert.ok(!anon.text.includes('Pool your machines'), 'signed out is unaffected');
++ t.network.setDown(false);
++ });
++
++ await check('phone: the four steps stack, every control is 44 px, nothing is wider than the screen', async () => {
++ const css = require('fs').readFileSync(require('path').join(__dirname, '..', 'public', 'css', 'exchange.css'), 'utf8');
++ assert.ok(/\.ex-pool \.ex-field select, \.ex-pool \.ex-btn, \.ex-pool \.ovk-btn \{ min-height: 44px; \}/.test(css), 'controls are 44 px');
++ assert.ok(/@media \(max-width: 560px\)/.test(css), 'the phone layout is stacked');
++ t.network.state.machines = [{ id: desktop, name: 'Desktop', online: true }, { id: laptop, name: 'Laptop', online: true }];
++ t.network.state.candidates = [announce(desktop, 'home', 24000, 'RTX 4090'), announce(laptop, 'home', 8000, 'RTX 3060')];
++ t.network.state.pools = [{ id: 'pool_TEST', head: desktop, created_at: '2026-11-07T00:00:00.000Z', machines: [{ id: desktop, approved: true, ready: true, vram_share: 0.6 }, { id: laptop, approved: false, ready: false, vram_share: 0.4 }] }];
++ const r = await t.get('/sell', { as: user });
++ assert.strictEqual((r.text.match(/class="ex-pool-step"/g) || []).length, 4, 'four stacked step cards');
++ assert.ok(r.text.includes('class="ex-pool ex-pool-steps"'), 'the steps container');
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})().catch((err) => { console.error(err); process.exitCode = 1; });