openvibers/openvibe.network Commits
Machine pools: the record, the approve step on each machine, and the head's offer (#609)
Changes
nnew file mode 100644@@@ -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.nnew file mode 100644@@@ -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);@@@ -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; } }@@@ -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;++ });++}@@@ -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') });@@@ -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 }) {nnew file mode 100644@@@ -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 };@@@ -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.nnew file mode 100644@@@ -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; });@@@ -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 twiceassert.deepStrictEqual(await rows(), seeded, 'no existing row changes');
new file mode 100644
new file mode 100644
@@ -0,0 +1,66 @@
@@ -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.
new file mode 100644
new file mode 100644
@@ -0,0 +1,29 @@
@@ -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);
@@ -368,6 +368,8 @@ button.td-stat:hover span { color: var(--accent-light); }
@@ -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 { 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-revoke.is-sure { background: #ef4444; color: #fff; border-color: #ef4444; }
.mc-confirm { flex-basis: 100%; font-size: 12.5px; color: var(--text-secondary); }
.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 { 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); }
.mc-empty > i { font-size: 26px; color: var(--accent-light); }
@media (prefers-reduced-motion: reduce) { .mc-wait-dot { animation: none; } }
@media (prefers-reduced-motion: reduce) { .mc-wait-dot { animation: none; } }
@@ -44,6 +44,7 @@ function mcCard(m) {
@@ -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 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]);
const head = mcEl('div', 'mc-head', [dot, mcEl('div', 'mc-title', [name, state]), meta]);
card.append(head);
card.append(head);
+ (m.pools || []).forEach((pool) => card.append(mcPoolBlock(pool)));
if (Array.isArray(m.running) && m.running.length) {
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)]))));
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() {
@@ -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.'])]));
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;
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) {
} catch (e) {
list.replaceChildren(mcEl('p', 'muted', [e.status === 401 ? 'Sign in to see your machines.' : 'Your machines could not be read just now.']));
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;
+ });
+}
@@ -596,6 +596,11 @@ const notifyAccountData = async (userId, n) => await notificationService.create(
@@ -596,6 +596,11 @@ const notifyAccountData = async (userId, n) => await notificationService.create(
{
{
const machines = require('./registry/machines');
const machines = require('./registry/machines');
const bot = machines.createBotClient({ baseUrl: config.services.bot.internalUrl, privateKey, issuer: config.jwt.issuer });
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 });
const machineRouters = machines.routers({ bot, requireAuth, publicKey, issuer: config.jwt.issuer });
app.use('/api/v1/machines', rateLimit({ windowMs: 60_000, max: 60 }), machineRouters.hub);
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') });
const machineInternal = machines.internalRouters({ guard: require('./identity/principals').guard('network.node.manage') });
@@ -105,7 +105,8 @@ async function list(db, { source = null, owner = null } = {}) {
@@ -105,7 +105,8 @@ async function list(db, { source = null, owner = null } = {}) {
if (source) { where.push('source = ?'); args.push(source); }
if (source) { where.push('source = ?'); args.push(source); }
if (owner) { where.push('owner_subject = ?'); args.push(owner); }
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);
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 }) {
function routers({ guard, readGuard = guard }) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,249 @@
@@ -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 };
@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// The real directory: 0016 applies on top of 0015, once.
// The real directory: 0016 applies on top of 0015, once.
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
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.ok(await hasAgents());
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
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.
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
new file mode 100644
new file mode 100644
@@ -0,0 +1,119 @@
@@ -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; });
@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// ── 0020 applies on top of 0019, once, and rewrites nothing.
// ── 0020 applies on top of 0019, once, and rewrites nothing.
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
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');
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
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');
assert.deepStrictEqual(await rows(), seeded, 'no existing row changes');