openvibers/openvibe.network Commits
Community AI offers: a private report for Bot and a registry read for AI (#581)
Changes
nnew file mode 100644@@@ -0,0 +1,36 @@++# Community AI offers in Network++++OpenVibe.Node's `ai` plugin can offer a person's local models to the network ("Offer to the network"). Bot reports each++machine's offer, Network validates and stores it, and OpenVibe.AI reads it to place only opted-in requests.++++## Why its own table++++`platform.resource-offer@1` closes `detail` for `kind: node` and `kind: provider`, and a community model offer needs a++model list, hours, concurrency, a credit price and an audience. `platform_ai_offers` (migrations/0040) is that shape. It++is still a user-owned offer: `owner_subject` is required, there is no public route, and only a service holding++`network.registry.read` can read it whole.++++## API++++- `POST /internal/ai/offers/report` — `network.resource.report` (Bot). Body `{source, offers}`; source `bot`. Applies++ the whole set and marks this source's absent offers down (never deletes them).++- `GET /internal/ai/offers` — `network.registry.read` (AI). Returns `{offers, generated_at, count}` with the owner and++ the machine id, so AI can place and label the answer.++++The offer: `offer_id` `ai:<device>`, `machine_id`, `owner`, `models`, `hours {start,end,tz}`, `concurrency` 1-8,++`price {in_per_million,out_per_million}` in credits, `audience` `everyone|followers|group` (with `group`), `ready`, and++`health {status: up|degraded|down}`. `server/registry/ai-offers.js` validates every field before a write; one bad offer++refuses the whole report, so Bot leaves an incomplete one out.++++Network grants AI `network.registry.read` (the offers), `network.follows.read` (the `followers` audience) and++`identity.subject.resolve` (a requester merged into the owner is still the owner for self-dealing). Nothing here moves++money; the seller ledger is OpenVibe.AI's.++++## Test++++```sh++node test/run.js ai-offers++```++++It covers validation, the report/mark-down cycle, the read guard (AI reads, services without it do not), AI never++reporting, and the grants.nnew file mode 100644@@@ -0,0 +1,29 @@++-- phase: expand++-- Community AI offers (the design's "Offer to the network"; plan E4). A Node ai plugin advertises one machine's local++-- models, hours, concurrency, credit price and audience; Bot reports the complete set for source `bot`; AI reads it++-- internally with network.registry.read and places only opted-in requests on a verified machine. This is its own table,++-- not platform_resource_offers, because platform.resource-offer@1's detail slot is closed for kind node/provider and a++-- community model offer has fields the generic contract cannot carry. It is still a user-owned offer: owner_subject is++-- required, the public API never lists it, and only services holding network.registry.read can read it whole.++CREATE TABLE IF NOT EXISTS platform_ai_offers (++ id text COLLATE "C" PRIMARY KEY, -- ai:<device id>++ source text COLLATE "C" NOT NULL, -- bot++ machine_id text COLLATE "C" NOT NULL,++ owner_subject text COLLATE "C" NOT NULL,++ models jsonb NOT NULL,++ hours jsonb NOT NULL,++ concurrency integer NOT NULL CHECK (concurrency BETWEEN 1 AND 8),++ price_in numeric(20, 6) NOT NULL CHECK (price_in >= 0),++ price_out numeric(20, 6) NOT NULL CHECK (price_out >= 0),++ audience text COLLATE "C" NOT NULL CHECK (audience IN ('everyone', 'followers', 'group')),++ group_id text COLLATE "C",++ ready boolean NOT NULL DEFAULT false,++ status text COLLATE "C" NOT NULL DEFAULT 'up' CHECK (status IN ('up', 'degraded', 'down')),++ doc text COLLATE "C" NOT NULL,++ reported_at timestamptz NOT NULL,++ updated_at timestamptz NOT NULL,++ CHECK (audience <> 'group' OR (group_id IS NOT NULL AND group_id <> '')),++ CHECK (owner_subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$')++);++CREATE INDEX IF NOT EXISTS platform_ai_offers_source ON platform_ai_offers (source, status);++CREATE INDEX IF NOT EXISTS platform_ai_offers_owner ON platform_ai_offers (owner_subject, updated_at DESC);@@@ -474,6 +474,15 @@ const DEFAULT_GRANTS = [// developer API keys a caller shows it (POST /internal/api-keys/exchange). Both are behind// NETWORK_DEV_API_KEYS; the capability is first-party and never grantable to apps.['ai', 'network.project.read', SELF_AUDIENCE, []],++ // Community AI offers (Node's "Offer to the network", plan E4): AI reads the full internal resource list, including++ // each user-owned offer's owner, to place only opted-in requests on verified machines. Run and Bot hold the same read.++ ['ai', 'network.registry.read', SELF_AUDIENCE, []],++ // A community AI offer's "followers" audience: AI checks whether the requester follows the offer's owner (the++ // owner's channel) before placing a run. Read-only, like Live's.++ ['ai', 'network.follows.read', SELF_AUDIENCE, []],++ // Self-dealing: a community run whose requester is the offer owner (or an account merged into it) earns nothing.++ // AI resolves both subjects to the survivor before it records the seller's ledger.++ ['ai', 'identity.subject.resolve', SELF_AUDIENCE, []],// WS-N task 12: ovhost incident / maintenance post to the status page.['host', 'network.status.incident', SELF_AUDIENCE, []],// WS-L task 4: OpenVibe.Host's scheduled Tools job proof (openvibe-toolsjob.timer, OpenVibe.Examples@@@ -513,6 +513,16 @@ const notifyAccountData = async (userId, n) => await notificationService.create(app.use('/api/v1/offers', offerRouters.pub);app.use('/internal/resources', offerRouters.internal);}++// Community AI offers (the design's "Offer to the network"; plan E4): Bot reports each machine's models, hours,++// concurrency, credit price and audience; AI reads them whole to place only opted-in requests after verification.++// Private by construction: no public route, owner required, network.registry.read to read, network.resource.report to write.++{++ const { guard } = require('./identity/principals');++ app.use('/internal/ai/offers', require('./registry/ai-offers').routers({++ guard: guard('network.resource.report'),++ readGuard: guard('network.registry.read'),++ }).internal);++}// Network's authority resource index (ADR-048 section 3, capability network.resource.read): common.resource-summary@1// pages of the resources Network owns (projects, apps, node principals) for OpenVibe.Services' fan-out.{nnew file mode 100644@@@ -0,0 +1,128 @@++'use strict';++/**++ * Community AI offers in OpenVibe.Network (the design's "Offer to the network"; plan E4). OpenVibe.Node's ai plugin++ * advertises one machine's local models, hours, concurrency, credit price and audience; OpenVibe.Bot reports the++ * complete set for source `bot` (POST /internal/ai/offers/report, network.resource.report); a missing offer is marked++ * down, never deleted. OpenVibe.AI reads the live ones whole with network.registry.read (GET /internal/ai/offers) and++ * places only requests that opt in to community capacity, after its canaries, model fingerprint and spot re-runs.++ *++ * It is a user-owned offer: the owner is required, nothing here is public, and this table exists rather than++ * platform_resource_offers because platform.resource-offer@1 closes its detail slot for kind node/provider and cannot++ * carry a model list, hours, a credit price and an audience. The generic registry still holds worker and desk offers.++ *++ * POST /internal/ai/offers/report network.resource.report → 200 { source, offers, marked_down }++ * GET /internal/ai/offers network.registry.read → 200 { offers, generated_at, count }++ */++const express = require('express');++++const SOURCE = /^[a-z][a-z0-9-]{1,39}$/;++const MAX_OFFERS = 500;++const USR = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++const DEVICE = /^dev_[0-9A-HJKMNP-TV-Z]{26}$/;++const HHMM = /^([01][0-9]|2[0-3]):[0-5][0-9]$/;++const AUDIENCES = new Set(['everyone', 'followers', 'group']);++const STATUSES = new Set(['up', 'degraded', 'down']);++const isPrice = (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= 1000000;++++function ensureSchema(db) { /* the schema is migrations/0040_ai_offers.sql; nothing is created at runtime */ }++++const KEYS = new Set(['offer_id', 'machine_id', 'owner', 'models', 'hours', 'concurrency', 'price', 'audience', 'group', 'ready', 'health']);++const HEALTH_KEYS = new Set(['status', 'checked_at']);++++/** One offer as Bot reports it. → null, or the problem's code. */++function checkOffer(o) {++ if (!o || typeof o !== 'object' || Array.isArray(o)) return 'registry.ai_offer_shape';++ // Only the offer's own fields are stored: a reporter cannot smuggle prompt text or arbitrary data into the table.++ if (Object.keys(o).some((k) => !KEYS.has(k)) || (o.health && Object.keys(o.health).some((k) => !HEALTH_KEYS.has(k)))) {++ return 'registry.ai_offer_shape';++ }++ if (typeof o.offer_id !== 'string' || !o.offer_id.startsWith('ai:')) return 'registry.ai_offer_id';++ if (typeof o.machine_id !== 'string' || !DEVICE.test(o.machine_id) || o.offer_id !== `ai:${o.machine_id}`) return 'registry.ai_offer_machine';++ if (typeof o.owner !== 'string' || !USR.test(o.owner)) return 'registry.ai_offer_owner';++ if (!Array.isArray(o.models) || o.models.length < 1 || o.models.length > 40 || o.models.some((m) => typeof m !== 'string' || !m || m.length > 200)) {++ return 'registry.ai_offer_models';++ }++ const h = o.hours;++ if (!h || typeof h !== 'object' || !HHMM.test(String(h.start)) || !HHMM.test(String(h.end)) || typeof h.tz !== 'string' || !h.tz || h.tz.length > 64) {++ return 'registry.ai_offer_hours';++ }++ if (!Number.isInteger(o.concurrency) || o.concurrency < 1 || o.concurrency > 8) return 'registry.ai_offer_concurrency';++ if (!o.price || typeof o.price !== 'object' || !isPrice(o.price.in_per_million) || !isPrice(o.price.out_per_million)) return 'registry.ai_offer_price';++ if (!AUDIENCES.has(o.audience)) return 'registry.ai_offer_audience';++ if (o.audience === 'group' && (typeof o.group !== 'string' || !o.group.trim() || o.group.length > 80)) return 'registry.ai_offer_group';++ if (typeof o.ready !== 'boolean') return 'registry.ai_offer_ready';++ if (!o.health || !STATUSES.has(o.health.status)) return 'registry.ai_offer_health';++ return null;++}++++/** The whole report: {source, offers} and every offer. → null, or the 400 body for the first problem. */++function check(body) {++ const keys = body && typeof body === 'object' && !Array.isArray(body) ? Object.keys(body) : null;++ if (!keys || keys.some((k) => k !== 'source' && k !== 'offers') || typeof body.source !== 'string' || !SOURCE.test(body.source)++ || !Array.isArray(body.offers) || body.offers.length > MAX_OFFERS) {++ return { error: `The body must be {source, offers}: source matching ${SOURCE}, at most ${MAX_OFFERS} offers` };++ }++ for (let i = 0; i < body.offers.length; i++) {++ const bad = checkOffer(body.offers[i]);++ if (bad) return { error: bad, offer: i };++ }++ return null;++}++++/** Apply a checked report in one transaction; mark this source's other offers down. → { offers, marked_down } */++async function report(db, { source, offers }, now = new Date().toISOString()) {++ ensureSchema(db);++ const upsert = db.prepare(`INSERT INTO platform_ai_offers (id, source, machine_id, owner_subject, models, hours, concurrency, price_in, price_out, audience, group_id, ready, status, doc, reported_at, updated_at)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)++ ON CONFLICT (id) DO UPDATE SET source = excluded.source, machine_id = excluded.machine_id, owner_subject = excluded.owner_subject,++ models = excluded.models, hours = excluded.hours, concurrency = excluded.concurrency, price_in = excluded.price_in, price_out = excluded.price_out,++ audience = excluded.audience, group_id = excluded.group_id, ready = excluded.ready, status = excluded.status, doc = excluded.doc,++ reported_at = excluded.reported_at, updated_at = excluded.updated_at`);++ let marked = 0;++ await db.tx(async () => {++ for (const o of offers) {++ await upsert.run(o.offer_id, source, o.machine_id, o.owner, JSON.stringify(o.models), JSON.stringify(o.hours), o.concurrency,++ o.price.in_per_million, o.price.out_per_million, o.audience, o.group || null, o.ready, o.health.status, JSON.stringify(o), now, now);++ }++ const ids = new Set(offers.map((o) => o.offer_id));++ for (const row of await db.prepare('SELECT id, doc FROM platform_ai_offers WHERE source = ? AND status <> ?').all(source, 'down')) {++ if (ids.has(row.id)) continue;++ const doc = JSON.parse(row.doc);++ doc.health = { ...(doc.health || {}), status: 'down' };++ doc.ready = false;++ await db.prepare('UPDATE platform_ai_offers SET doc = ?, status = ?, ready = false, updated_at = ? WHERE id = ?').run(JSON.stringify(doc), 'down', now, row.id);++ marked++;++ }++ });++ return { offers: offers.length, marked_down: marked };++}++++/** The stored offers, up first, as the reporter's docs. It includes down ones so AI can show a machine is away. */++async function list(db, { source = null, owner = null } = {}) {++ ensureSchema(db);++ const where = [];++ const args = [];++ 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));++}++++function routers({ guard, readGuard = guard }) {++ const internal = express.Router();++ const noStore = (res) => res.set('Cache-Control', 'no-store');++ internal.get('/', readGuard, async (req, res) => {++ const offers = await list(req.app.locals.db, { source: typeof req.query.source === 'string' ? req.query.source : null,++ owner: typeof req.query.owner === 'string' ? req.query.owner : null });++ noStore(res).json({ offers, generated_at: new Date().toISOString(), count: offers.length });++ });++ internal.post('/report', guard, express.json({ limit: '256kb' }), async (req, res) => {++ const bad = check(req.body);++ if (bad) return res.status(400).json(bad);++ const out = await report(req.app.locals.db, req.body);++ noStore(res).set('X-Offers-Marked-Down', String(out.marked_down)).json({ source: req.body.source, ...out, generated_at: new Date().toISOString() });++ });++ return { internal };++}++++module.exports = { ensureSchema, check, checkOffer, report, list, routers };@@@ -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']]);++ 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']]);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,107 @@++'use strict';++// Community AI offers in Network (plan E4, the design's "Offer to the network"): Bot reports one machine's models,++// hours, concurrency, credit price and audience; Network validates the offer strictly, keeps it with user-owned++// offers, and lets only a service holding network.registry.read (AI) read the whole set. The offers are never public.++// node test/ai-offers.test.js++const assert = require('assert');++const crypto = require('crypto');++const http = require('http');++const express = require('express');++const { getDb } = require('../server/db/database');++const principals = require('../server/identity/principals');++const aiOffers = require('../server/registry/ai-offers');++++const OWNER = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A1';++const DEVICE = 'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2D1';++const ID = `ai:${DEVICE}`;++const offer = () => ({++ offer_id: ID, machine_id: DEVICE, owner: OWNER,++ models: ['llama3.1:8b', 'qwen2.5:7b'], hours: { start: '09:00', end: '23:00', tz: 'Europe/Lisbon' },++ concurrency: 2, price: { in_per_million: 10, out_per_million: 30 }, audience: 'followers', group: null,++ ready: true, health: { status: 'up' },++});++++(async () => {++ const log = console.log; console.log = () => {};++ const db = getDb();++ console.log = log;++ for (const id of ['bot', 'ai', 'tools']) {++ await db.prepare(`INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES (?, ?, ?, '[]', 1)++ ON CONFLICT (client_id) DO UPDATE SET client_secret = EXCLUDED.client_secret`).run(id, `${id}-secret`, id);++ }++ await principals.ensureSchema(db);++ const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });++ const app = express();++ app.use(express.urlencoded({ extended: true }));++ app.locals.db = db;++ app.locals.config = { jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };++ app.locals.privateKey = keys.privateKey;++ app.locals.publicKey = keys.publicKey;++ app.use('/oauth', require('../server/auth/oauth-routes'));++ app.use('/internal/ai/offers', aiOffers.routers({++ guard: principals.guard('network.resource.report'),++ readGuard: principals.guard('network.registry.read'),++ }).internal);++ const server = http.createServer(app);++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));++ const base = `http://127.0.0.1:${server.address().port}`;++ const token = async (id) => (await (await fetch(`${base}/oauth/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'client_credentials', client_id: id, client_secret: `${id}-secret`, audience: 'openvibe.network' }) })).json()).access_token;++ const get = (path, tok) => fetch(`${base}${path}`, { headers: tok ? { authorization: `Bearer ${tok}` } : {} }).then(async (x) => ({ status: x.status, body: await x.json().catch(() => null) }));++ try {++ const good = offer();++ assert.strictEqual(aiOffers.check({ source: 'bot', offers: [good] }), null, 'a complete AI offer passes');++ const bad = [++ ['id', (o) => { o.offer_id = 'nope'; }, 'registry.ai_offer_id'],++ ['machine', (o) => { o.machine_id = 'dev_nope'; }, 'registry.ai_offer_machine'],++ ['owner', (o) => { o.owner = 'nobody'; }, 'registry.ai_offer_owner'],++ ['models', (o) => { o.models = []; }, 'registry.ai_offer_models'],++ ['hours', (o) => { o.hours.start = '25:00'; }, 'registry.ai_offer_hours'],++ ['tz', (o) => { o.hours.tz = ''; }, 'registry.ai_offer_hours'],++ ['concurrency', (o) => { o.concurrency = 9; }, 'registry.ai_offer_concurrency'],++ ['price', (o) => { o.price.out_per_million = -1; }, 'registry.ai_offer_price'],++ ['price-number', (o) => { o.price.in_per_million = 'free'; }, 'registry.ai_offer_price'],++ ['audience', (o) => { o.audience = 'friends'; }, 'registry.ai_offer_audience'],++ ['group', (o) => { o.audience = 'group'; o.group = null; }, 'registry.ai_offer_group'],++ ['ready', (o) => { delete o.ready; }, 'registry.ai_offer_ready'],++ ['health', (o) => { o.health = { status: 'sick' }; }, 'registry.ai_offer_health'],++ ];++ for (const [name, mutate, code] of bad) {++ const o = offer(); mutate(o);++ const c = aiOffers.check({ source: 'bot', offers: [o] });++ assert.ok(c && c.error === code, `${name}: ${JSON.stringify(c)}`);++ }++++ const bot = await token('bot');++ const ai = await token('ai');++ const tools = await token('tools');++ const posted = await fetch(`${base}/internal/ai/offers/report`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${bot}` },++ body: JSON.stringify({ source: 'bot', offers: [good] }) });++ assert.strictEqual(posted.status, 200, await posted.text());++ const read = await get('/internal/ai/offers', ai);++ assert.strictEqual(read.status, 200, 'AI reads the offers with network.registry.read');++ assert.strictEqual(read.body.offers[0].owner, OWNER);++ assert.deepStrictEqual(read.body.offers[0].models, ['llama3.1:8b', 'qwen2.5:7b']);++ assert.strictEqual((await get('/internal/ai/offers', tools)).status, 403, 'a service without the read does not');++ assert.strictEqual((await get('/internal/ai/offers')).status, 403, 'nor does anyone without a token');++ const reportByAi = await fetch(`${base}/internal/ai/offers/report`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${ai}` },++ body: JSON.stringify({ source: 'bot', offers: [good] }) });++ assert.strictEqual(reportByAi.status, 403, 'AI reads but never reports');++ // A later report without the offer marks it down; it is kept, not deleted.++ const empty = await fetch(`${base}/internal/ai/offers/report`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${bot}` },++ body: JSON.stringify({ source: 'bot', offers: [] }) });++ assert.strictEqual(empty.status, 200);++ assert.strictEqual((await empty.json()).marked_down, 1);++ const down = await get('/internal/ai/offers', ai);++ assert.strictEqual(down.body.offers[0].health.status, 'down');++ assert.strictEqual(down.body.offers[0].ready, false);++ const has = (svc, cap) => principals.DEFAULT_GRANTS.some(([s, c, a]) => s === svc && c === cap && a === 'openvibe.network');++ assert.ok(has('ai', 'network.registry.read'), 'AI holds the internal read');++ assert.ok(has('ai', 'network.follows.read'), 'AI can resolve the followers audience');++ assert.ok(has('ai', 'identity.subject.resolve'), 'AI resolves merged subjects for self-dealing');++ assert.ok(has('bot', 'network.resource.report'), 'Bot reports');++ console.log('ai-offers: ok');++ } finally {++ await db.prepare('DELETE FROM platform_ai_offers WHERE id = ?').run(ID);++ server.close();++ }++})().catch((err) => { console.error(err); process.exit(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']]);++ 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']]);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,36 @@
@@ -0,0 +1,36 @@
+# Community AI offers in Network
+
+OpenVibe.Node's `ai` plugin can offer a person's local models to the network ("Offer to the network"). Bot reports each
+machine's offer, Network validates and stores it, and OpenVibe.AI reads it to place only opted-in requests.
+
+## Why its own table
+
+`platform.resource-offer@1` closes `detail` for `kind: node` and `kind: provider`, and a community model offer needs a
+model list, hours, concurrency, a credit price and an audience. `platform_ai_offers` (migrations/0040) is that shape. It
+is still a user-owned offer: `owner_subject` is required, there is no public route, and only a service holding
+`network.registry.read` can read it whole.
+
+## API
+
+- `POST /internal/ai/offers/report` — `network.resource.report` (Bot). Body `{source, offers}`; source `bot`. Applies
+ the whole set and marks this source's absent offers down (never deletes them).
+- `GET /internal/ai/offers` — `network.registry.read` (AI). Returns `{offers, generated_at, count}` with the owner and
+ the machine id, so AI can place and label the answer.
+
+The offer: `offer_id` `ai:<device>`, `machine_id`, `owner`, `models`, `hours {start,end,tz}`, `concurrency` 1-8,
+`price {in_per_million,out_per_million}` in credits, `audience` `everyone|followers|group` (with `group`), `ready`, and
+`health {status: up|degraded|down}`. `server/registry/ai-offers.js` validates every field before a write; one bad offer
+refuses the whole report, so Bot leaves an incomplete one out.
+
+Network grants AI `network.registry.read` (the offers), `network.follows.read` (the `followers` audience) and
+`identity.subject.resolve` (a requester merged into the owner is still the owner for self-dealing). Nothing here moves
+money; the seller ledger is OpenVibe.AI's.
+
+## Test
+
+```sh
+node test/run.js ai-offers
+```
+
+It covers validation, the report/mark-down cycle, the read guard (AI reads, services without it do not), AI never
+reporting, and the grants.
new file mode 100644
new file mode 100644
@@ -0,0 +1,29 @@
@@ -0,0 +1,29 @@
+-- phase: expand
+-- Community AI offers (the design's "Offer to the network"; plan E4). A Node ai plugin advertises one machine's local
+-- models, hours, concurrency, credit price and audience; Bot reports the complete set for source `bot`; AI reads it
+-- internally with network.registry.read and places only opted-in requests on a verified machine. This is its own table,
+-- not platform_resource_offers, because platform.resource-offer@1's detail slot is closed for kind node/provider and a
+-- community model offer has fields the generic contract cannot carry. It is still a user-owned offer: owner_subject is
+-- required, the public API never lists it, and only services holding network.registry.read can read it whole.
+CREATE TABLE IF NOT EXISTS platform_ai_offers (
+ id text COLLATE "C" PRIMARY KEY, -- ai:<device id>
+ source text COLLATE "C" NOT NULL, -- bot
+ machine_id text COLLATE "C" NOT NULL,
+ owner_subject text COLLATE "C" NOT NULL,
+ models jsonb NOT NULL,
+ hours jsonb NOT NULL,
+ concurrency integer NOT NULL CHECK (concurrency BETWEEN 1 AND 8),
+ price_in numeric(20, 6) NOT NULL CHECK (price_in >= 0),
+ price_out numeric(20, 6) NOT NULL CHECK (price_out >= 0),
+ audience text COLLATE "C" NOT NULL CHECK (audience IN ('everyone', 'followers', 'group')),
+ group_id text COLLATE "C",
+ ready boolean NOT NULL DEFAULT false,
+ status text COLLATE "C" NOT NULL DEFAULT 'up' CHECK (status IN ('up', 'degraded', 'down')),
+ doc text COLLATE "C" NOT NULL,
+ reported_at timestamptz NOT NULL,
+ updated_at timestamptz NOT NULL,
+ CHECK (audience <> 'group' OR (group_id IS NOT NULL AND group_id <> '')),
+ CHECK (owner_subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$')
+);
+CREATE INDEX IF NOT EXISTS platform_ai_offers_source ON platform_ai_offers (source, status);
+CREATE INDEX IF NOT EXISTS platform_ai_offers_owner ON platform_ai_offers (owner_subject, updated_at DESC);
@@ -474,6 +474,15 @@ const DEFAULT_GRANTS = [
@@ -474,6 +474,15 @@ const DEFAULT_GRANTS = [
// developer API keys a caller shows it (POST /internal/api-keys/exchange). Both are behind
// developer API keys a caller shows it (POST /internal/api-keys/exchange). Both are behind
// NETWORK_DEV_API_KEYS; the capability is first-party and never grantable to apps.
// NETWORK_DEV_API_KEYS; the capability is first-party and never grantable to apps.
['ai', 'network.project.read', SELF_AUDIENCE, []],
['ai', 'network.project.read', SELF_AUDIENCE, []],
+ // Community AI offers (Node's "Offer to the network", plan E4): AI reads the full internal resource list, including
+ // each user-owned offer's owner, to place only opted-in requests on verified machines. Run and Bot hold the same read.
+ ['ai', 'network.registry.read', SELF_AUDIENCE, []],
+ // A community AI offer's "followers" audience: AI checks whether the requester follows the offer's owner (the
+ // owner's channel) before placing a run. Read-only, like Live's.
+ ['ai', 'network.follows.read', SELF_AUDIENCE, []],
+ // Self-dealing: a community run whose requester is the offer owner (or an account merged into it) earns nothing.
+ // AI resolves both subjects to the survivor before it records the seller's ledger.
+ ['ai', 'identity.subject.resolve', SELF_AUDIENCE, []],
// WS-N task 12: ovhost incident / maintenance post to the status page.
// WS-N task 12: ovhost incident / maintenance post to the status page.
['host', 'network.status.incident', SELF_AUDIENCE, []],
['host', 'network.status.incident', SELF_AUDIENCE, []],
// WS-L task 4: OpenVibe.Host's scheduled Tools job proof (openvibe-toolsjob.timer, OpenVibe.Examples
// WS-L task 4: OpenVibe.Host's scheduled Tools job proof (openvibe-toolsjob.timer, OpenVibe.Examples
@@ -513,6 +513,16 @@ const notifyAccountData = async (userId, n) => await notificationService.create(
@@ -513,6 +513,16 @@ const notifyAccountData = async (userId, n) => await notificationService.create(
app.use('/api/v1/offers', offerRouters.pub);
app.use('/api/v1/offers', offerRouters.pub);
app.use('/internal/resources', offerRouters.internal);
app.use('/internal/resources', offerRouters.internal);
}
}
+// Community AI offers (the design's "Offer to the network"; plan E4): Bot reports each machine's models, hours,
+// concurrency, credit price and audience; AI reads them whole to place only opted-in requests after verification.
+// Private by construction: no public route, owner required, network.registry.read to read, network.resource.report to write.
+{
+ const { guard } = require('./identity/principals');
+ app.use('/internal/ai/offers', require('./registry/ai-offers').routers({
+ guard: guard('network.resource.report'),
+ readGuard: guard('network.registry.read'),
+ }).internal);
+}
// Network's authority resource index (ADR-048 section 3, capability network.resource.read): common.resource-summary@1
// Network's authority resource index (ADR-048 section 3, capability network.resource.read): common.resource-summary@1
// pages of the resources Network owns (projects, apps, node principals) for OpenVibe.Services' fan-out.
// pages of the resources Network owns (projects, apps, node principals) for OpenVibe.Services' fan-out.
{
{
new file mode 100644
new file mode 100644
@@ -0,0 +1,128 @@
@@ -0,0 +1,128 @@
+'use strict';
+/**
+ * Community AI offers in OpenVibe.Network (the design's "Offer to the network"; plan E4). OpenVibe.Node's ai plugin
+ * advertises one machine's local models, hours, concurrency, credit price and audience; OpenVibe.Bot reports the
+ * complete set for source `bot` (POST /internal/ai/offers/report, network.resource.report); a missing offer is marked
+ * down, never deleted. OpenVibe.AI reads the live ones whole with network.registry.read (GET /internal/ai/offers) and
+ * places only requests that opt in to community capacity, after its canaries, model fingerprint and spot re-runs.
+ *
+ * It is a user-owned offer: the owner is required, nothing here is public, and this table exists rather than
+ * platform_resource_offers because platform.resource-offer@1 closes its detail slot for kind node/provider and cannot
+ * carry a model list, hours, a credit price and an audience. The generic registry still holds worker and desk offers.
+ *
+ * POST /internal/ai/offers/report network.resource.report → 200 { source, offers, marked_down }
+ * GET /internal/ai/offers network.registry.read → 200 { offers, generated_at, count }
+ */
+const express = require('express');
+
+const SOURCE = /^[a-z][a-z0-9-]{1,39}$/;
+const MAX_OFFERS = 500;
+const USR = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+const DEVICE = /^dev_[0-9A-HJKMNP-TV-Z]{26}$/;
+const HHMM = /^([01][0-9]|2[0-3]):[0-5][0-9]$/;
+const AUDIENCES = new Set(['everyone', 'followers', 'group']);
+const STATUSES = new Set(['up', 'degraded', 'down']);
+const isPrice = (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= 1000000;
+
+function ensureSchema(db) { /* the schema is migrations/0040_ai_offers.sql; nothing is created at runtime */ }
+
+const KEYS = new Set(['offer_id', 'machine_id', 'owner', 'models', 'hours', 'concurrency', 'price', 'audience', 'group', 'ready', 'health']);
+const HEALTH_KEYS = new Set(['status', 'checked_at']);
+
+/** One offer as Bot reports it. → null, or the problem's code. */
+function checkOffer(o) {
+ if (!o || typeof o !== 'object' || Array.isArray(o)) return 'registry.ai_offer_shape';
+ // Only the offer's own fields are stored: a reporter cannot smuggle prompt text or arbitrary data into the table.
+ if (Object.keys(o).some((k) => !KEYS.has(k)) || (o.health && Object.keys(o.health).some((k) => !HEALTH_KEYS.has(k)))) {
+ return 'registry.ai_offer_shape';
+ }
+ if (typeof o.offer_id !== 'string' || !o.offer_id.startsWith('ai:')) return 'registry.ai_offer_id';
+ if (typeof o.machine_id !== 'string' || !DEVICE.test(o.machine_id) || o.offer_id !== `ai:${o.machine_id}`) return 'registry.ai_offer_machine';
+ if (typeof o.owner !== 'string' || !USR.test(o.owner)) return 'registry.ai_offer_owner';
+ if (!Array.isArray(o.models) || o.models.length < 1 || o.models.length > 40 || o.models.some((m) => typeof m !== 'string' || !m || m.length > 200)) {
+ return 'registry.ai_offer_models';
+ }
+ const h = o.hours;
+ if (!h || typeof h !== 'object' || !HHMM.test(String(h.start)) || !HHMM.test(String(h.end)) || typeof h.tz !== 'string' || !h.tz || h.tz.length > 64) {
+ return 'registry.ai_offer_hours';
+ }
+ if (!Number.isInteger(o.concurrency) || o.concurrency < 1 || o.concurrency > 8) return 'registry.ai_offer_concurrency';
+ if (!o.price || typeof o.price !== 'object' || !isPrice(o.price.in_per_million) || !isPrice(o.price.out_per_million)) return 'registry.ai_offer_price';
+ if (!AUDIENCES.has(o.audience)) return 'registry.ai_offer_audience';
+ if (o.audience === 'group' && (typeof o.group !== 'string' || !o.group.trim() || o.group.length > 80)) return 'registry.ai_offer_group';
+ if (typeof o.ready !== 'boolean') return 'registry.ai_offer_ready';
+ if (!o.health || !STATUSES.has(o.health.status)) return 'registry.ai_offer_health';
+ return null;
+}
+
+/** The whole report: {source, offers} and every offer. → null, or the 400 body for the first problem. */
+function check(body) {
+ const keys = body && typeof body === 'object' && !Array.isArray(body) ? Object.keys(body) : null;
+ if (!keys || keys.some((k) => k !== 'source' && k !== 'offers') || typeof body.source !== 'string' || !SOURCE.test(body.source)
+ || !Array.isArray(body.offers) || body.offers.length > MAX_OFFERS) {
+ return { error: `The body must be {source, offers}: source matching ${SOURCE}, at most ${MAX_OFFERS} offers` };
+ }
+ for (let i = 0; i < body.offers.length; i++) {
+ const bad = checkOffer(body.offers[i]);
+ if (bad) return { error: bad, offer: i };
+ }
+ return null;
+}
+
+/** Apply a checked report in one transaction; mark this source's other offers down. → { offers, marked_down } */
+async function report(db, { source, offers }, now = new Date().toISOString()) {
+ ensureSchema(db);
+ const upsert = db.prepare(`INSERT INTO platform_ai_offers (id, source, machine_id, owner_subject, models, hours, concurrency, price_in, price_out, audience, group_id, ready, status, doc, reported_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT (id) DO UPDATE SET source = excluded.source, machine_id = excluded.machine_id, owner_subject = excluded.owner_subject,
+ models = excluded.models, hours = excluded.hours, concurrency = excluded.concurrency, price_in = excluded.price_in, price_out = excluded.price_out,
+ audience = excluded.audience, group_id = excluded.group_id, ready = excluded.ready, status = excluded.status, doc = excluded.doc,
+ reported_at = excluded.reported_at, updated_at = excluded.updated_at`);
+ let marked = 0;
+ await db.tx(async () => {
+ for (const o of offers) {
+ await upsert.run(o.offer_id, source, o.machine_id, o.owner, JSON.stringify(o.models), JSON.stringify(o.hours), o.concurrency,
+ o.price.in_per_million, o.price.out_per_million, o.audience, o.group || null, o.ready, o.health.status, JSON.stringify(o), now, now);
+ }
+ const ids = new Set(offers.map((o) => o.offer_id));
+ for (const row of await db.prepare('SELECT id, doc FROM platform_ai_offers WHERE source = ? AND status <> ?').all(source, 'down')) {
+ if (ids.has(row.id)) continue;
+ const doc = JSON.parse(row.doc);
+ doc.health = { ...(doc.health || {}), status: 'down' };
+ doc.ready = false;
+ await db.prepare('UPDATE platform_ai_offers SET doc = ?, status = ?, ready = false, updated_at = ? WHERE id = ?').run(JSON.stringify(doc), 'down', now, row.id);
+ marked++;
+ }
+ });
+ return { offers: offers.length, marked_down: marked };
+}
+
+/** The stored offers, up first, as the reporter's docs. It includes down ones so AI can show a machine is away. */
+async function list(db, { source = null, owner = null } = {}) {
+ ensureSchema(db);
+ const where = [];
+ const args = [];
+ 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));
+}
+
+function routers({ guard, readGuard = guard }) {
+ const internal = express.Router();
+ const noStore = (res) => res.set('Cache-Control', 'no-store');
+ internal.get('/', readGuard, async (req, res) => {
+ const offers = await list(req.app.locals.db, { source: typeof req.query.source === 'string' ? req.query.source : null,
+ owner: typeof req.query.owner === 'string' ? req.query.owner : null });
+ noStore(res).json({ offers, generated_at: new Date().toISOString(), count: offers.length });
+ });
+ internal.post('/report', guard, express.json({ limit: '256kb' }), async (req, res) => {
+ const bad = check(req.body);
+ if (bad) return res.status(400).json(bad);
+ const out = await report(req.app.locals.db, req.body);
+ noStore(res).set('X-Offers-Marked-Down', String(out.marked_down)).json({ source: req.body.source, ...out, generated_at: new Date().toISOString() });
+ });
+ return { internal };
+}
+
+module.exports = { ensureSchema, check, checkOffer, report, list, routers };
@@ -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']]);
+ 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']]);
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,107 @@
@@ -0,0 +1,107 @@
+'use strict';
+// Community AI offers in Network (plan E4, the design's "Offer to the network"): Bot reports one machine's models,
+// hours, concurrency, credit price and audience; Network validates the offer strictly, keeps it with user-owned
+// offers, and lets only a service holding network.registry.read (AI) read the whole set. The offers are never public.
+// node test/ai-offers.test.js
+const assert = require('assert');
+const crypto = require('crypto');
+const http = require('http');
+const express = require('express');
+const { getDb } = require('../server/db/database');
+const principals = require('../server/identity/principals');
+const aiOffers = require('../server/registry/ai-offers');
+
+const OWNER = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A1';
+const DEVICE = 'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2D1';
+const ID = `ai:${DEVICE}`;
+const offer = () => ({
+ offer_id: ID, machine_id: DEVICE, owner: OWNER,
+ models: ['llama3.1:8b', 'qwen2.5:7b'], hours: { start: '09:00', end: '23:00', tz: 'Europe/Lisbon' },
+ concurrency: 2, price: { in_per_million: 10, out_per_million: 30 }, audience: 'followers', group: null,
+ ready: true, health: { status: 'up' },
+});
+
+(async () => {
+ const log = console.log; console.log = () => {};
+ const db = getDb();
+ console.log = log;
+ for (const id of ['bot', 'ai', 'tools']) {
+ await db.prepare(`INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES (?, ?, ?, '[]', 1)
+ ON CONFLICT (client_id) DO UPDATE SET client_secret = EXCLUDED.client_secret`).run(id, `${id}-secret`, id);
+ }
+ await principals.ensureSchema(db);
+ const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
+ const app = express();
+ app.use(express.urlencoded({ extended: true }));
+ app.locals.db = db;
+ app.locals.config = { jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };
+ app.locals.privateKey = keys.privateKey;
+ app.locals.publicKey = keys.publicKey;
+ app.use('/oauth', require('../server/auth/oauth-routes'));
+ app.use('/internal/ai/offers', aiOffers.routers({
+ guard: principals.guard('network.resource.report'),
+ readGuard: principals.guard('network.registry.read'),
+ }).internal);
+ const server = http.createServer(app);
+ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const token = async (id) => (await (await fetch(`${base}/oauth/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'client_credentials', client_id: id, client_secret: `${id}-secret`, audience: 'openvibe.network' }) })).json()).access_token;
+ const get = (path, tok) => fetch(`${base}${path}`, { headers: tok ? { authorization: `Bearer ${tok}` } : {} }).then(async (x) => ({ status: x.status, body: await x.json().catch(() => null) }));
+ try {
+ const good = offer();
+ assert.strictEqual(aiOffers.check({ source: 'bot', offers: [good] }), null, 'a complete AI offer passes');
+ const bad = [
+ ['id', (o) => { o.offer_id = 'nope'; }, 'registry.ai_offer_id'],
+ ['machine', (o) => { o.machine_id = 'dev_nope'; }, 'registry.ai_offer_machine'],
+ ['owner', (o) => { o.owner = 'nobody'; }, 'registry.ai_offer_owner'],
+ ['models', (o) => { o.models = []; }, 'registry.ai_offer_models'],
+ ['hours', (o) => { o.hours.start = '25:00'; }, 'registry.ai_offer_hours'],
+ ['tz', (o) => { o.hours.tz = ''; }, 'registry.ai_offer_hours'],
+ ['concurrency', (o) => { o.concurrency = 9; }, 'registry.ai_offer_concurrency'],
+ ['price', (o) => { o.price.out_per_million = -1; }, 'registry.ai_offer_price'],
+ ['price-number', (o) => { o.price.in_per_million = 'free'; }, 'registry.ai_offer_price'],
+ ['audience', (o) => { o.audience = 'friends'; }, 'registry.ai_offer_audience'],
+ ['group', (o) => { o.audience = 'group'; o.group = null; }, 'registry.ai_offer_group'],
+ ['ready', (o) => { delete o.ready; }, 'registry.ai_offer_ready'],
+ ['health', (o) => { o.health = { status: 'sick' }; }, 'registry.ai_offer_health'],
+ ];
+ for (const [name, mutate, code] of bad) {
+ const o = offer(); mutate(o);
+ const c = aiOffers.check({ source: 'bot', offers: [o] });
+ assert.ok(c && c.error === code, `${name}: ${JSON.stringify(c)}`);
+ }
+
+ const bot = await token('bot');
+ const ai = await token('ai');
+ const tools = await token('tools');
+ const posted = await fetch(`${base}/internal/ai/offers/report`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${bot}` },
+ body: JSON.stringify({ source: 'bot', offers: [good] }) });
+ assert.strictEqual(posted.status, 200, await posted.text());
+ const read = await get('/internal/ai/offers', ai);
+ assert.strictEqual(read.status, 200, 'AI reads the offers with network.registry.read');
+ assert.strictEqual(read.body.offers[0].owner, OWNER);
+ assert.deepStrictEqual(read.body.offers[0].models, ['llama3.1:8b', 'qwen2.5:7b']);
+ assert.strictEqual((await get('/internal/ai/offers', tools)).status, 403, 'a service without the read does not');
+ assert.strictEqual((await get('/internal/ai/offers')).status, 403, 'nor does anyone without a token');
+ const reportByAi = await fetch(`${base}/internal/ai/offers/report`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${ai}` },
+ body: JSON.stringify({ source: 'bot', offers: [good] }) });
+ assert.strictEqual(reportByAi.status, 403, 'AI reads but never reports');
+ // A later report without the offer marks it down; it is kept, not deleted.
+ const empty = await fetch(`${base}/internal/ai/offers/report`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${bot}` },
+ body: JSON.stringify({ source: 'bot', offers: [] }) });
+ assert.strictEqual(empty.status, 200);
+ assert.strictEqual((await empty.json()).marked_down, 1);
+ const down = await get('/internal/ai/offers', ai);
+ assert.strictEqual(down.body.offers[0].health.status, 'down');
+ assert.strictEqual(down.body.offers[0].ready, false);
+ const has = (svc, cap) => principals.DEFAULT_GRANTS.some(([s, c, a]) => s === svc && c === cap && a === 'openvibe.network');
+ assert.ok(has('ai', 'network.registry.read'), 'AI holds the internal read');
+ assert.ok(has('ai', 'network.follows.read'), 'AI can resolve the followers audience');
+ assert.ok(has('ai', 'identity.subject.resolve'), 'AI resolves merged subjects for self-dealing');
+ assert.ok(has('bot', 'network.resource.report'), 'Bot reports');
+ console.log('ai-offers: ok');
+ } finally {
+ await db.prepare('DELETE FROM platform_ai_offers WHERE id = ?').run(ID);
+ server.close();
+ }
+})().catch((err) => { console.error(err); process.exit(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']]);
+ 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']]);
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');