openvibers/openvibe.network Commits

Machine pools: the record, the approve step on each machine, and the head's offer (#609)

OP OpenVibers committed 6 days ago · 9208a2b3b2 · 10 files, +551 −4

* Machine pools: the record, the approve step on each machine, and the head's offer

Several of one owner's machines on one local network can serve one model
through llama.cpp RPC (bot.machine-pool@1). Network keeps the record: a
machine announces itself with its own node token (machine and owner from
the token and Bot, public address from the connection); the owner proposes
two to eight of their machines seen on one network; each machine approves
on itself with its own Ed25519 public certificate, never a private key.

Every member renews its readiness every two seconds and the pool's offer is
ready only while all of them do. Any change (a member removed, paired to
someone else, revoked or leaving, the owner stopping it) revokes the whole
pool. The head's AI offer gets pool { machines, head } from this record
only, and a member's own offer is left out while it is in a pool.

The machines page shows each pool on its members' cards (approvals,
readiness, VRAM share, Stop pool) and offers Pool these machines per local
network, without sending addresses to the browser. Migration 0044, expand.

* Merge remote-tracking branch 'origin/main' into feature/pooled-machines

* Merge main; pooled machines migration is 0045 (0044 is consent refusals on main)

* Machines: Stop pool uses the stop glyph (circle-stop is not drawn)

Changes

ddiff --git a/docs/machine-pools.md b/docs/machine-pools.md
nnew file mode 100644
iindex 00000000..d5220ada
---- /dev/null
++++ b/docs/machine-pools.md
@@@ -0,0 +1,66 @@
++# Same-owner machine pools
++
++Several of one person's machines on the same local network can serve one model together through llama.cpp RPC:
++`rpc-server` on the members and `llama-server --rpc` on the head (the pooled machines design, 2026-11-06). Network
++holds the pool record (`bot.machine-pool@1`, `server/registry/machine-pools.js`, migration 0045); Node runs the
++engines and the private link; AI routes and verifies the pool's offer. Pools across the internet, pools of different
++owners and group pools are not enabled.
++
++## How a pool comes together
++
++1. **Each machine announces itself** with its own node token (`POST /api/v1/node/self/pools/announce`): a private
++ IPv4 address and prefix configured on one of its interfaces (RFC 1918 only), its link port, its free VRAM and RAM
++ and a short hardware name. Network takes the machine and its owner from the token and Bot, and the public address
++ from the connection. A claimed owner, machine id or public address in the body is never trusted.
++2. **The owner proposes the pool** on `/machines`. "Pool these machines" lists only machines that announced in the
++ last five minutes, grouped by local network (one public address and one private subnet); the page gets an opaque
++ network key, never an address or a public IP. Two to eight machines; the default head has the most free VRAM, and
++ the owner can pick another.
++3. **Each machine approves it on itself.** Node's local approve step shows the pool, asks the person at that machine,
++ generates an Ed25519 key there, and uploads only its self-signed public certificate
++ (`POST /api/v1/node/self/pools/:id/approve`). An owner session, an app or a service cannot approve for a machine,
++ and one machine cannot approve for another. Every machine needs its own key; a private key is refused.
++4. **Node measures each link** before serving (round trip under 5 ms; 1 Gb/s for models over 30B, 300 Mb/s below)
++ and runs the engines.
++
++A pool is never edited. Changing the head, the members or a key means a new pool and new approvals.
++
++## While it serves
++
++- Every member renews its readiness every two seconds (`POST /api/v1/node/self/pools/:id/status` with `{ ready }`);
++ the answer carries the pool, so the same request rechecks membership. The pool's offer is ready only while every
++ member has approved, renewed in the last ten seconds, and is still an active node of the owner.
++- Idle-only and the kill switch hold across the pool: Node pauses the whole pool when any member is in use or
++ stopped, and a member that is paused reports `ready: false`.
++- Every read rechecks each member against Bot. A member removed from Bot, paired to a different owner or revoked, a
++ member leaving (`POST /api/v1/node/self/pools/:id/leave`) or the owner pressing Stop pool
++ (`POST /api/v1/machine-pools/:id/revoke`) revokes the whole pool and forgets its public certificates. Each Node
++ sees the revocation at its next renewal and drops its private link and its key.
++
++## The AI offer
++
++`GET /internal/ai/offers` derives the pool from this record only. The head's offer gets `pool: { machines, head }`
++(`ai.offer-pool@1`) and is ready only while the pool is; a member's own offer is left out while it is in a pool, so
++the same GPU is never sold twice. A `pool` a reporter sends is dropped. One price, owner, audience and hours apply to
++the whole pool. Certificates and addresses never go to AI. AI verifies the pool's answers as it would one machine's
++and keeps a new or failed pool to the free tier only.
++
++The owner's machines page shows each pool on its members' cards: the head, who has approved, who is ready, and each
++machine's share of the pool's free VRAM, which is how its credits are split (a capacity share, not a speed claim).
++
++## API
++
++| Route | Who | Body → answer |
++| --- | --- | --- |
++| `GET /api/v1/machine-pools` | owner session | → `{ pools, candidates }` |
++| `POST /api/v1/machine-pools` | owner session | `{ machines, head? }` → 201 `{ pool }` |
++| `POST /api/v1/machine-pools/:id/revoke` | owner session | → `{ revoked }` |
++| `POST /api/v1/node/self/pools/announce` | the machine | `{ address, port, free_vram, free_ram, hardware }` → `{ machine }` |
++| `GET /api/v1/node/self/pools/:id` | a member | → `{ pool }` (`bot.machine-pool@1`) |
++| `POST /api/v1/node/self/pools/:id/approve` | a member | `{ certificate }` → `{ pool }` |
++| `POST /api/v1/node/self/pools/:id/status` | an approved member | `{ ready }` → `{ ok, pool }` |
++| `POST /api/v1/node/self/pools/:id/leave` | a member | → `{ revoked }` |
++
++The machine routes use the existing `network.node.self.manage` capability; each handler then checks the live node
++principal and its exact membership. Migration 0045 is expand-only. `test/machine-pools.test.js` runs on the test
++runner's isolated, migrated PGlite store.
ddiff --git a/migrations/0045_machine_pools.sql b/migrations/0045_machine_pools.sql
nnew file mode 100644
iindex 00000000..b4758f22
---- /dev/null
++++ b/migrations/0045_machine_pools.sql
@@@ -0,0 +1,29 @@
++-- phase: expand
++-- Same-owner pools. Only public certificates are stored; private keys stay on each Node.
++CREATE TABLE IF NOT EXISTS machine_pool_candidates (
++ machine_id text COLLATE "C" PRIMARY KEY,
++ principal_id text COLLATE "C" NOT NULL REFERENCES platform_node_principals(id),
++ owner_subject text COLLATE "C" NOT NULL,
++ doc text NOT NULL,
++ seen_at text NOT NULL
++);
++CREATE TABLE IF NOT EXISTS machine_pools (
++ id text COLLATE "C" PRIMARY KEY,
++ owner_subject text COLLATE "C" NOT NULL,
++ head text COLLATE "C" NOT NULL,
++ created_at text NOT NULL,
++ revoked_at text
++);
++CREATE TABLE IF NOT EXISTS machine_pool_members (
++ pool_id text COLLATE "C" NOT NULL REFERENCES machine_pools(id),
++ machine_id text COLLATE "C" NOT NULL,
++ principal_id text COLLATE "C" NOT NULL REFERENCES platform_node_principals(id),
++ doc text NOT NULL,
++ certificate text,
++ approved_at text,
++ ready boolean NOT NULL DEFAULT false,
++ seen_at text,
++ PRIMARY KEY (pool_id, machine_id)
++);
++CREATE INDEX IF NOT EXISTS machine_pools_owner ON machine_pools(owner_subject);
++CREATE INDEX IF NOT EXISTS machine_pool_members_machine ON machine_pool_members(machine_id);
ddiff --git a/public/css/my.css b/public/css/my.css
iindex b9353ffa..d4307bac 100644
---- a/public/css/my.css
++++ b/public/css/my.css
@@@ -368,6 +368,8 @@ button.td-stat:hover span { color: var(--accent-light); }
.mc-revoke { background: transparent; border: 1px solid color-mix(in srgb, #ef4444 45%, var(--border)); color: var(--danger-text, #ef4444); }
.mc-revoke.is-sure { background: #ef4444; color: #fff; border-color: #ef4444; }
.mc-confirm { flex-basis: 100%; font-size: 12.5px; color: var(--text-secondary); }
++.mc-pool > .mc-confirm, .mc-pool > .mc-acts { grid-column: 1 / -1; margin: 0; }
++.mc-pool > div.mc-role { grid-template-columns: auto minmax(0, 1fr); cursor: default; }
.mc-empty { display: flex; gap: 14px; align-items: center; padding: 18px; border-radius: 14px; border: 1px dashed var(--border); color: var(--text-secondary); }
.mc-empty > i { font-size: 26px; color: var(--accent-light); }
@media (prefers-reduced-motion: reduce) { .mc-wait-dot { animation: none; } }
ddiff --git a/public/js/my/machines.js b/public/js/my/machines.js
iindex 6f9f5751..eb9967c0 100644
---- a/public/js/my/machines.js
++++ b/public/js/my/machines.js
@@@ -44,6 +44,7 @@ function mcCard(m) {
const meta = mcEl('p', 'mc-meta', [[MC_KIND[m.kind], m.os, m.node_version ? `Node ${m.node_version}` : ''].filter(Boolean).join(' · ')]);
const head = mcEl('div', 'mc-head', [dot, mcEl('div', 'mc-title', [name, state]), meta]);
card.append(head);
++ (m.pools || []).forEach((pool) => card.append(mcPoolBlock(pool)));
if (Array.isArray(m.running) && m.running.length) {
card.append(mcEl('ul', 'mc-running', m.running.slice(0, 6).map((r) => mcEl('li', '', [String((r && (r.title || r.kind || r.id)) || r)]))));
@@@ -171,8 +172,83 @@ async function loadMachines() {
list.replaceChildren(mcEl('div', 'mc-empty', [mcIcon('fa-laptop'), mcEl('p', '', [mcEl('b', '', ['No machines yet.']), ' Pair your computer and it can run your Codes jobs, host your desks and serve your own models, under your account.'])]));
return;
}
-- list.replaceChildren(...machines.map(mcCard));
++ // Pools are an extra: when they cannot be read, the machines still show.
++ const pools = (await apiFetch('/api/v1/machine-pools').catch(() => null)) || { pools: [], candidates: [] };
++ mcNames = new Map(machines.map((m) => [m.id, m.name || MC_KIND[m.kind] || 'A machine']));
++ for (const m of machines) m.pools = (pools.pools || []).filter((p) => p.machines.some((x) => x.id === m.id));
++ list.replaceChildren(...machines.map(mcCard), ...mcPoolPickers(pools.candidates || []));
} catch (e) {
list.replaceChildren(mcEl('p', 'muted', [e.status === 401 ? 'Sign in to see your machines.' : 'Your machines could not be read just now.']));
}
}
++
++/* ── Pooled machines (bot.machine-pool@1): several of your machines on one local network serving one model together.
++ The page proposes and stops a pool; each machine approves it on itself (in Node), never from here. ── */
++let mcNames = new Map(); // machine id → the name its card shows
++const mcName = (id) => mcNames.get(id) || 'A machine';
++const mcGB = (mb) => `${(Number(mb) / 1024).toFixed(mb >= 10240 ? 0 : 1)} GB`;
++
++/** A pool on a member's card: who is the head, who has approved on itself, each machine's share, and Stop pool. */
++function mcPoolBlock(pool) {
++ const ready = pool.machines.every((x) => x.approved && x.ready);
++ const waiting = pool.machines.filter((x) => !x.approved).length;
++ const block = mcEl('fieldset', 'mc-roles mc-pool', [mcEl('legend', '', [`Pool of ${pool.machines.length} machines`])]);
++ for (const x of pool.machines) {
++ const state = !x.approved ? 'Waiting for approval on that machine' : x.ready ? 'Approved · ready' : 'Approved · paused or offline';
++ block.append(mcEl('div', 'mc-role', [
++ mcEl('span', 'mc-role-ic', [mcIcon(x.id === pool.head ? 'fa-crown' : 'fa-microchip')]),
++ mcEl('span', 'mc-role-t', [mcEl('b', '', [mcName(x.id) + (x.id === pool.head ? ' (head)' : '')]),
++ mcEl('small', '', [`${state} · ${Math.round(x.vram_share * 100)}% of the pool's VRAM and credits`])]),
++ ]));
++ }
++ const note = waiting
++ ? `${waiting} machine${waiting === 1 ? '' : 's'} still to approve this pool on itself, in Node (pool ${pool.id}).`
++ : ready ? 'Serving as one offer. The head sees prompts; the others only receive tensors.' : 'Paused: a machine is in use, offline or outside its hours.';
++ const stop = mcEl('button', 'btn btn-outline btn-small', [mcIcon('fa-stop'), ' Stop pool'], { type: 'button' });
++ stop.addEventListener('click', async () => {
++ stop.disabled = true;
++ try {
++ await apiFetch(`/api/v1/machine-pools/${encodeURIComponent(pool.id)}/revoke`, { method: 'POST', body: '{}' });
++ mcSay('The pool is stopped. Each machine drops its private link and key at its next check.');
++ loadMachines();
++ } catch (e) { mcSay(e.message || 'The pool could not be stopped.', true); stop.disabled = false; }
++ });
++ block.append(mcEl('p', 'mc-confirm', [note]), mcEl('div', 'mc-acts', [stop]));
++ return block;
++}
++
++/** "Pool these machines": one picker per local network with two or more of your machines that checked in lately. */
++function mcPoolPickers(candidates) {
++ const groups = new Map();
++ for (const c of candidates) {
++ if (!c.network) continue;
++ if (!groups.has(c.network)) groups.set(c.network, []);
++ groups.get(c.network).push(c);
++ }
++ return [...groups.values()].filter((g) => g.length >= 2).map((group) => {
++ const panel = mcEl('fieldset', 'mc-roles mc-pool mc-card', [mcEl('legend', '', ['Pool these machines'])]);
++ const boxes = group.map((c) => {
++ const id = `mc-pool-${c.id}`;
++ const box = mcEl('input', '', [], { type: 'checkbox', id, value: c.id });
++ panel.append(mcEl('label', 'mc-role', [box, mcEl('span', 'mc-role-ic', [mcIcon('fa-microchip')]), mcEl('span', 'mc-role-t', [
++ mcEl('b', '', [mcName(c.id)]), mcEl('small', '', [`${mcGB(c.free_vram)} free VRAM · ${mcGB(c.free_ram)} free RAM · ${c.hardware}`])])], { for: id }));
++ return box;
++ });
++ const head = mcEl('select', 'mc-rename', [mcEl('option', '', ['Head: the most free VRAM'], { value: '' })], { 'aria-label': 'Which machine is the head' });
++ group.forEach((c) => head.append(mcEl('option', '', [`Head: ${mcName(c.id)}`], { value: c.id })));
++ const create = mcEl('button', 'btn btn-outline btn-small', [mcIcon('fa-layer-group'), ' Pool them'], { type: 'button' });
++ create.addEventListener('click', async () => {
++ const machines = boxes.filter((b) => b.checked).map((b) => b.value);
++ if (machines.length < 2) { mcSay('Choose at least two machines.', true); return; }
++ create.disabled = true;
++ try {
++ await apiFetch('/api/v1/machine-pools', { method: 'POST', body: JSON.stringify({ machines, ...(head.value ? { head: head.value } : {}) }) });
++ mcSay('Pool proposed. Approve it on each machine; Node measures the links before it serves.');
++ loadMachines();
++ } catch (e) { mcSay(e.message || 'The pool could not be made.', true); create.disabled = false; }
++ });
++ panel.append(mcEl('p', 'mc-confirm', ['These machines checked in from the same local network. The head runs the model and sees prompts; the others lend their memory and only receive tensors. One price for the pool, and credits split by the VRAM each machine serves.']),
++ mcEl('div', 'mc-acts', [head, create]));
++ return panel;
++ });
++}
ddiff --git a/server/index.js b/server/index.js
iindex 67beda8b..c05c56a6 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -596,6 +596,11 @@ const notifyAccountData = async (userId, n) => await notificationService.create(
{
const machines = require('./registry/machines');
const bot = machines.createBotClient({ baseUrl: config.services.bot.internalUrl, privateKey, issuer: config.jwt.issuer });
++ // Same-owner machine pools (bot.machine-pool@1): the owner proposes and stops them; each machine announces, approves,
++ // renews and leaves with its own node token. /api/v1/node/self's guard above checks that token for the machine routes.
++ const poolRouters = require('./registry/machine-pools').routers({ bot, requireAuth });
++ app.use('/api/v1/machine-pools', rateLimit({ windowMs: 60_000, max: 60 }), poolRouters.user);
++ app.use('/api/v1/node/self/pools', poolRouters.self);
const machineRouters = machines.routers({ bot, requireAuth, publicKey, issuer: config.jwt.issuer });
app.use('/api/v1/machines', rateLimit({ windowMs: 60_000, max: 60 }), machineRouters.hub);
const machineInternal = machines.internalRouters({ guard: require('./identity/principals').guard('network.node.manage') });
ddiff --git a/server/registry/ai-offers.js b/server/registry/ai-offers.js
iindex 66f5fdd9..f0fa3e66 100644
---- a/server/registry/ai-offers.js
++++ b/server/registry/ai-offers.js
@@@ -105,7 +105,8 @@ async function list(db, { source = null, owner = null } = {}) {
if (source) { where.push('source = ?'); args.push(source); }
if (owner) { where.push('owner_subject = ?'); args.push(owner); }
const rows = await db.prepare(`SELECT doc FROM platform_ai_offers ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY status = 'up' DESC, id`).all(...args);
-- return rows.map((r) => JSON.parse(r.doc));
++ const offers = await Promise.all(rows.map((r) => require('./machine-pools').offerPool(db, JSON.parse(r.doc))));
++ return offers.filter(Boolean);
}
function routers({ guard, readGuard = guard }) {
ddiff --git a/server/registry/machine-pools.js b/server/registry/machine-pools.js
nnew file mode 100644
iindex 00000000..fc49f035
---- /dev/null
++++ b/server/registry/machine-pools.js
@@@ -0,0 +1,249 @@
++'use strict';
++/**
++ * Same-owner machine pools (bot.machine-pool@1; the pooled machines design, 2026-11-06). Several of one person's
++ * machines on one local network serve one model together through llama.cpp RPC: `rpc-server` on the members and
++ * `llama-server --rpc` on the head. This module holds the record and nothing that could run a model:
++ *
++ * - a machine announces itself with its own node token (its private address and prefix, its link port, its free
++ * VRAM and RAM). Network takes the machine and the owner from the token and Bot, and the public address from the
++ * connection, never from the body;
++ * - the signed-in owner proposes a pool of two to eight of their machines seen in the last five minutes from one
++ * public address with private addresses in one subnet (the default head has the most free VRAM);
++ * - each machine approves it on itself (Node's local `approve`), uploading only its self-signed Ed25519 public
++ * certificate; an owner session cannot approve for a machine, and one machine cannot approve for another;
++ * - every member renews its readiness every two seconds; the offer is ready only while every member has approved,
++ * renewed in the last ten seconds and is still the owner's;
++ * - any change (a member removed from Bot, a different owner, a revoked node, a key change, a member leaving, the
++ * owner stopping it) revokes the whole pool: all machines approve a new one.
++ *
++ * The head's community AI offer (ai-offers.js) gets `pool: { machines, head }` (ai.offer-pool@1) from this record; a
++ * member's own offer is left out while it is in a pool, so the same GPU is never sold twice.
++ *
++ * GET /api/v1/machine-pools session → { pools, candidates } (no address or public IP)
++ * POST /api/v1/machine-pools session { machines, head? } → 201 { pool }
++ * POST /api/v1/machine-pools/:id/revoke session → { revoked }
++ * POST /api/v1/node/self/pools/announce node { address, port, free_vram, free_ram, hardware } → { machine }
++ * GET /api/v1/node/self/pools/:id node → { pool } (bot.machine-pool@1)
++ * POST /api/v1/node/self/pools/:id/approve node { certificate } → { pool }
++ * POST /api/v1/node/self/pools/:id/status node { ready } → { ok, pool }
++ * POST /api/v1/node/self/pools/:id/leave node → { revoked }
++ */
++const express = require('express');
++const crypto = require('node:crypto');
++const net = require('node:net');
++const { ids } = require('openvibe-contracts');
++
++const CANDIDATE_TTL_MS = 5 * 60 * 1000; // an announcement older than this is not offered for a new pool
++const READY_WINDOW_MS = 10 * 1000; // a member renews every 2 s; a missed window stops the pool's offer
++const MAX_MB = 16 * 1024 * 1024;
++
++const fail = (status, message) => { throw Object.assign(new Error(message), { status }); };
++const iso = (now) => new Date(now).toISOString();
++const ipv4 = (ip) => ip.split('.').reduce((n, x) => (n * 256 + Number(x)) >>> 0, 0);
++
++/** A private (RFC 1918) IPv4 address with a /8 to /30 prefix → { ip, network, bits }, else null. */
++function subnet(address) {
++ if (typeof address !== 'string') return null;
++ const [ip, prefix] = address.split('/');
++ const bits = Number(prefix);
++ if (net.isIP(ip) !== 4 || !/^(?:[89]|[12][0-9]|30)$/.test(prefix)) return null;
++ const n = ipv4(ip), mask = (0xffffffff << (32 - bits)) >>> 0;
++ const privateIP = (n >>> 24) === 10 || (n >>> 20) === (ipv4('172.16.0.0') >>> 20) || (n >>> 16) === (ipv4('192.168.0.0') >>> 16);
++ return privateIP ? { ip, network: (n & mask) >>> 0, bits } : null;
++}
++/** Two announced machines on one local network: one public address, one private subnet, two different addresses. */
++function sameNetwork(a, b) {
++ const x = subnet(a.address), y = subnet(b.address);
++ return Boolean(x && y && x.ip !== y.ip && x.network === y.network && x.bits === y.bits && a.public_ip && a.public_ip === b.public_ip);
++}
++/** A short, stable name for a machine's local network, so the owner's page can group machines without addresses. */
++function networkKey(doc) {
++ const s = subnet(doc.address);
++ if (!s || !doc.public_ip) return null;
++ return crypto.createHash('sha256').update(`${doc.public_ip}|${s.network}/${s.bits}`).digest('hex').slice(0, 12);
++}
++/** An unexpired, self-signed Ed25519 public certificate (PEM), never a private key. */
++function certificate(value) {
++ if (typeof value !== 'string' || value.length > 8192 || /PRIVATE KEY/.test(value)) fail(400, 'a public pool certificate is required');
++ let cert;
++ try { cert = new crypto.X509Certificate(value); } catch { fail(400, 'invalid public pool certificate'); }
++ if (cert.publicKey.asymmetricKeyType !== 'ed25519' || !cert.verify(cert.publicKey) ||
++ Date.parse(cert.validTo) <= Date.now() || Date.parse(cert.validFrom) > Date.now()) fail(400, 'an unexpired self-signed Ed25519 certificate is required');
++ return cert.toString();
++}
++/** The calling machine's live node principal. A user, app or service token is refused: only a machine approves. */
++async function node(db, req) {
++ const sub = String(req.principal?.sub || '');
++ if (!sub.startsWith('node:')) fail(403, 'only the machine itself may do this');
++ const p = await db.prepare('SELECT id, owner_subject, status, owner_kind FROM platform_node_principals WHERE id = ?').get(sub.slice('node:'.length));
++ if (!p || p.status !== 'active' || p.owner_kind !== 'user') fail(401, 'machine is no longer paired to an owner');
++ return p;
++}
++async function ownMachines(bot, owner) {
++ return (await bot.list(owner)).machines || [];
++}
++/** The pool as its members read it (bot.machine-pool@1). Rechecks every member against Bot and revokes on any change. */
++async function document(db, id, owner, bot) {
++ const pool = await db.prepare('SELECT * FROM machine_pools WHERE id = ? AND owner_subject = ? AND revoked_at IS NULL').get(id, owner);
++ if (!pool) fail(404, 'pool not found');
++ const rows = await db.prepare('SELECT * FROM machine_pool_members WHERE pool_id = ? ORDER BY machine_id').all(id);
++ const current = await ownMachines(bot, owner);
++ for (const member of rows) {
++ const principal = await db.prepare('SELECT status, owner_subject FROM platform_node_principals WHERE id = ?').get(member.principal_id);
++ if (!principal || principal.status !== 'active' || principal.owner_subject !== owner ||
++ !current.some((m) => m.id === member.machine_id && m.node_id === member.principal_id)) {
++ await revoke(db, id, owner);
++ fail(409, 'pool membership changed; all machines must approve a new pool');
++ }
++ }
++ return { id: pool.id, owner, head: pool.head, machines: rows.map((r) => ({ ...JSON.parse(r.doc), certificate: r.certificate || null })) };
++}
++async function propose(db, bot, owner, body, now = Date.now()) {
++ const members = body?.machines;
++ if (!Array.isArray(members) || members.length < 2 || members.length > 8 || members.some((m) => typeof m !== 'string') || new Set(members).size !== members.length) fail(400, 'choose 2-8 distinct machines');
++ if (body.head != null && typeof body.head !== 'string') fail(400, 'head must be a machine id');
++ const current = await ownMachines(bot, owner);
++ return db.tx(async () => {
++ const candidates = [];
++ // Lock in a stable order: two simultaneous proposals cannot claim the same machine.
++ for (const id of [...members].sort()) {
++ const row = await db.prepare('SELECT * FROM machine_pool_candidates WHERE machine_id = ? AND owner_subject = ? FOR UPDATE').get(id, owner);
++ if (!row || !current.some((m) => m.id === id && m.node_id === row.principal_id) || Date.parse(row.seen_at) < now - CANDIDATE_TTL_MS) fail(404, 'machine is not an available machine of this owner');
++ const used = await db.prepare('SELECT m.pool_id FROM machine_pool_members m JOIN machine_pools p ON p.id = m.pool_id WHERE m.machine_id = ? AND p.revoked_at IS NULL').get(id);
++ if (used) fail(409, 'machine already belongs to a pool');
++ candidates.push({ row, doc: JSON.parse(row.doc) });
++ }
++ const head = body.head || [...candidates].sort((a, b) => b.doc.free_vram - a.doc.free_vram)[0].doc.id;
++ if (!members.includes(head)) fail(400, 'head must be a member');
++ const leader = candidates.find((c) => c.doc.id === head).doc;
++ if (candidates.some((c) => c.doc.id !== head && !sameNetwork(leader, c.doc))) fail(400, 'machines must be on the same network');
++ const id = `pool_${ids.ulid()}`;
++ await db.prepare('INSERT INTO machine_pools (id, owner_subject, head, created_at) VALUES (?, ?, ?, ?)').run(id, owner, head, iso(now));
++ for (const c of candidates) await db.prepare('INSERT INTO machine_pool_members (pool_id, machine_id, principal_id, doc) VALUES (?, ?, ?, ?)').run(id, c.doc.id, c.row.principal_id, JSON.stringify(c.doc));
++ return { id, owner, head, machines: candidates.map((c) => ({ ...c.doc, certificate: null })) };
++ });
++}
++async function approve(db, poolId, principal, publicCertificate, bot) {
++ const pem = certificate(publicCertificate);
++ // Membership is checked before the transaction, so a revocation it makes is not rolled back by the refusal.
++ await document(db, poolId, principal.owner_subject, bot);
++ return db.tx(async () => {
++ const lock = await db.prepare('SELECT id FROM machine_pools WHERE id = ? AND revoked_at IS NULL FOR UPDATE').get(poolId);
++ if (!lock) fail(404, 'pool not found');
++ const member = await db.prepare('SELECT * FROM machine_pool_members WHERE pool_id = ? AND principal_id = ?').get(poolId, principal.id);
++ if (!member) fail(403, 'this machine is not a member');
++ if (member.certificate && member.certificate !== pem) fail(409, 'changing a key requires a new pool and new approvals');
++ const others = await db.prepare('SELECT certificate FROM machine_pool_members WHERE pool_id = ? AND principal_id <> ? AND certificate IS NOT NULL').all(poolId, principal.id);
++ if (others.some((m) => m.certificate === pem)) fail(400, 'each machine must generate its own key');
++ await db.prepare('UPDATE machine_pool_members SET certificate = ?, approved_at = ? WHERE pool_id = ? AND principal_id = ?').run(pem, iso(Date.now()), poolId, principal.id);
++ return document(db, poolId, principal.owner_subject, bot);
++ });
++}
++async function revoke(db, id, owner) {
++ await db.prepare('UPDATE machine_pools SET revoked_at = ? WHERE id = ? AND owner_subject = ? AND revoked_at IS NULL').run(iso(Date.now()), id, owner);
++ await db.prepare('UPDATE machine_pool_members SET ready = false, certificate = NULL WHERE pool_id IN (SELECT id FROM machine_pools WHERE id = ? AND owner_subject = ? AND revoked_at IS NOT NULL)').run(id, owner);
++}
++/** The owner's pools for their machines page: approvals, readiness and each machine's share of the free VRAM. */
++async function list(db, owner, now = Date.now()) {
++ const pools = await db.prepare('SELECT id, head, created_at FROM machine_pools WHERE owner_subject = ? AND revoked_at IS NULL ORDER BY created_at').all(owner);
++ const out = [];
++ for (const p of pools) {
++ const rows = await db.prepare('SELECT machine_id, approved_at, ready, seen_at, doc FROM machine_pool_members WHERE pool_id = ? ORDER BY machine_id').all(p.id);
++ const docs = rows.map((r) => JSON.parse(r.doc));
++ const total = docs.reduce((n, d) => n + d.free_vram, 0);
++ out.push({ id: p.id, head: p.head, created_at: p.created_at, machines: rows.map((r, i) => ({
++ id: r.machine_id, approved: Boolean(r.approved_at),
++ ready: Boolean(r.ready && r.seen_at && Date.parse(r.seen_at) > now - READY_WINDOW_MS),
++ // Credits are split by the VRAM each machine served; this is the announced share, not a speed claim.
++ vram_share: total ? docs[i].free_vram / total : 1 / rows.length,
++ })) });
++ }
++ return out;
++}
++/** The machines the owner can pool now, grouped by an opaque network key: no address or public IP leaves Network. */
++async function candidates(db, owner, now = Date.now()) {
++ const rows = await db.prepare('SELECT doc FROM machine_pool_candidates WHERE owner_subject = ? AND seen_at > ? ORDER BY machine_id').all(owner, iso(now - CANDIDATE_TTL_MS));
++ return rows.map((r) => JSON.parse(r.doc)).map((d) => ({ id: d.id, free_vram: d.free_vram, free_ram: d.free_ram, hardware: d.hardware, network: networkKey(d) }));
++}
++/**
++ * One stored AI offer as AI reads it. A machine outside any pool: as reported (a `pool` a reporter sent is dropped:
++ * only this record names one). The head of a pool: with `pool` and ready only while every member is. A member that
++ * is not the head: null, left out of the list.
++ */
++async function offerPool(db, offer, now = Date.now()) {
++ const { pool: _reported, ...own } = offer;
++ const row = await db.prepare('SELECT p.* FROM machine_pools p JOIN machine_pool_members m ON m.pool_id = p.id WHERE m.machine_id = ? AND p.owner_subject = ? AND p.revoked_at IS NULL').get(own.machine_id, own.owner);
++ if (!row) return own;
++ if (row.head !== own.machine_id) return null;
++ const members = await db.prepare('SELECT m.*, n.status AS node_status, n.owner_subject AS node_owner FROM machine_pool_members m JOIN platform_node_principals n ON n.id = m.principal_id WHERE m.pool_id = ? ORDER BY m.machine_id').all(row.id);
++ const ready = members.length >= 2 && members.every((m) => m.certificate && m.ready && m.seen_at && Date.parse(m.seen_at) > now - READY_WINDOW_MS && m.node_status === 'active' && m.node_owner === own.owner);
++ return { ...own, pool: { machines: members.map((m) => m.machine_id), head: row.head }, ready: Boolean(own.ready && ready),
++ health: ready ? own.health : { ...own.health, status: 'down' } };
++}
++function routers({ bot, requireAuth }) {
++ const user = express.Router(), self = express.Router();
++ const wrap = (fn) => async (req, res) => {
++ res.set('Cache-Control', 'private, no-store');
++ try { await fn(req, res, req.app.locals.db); }
++ catch (e) { res.status(e.status || 503).json({ error: e.status ? e.message : 'pool registry unavailable' }); }
++ };
++ user.use(requireAuth, express.json({ limit: '16kb' }));
++ const owner = async (db, req) => {
++ if (!req.user || req.user.is_anon) fail(403, 'sign in to manage pools');
++ return require('../identity/subjects').ensureUserSubject(db, req.user);
++ };
++ user.get('/', wrap(async (req, res, db) => {
++ const subject = await owner(db, req);
++ res.json({ pools: await list(db, subject), candidates: await candidates(db, subject) });
++ }));
++ user.post('/', wrap(async (req, res, db) => {
++ const pool = await propose(db, bot, await owner(db, req), req.body);
++ res.status(201).json({ pool: { id: pool.id, head: pool.head, machines: pool.machines.map((m) => ({ id: m.id, approved: false, ready: false })) } });
++ }));
++ user.post('/:id/revoke', wrap(async (req, res, db) => { await revoke(db, req.params.id, await owner(db, req)); res.json({ revoked: true }); }));
++ // Mounted under /api/v1/node/self, whose guard (server/index.js) has already checked the node token and
++ // network.node.self.manage once; every handler then resolves the live node principal (node()), which refuses a
++ // request that reached it without one.
++ self.use(express.json({ limit: '16kb' }));
++ self.post('/announce', wrap(async (req, res, db) => {
++ const p = await node(db, req), body = req.body || {};
++ const machines = await ownMachines(bot, p.owner_subject);
++ const machine = machines.find((m) => m.node_id === p.id);
++ if (!machine) fail(403, 'paired machine not found');
++ if (!subnet(body.address) || !Number.isInteger(body.port) || body.port < 1024 || body.port > 65535 ||
++ !['free_vram', 'free_ram'].every((k) => Number.isInteger(body[k]) && body[k] >= 0 && body[k] <= MAX_MB)) fail(400, 'invalid local address or free memory');
++ const publicIP = String(req.ip).replace(/^::ffff:/, '');
++ if (!net.isIP(publicIP)) fail(400, 'source address unavailable');
++ const doc = { id: machine.id, owner: p.owner_subject, address: body.address, public_ip: publicIP, port: body.port,
++ free_vram: body.free_vram, free_ram: body.free_ram, hardware: String(body.hardware || 'unknown').slice(0, 120) };
++ await db.prepare('INSERT INTO machine_pool_candidates (machine_id, principal_id, owner_subject, doc, seen_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT (machine_id) DO UPDATE SET principal_id = excluded.principal_id, owner_subject = excluded.owner_subject, doc = excluded.doc, seen_at = excluded.seen_at').run(machine.id, p.id, p.owner_subject, JSON.stringify(doc), iso(Date.now()));
++ res.json({ machine: doc });
++ }));
++ const member = async (db, req) => {
++ const p = await node(db, req);
++ const row = await db.prepare('SELECT machine_id FROM machine_pool_members WHERE pool_id = ? AND principal_id = ?').get(req.params.id, p.id);
++ if (!row) fail(404, 'pool not found');
++ return p;
++ };
++ self.get('/:id', wrap(async (req, res, db) => {
++ const p = await member(db, req);
++ res.json({ pool: await document(db, req.params.id, p.owner_subject, bot) });
++ }));
++ self.post('/:id/approve', wrap(async (req, res, db) => res.json({ pool: await approve(db, req.params.id, await node(db, req), (req.body || {}).certificate, bot) })));
++ self.post('/:id/status', wrap(async (req, res, db) => {
++ const p = await member(db, req);
++ const pool = await document(db, req.params.id, p.owner_subject, bot);
++ if (typeof (req.body || {}).ready !== 'boolean') fail(400, 'ready must be boolean');
++ const result = await db.prepare('UPDATE machine_pool_members SET ready = ?, seen_at = ? WHERE pool_id = ? AND principal_id = ? AND certificate IS NOT NULL').run(req.body.ready, iso(Date.now()), req.params.id, p.id);
++ if (!result.changes) fail(403, 'this machine has not approved');
++ // The renewal answers with the pool, so a member checks its membership with the same request.
++ res.json({ ok: true, pool });
++ }));
++ self.post('/:id/leave', wrap(async (req, res, db) => {
++ const p = await member(db, req);
++ await revoke(db, req.params.id, p.owner_subject);
++ res.json({ revoked: true });
++ }));
++ return { user, self };
++}
++module.exports = { subnet, sameNetwork, networkKey, certificate, node, document, propose, approve, revoke, list, candidates, offerPool, routers, READY_WINDOW_MS };
ddiff --git a/test/agent-schema.test.js b/test/agent-schema.test.js
iindex de5a203d..f78111c5 100644
---- a/test/agent-schema.test.js
++++ b/test/agent-schema.test.js
@@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// The real directory: 0016 applies on top of 0015, once.
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand']]);
++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand']]);
assert.ok(await hasAgents());
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
ddiff --git a/test/machine-pools.test.js b/test/machine-pools.test.js
nnew file mode 100644
iindex 00000000..3be2d42e
---- /dev/null
++++ b/test/machine-pools.test.js
@@@ -0,0 +1,119 @@
++'use strict';
++// Same-owner machine pools (server/registry/machine-pools.js): only the owner's own machines can be pooled, each
++// machine approves on itself, and the AI offer carries a pool only from this record. Isolated migrated PGlite: no
++// production connection. Run through test/run.js.
++const assert = require('node:assert/strict');
++const { execFileSync } = require('node:child_process');
++const fs = require('node:fs');
++const os = require('node:os');
++const path = require('node:path');
++const { ids } = require('openvibe-contracts');
++const pools = require('../server/registry/machine-pools');
++const { getDb } = require('../server/db/database');
++(async () => {
++ const db = getDb(), owner = `usr_${ids.ulid()}`, stranger = `usr_${ids.ulid()}`;
++ const own = [], principals = [], theirs = [];
++ const bot = { list: async (who) => ({ machines: who === owner ? own : who === stranger ? theirs : [] }) };
++ const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'pool-cert-'));
++ const machine = async (who, i, list, address = `192.168.1.${i + 2}/24`, publicIP = '203.0.113.1') => {
++ const principal = `nod_${ids.ulid()}`, id = `dev_${ids.ulid()}`;
++ list.push({ id, node_id: principal });
++ await db.prepare(`INSERT INTO platform_node_principals (id, node_id, home_cell, owner_kind, trust, owner_subject, credential_hash, created_by)
++ VALUES (?, ?, 'wnam-1', 'user', 'community', ?, ?, ?)`).run(principal, `pool-node-${id.slice(-12).toLowerCase()}`, who, require('node:crypto').createHash('sha256').update(id).digest('hex'), who);
++ const doc = { id, owner: who, address, public_ip: publicIP, port: 50053, free_vram: (i + 1) * 1000, free_ram: 16000, hardware: 'test' };
++ await db.prepare('INSERT INTO machine_pool_candidates (machine_id, principal_id, owner_subject, doc, seen_at) VALUES (?, ?, ?, ?, ?)')
++ .run(id, principal, who, JSON.stringify(doc), new Date().toISOString());
++ return { id: principal, owner_subject: who, machine: id };
++ };
++ try {
++ for (let i = 0; i < 3; i++) principals.push(await machine(owner, i, own));
++ const strangers = [await machine(stranger, 7, theirs)];
++ const elsewhere = await machine(owner, 8, own, '192.168.9.9/24', '198.51.100.4');
++
++ // Only the owner's own machines can be pooled.
++ await assert.rejects(pools.propose(db, bot, stranger, { machines: own.slice(0, 2).map((m) => m.id) }), /owner/, 'a stranger cannot pool my machines');
++ await assert.rejects(pools.propose(db, bot, owner, { machines: [own[0].id, strangers[0].machine] }), /owner/, 'I cannot pool a stranger\'s machine');
++ await assert.rejects(pools.propose(db, bot, owner, { machines: [own[0].id, elsewhere.machine] }), /same network/, 'a machine on another network is refused');
++ await assert.rejects(pools.propose(db, bot, owner, { machines: [own[0].id] }), /2-8/);
++ await assert.rejects(pools.propose(db, bot, owner, { machines: [own[0].id, own[0].id] }), /2-8/);
++ await assert.rejects(pools.propose(db, bot, owner, { machines: [own[0].id, own[1].id], head: own[2].id }), /head must be a member/);
++
++ const pool = await pools.propose(db, bot, owner, { machines: own.slice(0, 2).map((m) => m.id) });
++ assert.equal(pool.head, own[1].id, 'most free VRAM is the default head');
++ assert.ok(pool.machines.every((m) => m.certificate === null), 'a new pool has no approvals');
++ const offer = { offer_id: `ai:${pool.head}`, machine_id: pool.head, owner, ready: true, health: { status: 'up' } };
++ assert.equal((await pools.offerPool(db, offer)).ready, false, 'no traffic before approvals');
++
++ const certs = [];
++ for (let i = 0; i < 3; i++) {
++ const key = path.join(temp, `key${i}.pem`), cert = path.join(temp, `cert${i}.pem`);
++ execFileSync('openssl', ['req', '-x509', '-newkey', 'ed25519', '-nodes', '-days', '1', '-subj', '/CN=pool-test', '-keyout', key, '-out', cert], { stdio: 'ignore' });
++ certs.push(fs.readFileSync(cert, 'utf8'));
++ }
++ const privateKey = fs.readFileSync(path.join(temp, 'key0.pem'), 'utf8');
++
++ // The approve step is needed on each machine, by that machine.
++ for (const sub of ['svc:bot', `user:${owner}`, owner, 'app:app_01J8Z3Q4R5S6T7V8W9X0Y1Z2A1', '']) {
++ await assert.rejects(pools.node(db, { principal: { sub } }), /only the machine/, `${sub || 'no principal'} cannot act for a machine`);
++ }
++ await assert.rejects(pools.node(db, {}), /only the machine/, 'a request that skipped the node guard is refused');
++ await assert.rejects(pools.node(db, { principal: { sub: `node:nod_${ids.ulid()}` } }), /no longer paired/);
++ await assert.rejects(pools.approve(db, pool.id, principals[2], certs[2], bot), /not a member/, 'a machine outside the pool cannot approve it');
++ await assert.rejects(pools.approve(db, pool.id, strangers[0], certs[2], bot), /not found/, 'a stranger\'s machine cannot see the pool');
++ await assert.rejects(pools.approve(db, pool.id, principals[0], privateKey, bot), /public pool certificate/, 'a private key is refused');
++ await assert.rejects(pools.approve(db, pool.id, principals[0], 'not a certificate', bot), /invalid public pool certificate/);
++ await pools.approve(db, pool.id, principals[0], certs[0], bot);
++ assert.equal((await pools.offerPool(db, offer)).ready, false, 'one approval cannot approve the other machine');
++ let listed = (await pools.list(db, owner))[0];
++ assert.deepEqual(listed.machines.map((m) => m.approved).sort(), [false, true]);
++ await assert.rejects(pools.approve(db, pool.id, principals[1], certs[0], bot), /own key/);
++ await pools.approve(db, pool.id, principals[1], certs[1], bot);
++ await assert.rejects(pools.approve(db, pool.id, principals[0], certs[2], bot), /new pool/, 'a key change needs a new pool');
++
++ // Ready only while every member renews; a member is never a second offer for the same GPU.
++ await db.prepare('UPDATE machine_pool_members SET ready = true, seen_at = ? WHERE pool_id = ?').run(new Date().toISOString(), pool.id);
++ const live = await pools.offerPool(db, offer);
++ assert.equal(live.ready, true);
++ assert.deepEqual(new Set(live.pool.machines), new Set(own.slice(0, 2).map((m) => m.id)));
++ assert.equal(live.pool.head, pool.head);
++ assert.equal(await pools.offerPool(db, { ...offer, offer_id: `ai:${own[0].id}`, machine_id: own[0].id }), null, 'members do not double-sell the same GPU');
++ assert.equal((await pools.offerPool(db, offer, Date.now() + pools.READY_WINDOW_MS + 1000)).ready, false, 'a missed renewal stops the whole offer');
++ assert.equal((await pools.offerPool(db, offer, Date.now() + pools.READY_WINDOW_MS + 1000)).health.status, 'down');
++ listed = (await pools.list(db, owner))[0];
++ assert.ok(listed.machines.every((m) => m.approved && m.ready));
++ assert.equal(Math.round(listed.machines.reduce((n, m) => n + m.vram_share, 0) * 1000), 1000, 'the VRAM shares split the credits whole');
++ assert.ok(!('owner_subject' in listed) && !('revoked_at' in listed), 'the owner view is not a raw row');
++
++ // Only the pool record names a pool: a reported one is dropped.
++ const outside = { offer_id: `ai:${own[2].id}`, machine_id: own[2].id, owner, ready: true, health: { status: 'up' } };
++ const forged = await pools.offerPool(db, { ...outside, pool: { machines: [own[2].id, own[0].id], head: own[2].id } });
++ assert.equal(forged.pool, undefined, 'a machine cannot nominate a pool of its own');
++ assert.equal(forged.ready, true);
++
++ // The owner's page sees machines and an opaque network key, never an address or public IP.
++ const cands = await pools.candidates(db, owner);
++ assert.ok(cands.length >= 3);
++ assert.ok(cands.every((c) => !('address' in c) && !('public_ip' in c) && !JSON.stringify(c).includes('203.0.113')));
++ const keys = new Map(cands.map((c) => [c.id, c.network]));
++ assert.equal(keys.get(own[0].id), keys.get(own[1].id), 'same network, same key');
++ assert.notEqual(keys.get(own[0].id), keys.get(elsewhere.machine), 'another network, another key');
++ assert.ok(!cands.some((c) => c.id === strangers[0].machine), 'a stranger\'s machine is never listed');
++
++ await assert.rejects(pools.propose(db, bot, owner, { machines: [own[0].id, own[2].id] }), /already belongs/);
++
++ // Removing a machine from its owner revokes the whole pool, and the pool's offer goes with it.
++ own.splice(0, 1);
++ await assert.rejects(pools.document(db, pool.id, owner, bot), /membership changed/);
++ assert.equal((await pools.list(db, owner)).length, 0, 'removing a machine revokes its pool');
++ const after = await pools.offerPool(db, offer);
++ assert.equal(after.pool, undefined, 'a revoked pool is no longer offered');
++ const certsLeft = await db.prepare('SELECT COUNT(*) AS n FROM machine_pool_members WHERE pool_id = ? AND certificate IS NOT NULL').get(pool.id);
++ assert.equal(Number(certsLeft.n), 0, 'revocation forgets the public certificates');
++
++ assert.equal(pools.sameNetwork({ address: '192.168.1.2/24', public_ip: '1.1.1.1' }, { address: '192.168.2.2/24', public_ip: '1.1.1.1' }), false);
++ assert.equal(pools.sameNetwork({ address: '192.168.1.2/24', public_ip: '1.1.1.1' }, { address: '192.168.1.3/24', public_ip: '1.1.1.2' }), false);
++ assert.equal(pools.sameNetwork({ address: '192.168.1.2/24', public_ip: '1.1.1.1' }, { address: '192.168.1.2/24', public_ip: '1.1.1.1' }), false, 'one address is one machine');
++ for (const bad of ['0.0.0.0/24', '8.8.8.8/24', '127.0.0.1/8', '192.168.1.2/32', '192.168.1.2', '172.32.0.1/16']) assert.equal(pools.subnet(bad), null, bad);
++ console.log('machine-pools: owner isolation, per-machine approvals, unique public keys, head selection, renewals, member suppression, forged pools and revocation passed');
++ } finally { fs.rmSync(temp, { recursive: true, force: true }); }
++})().catch((err) => { console.error(err); process.exitCode = 1; });
ddiff --git a/test/user-owned-trust.test.js b/test/user-owned-trust.test.js
iindex 8d85dae2..44a4c814 100644
---- a/test/user-owned-trust.test.js
++++ b/test/user-owned-trust.test.js
@@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// ── 0020 applies on top of 0019, once, and rewrites nothing.
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand']]);
++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand']]);
assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twice
assert.deepStrictEqual(await rows(), seeded, 'no existing row changes');