openvibers/openvibe.network Commits

Community AI offers: a private report for Bot and a registry read for AI (#581)

OP OpenVibers committed 7 days ago · 22ea9d09e4 · 8 files, +321 −2

* Community AI offers: a private report for Bot and a registry read for AI

* Community AI offers: renumber the migration after main's 0039

* docs: the AI offers migration is 0040

* Tests: the migration list now includes locker_keys and ai_offers

* Merge remote-tracking branch 'origin/main' into feat/community-ai-offers

# Conflicts:
#	test/agent-schema.test.js
#	test/user-owned-trust.test.js

* AI offers: resolve merged subjects for self-dealing

Changes

ddiff --git a/docs/ai-offers.md b/docs/ai-offers.md
nnew file mode 100644
iindex 00000000..441130e1
---- /dev/null
++++ b/docs/ai-offers.md
@@@ -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.
ddiff --git a/migrations/0040_ai_offers.sql b/migrations/0040_ai_offers.sql
nnew file mode 100644
iindex 00000000..1b18a0f5
---- /dev/null
++++ b/migrations/0040_ai_offers.sql
@@@ -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);
ddiff --git a/server/identity/principals.js b/server/identity/principals.js
iindex 9101871b..5c4ac7f6 100644
---- a/server/identity/principals.js
++++ b/server/identity/principals.js
@@@ -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
ddiff --git a/server/index.js b/server/index.js
iindex 8ab44269..7e0f423b 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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.
{
ddiff --git a/server/registry/ai-offers.js b/server/registry/ai-offers.js
nnew file mode 100644
iindex 00000000..66f5fdd9
---- /dev/null
++++ b/server/registry/ai-offers.js
@@@ -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 };
ddiff --git a/test/agent-schema.test.js b/test/agent-schema.test.js
iindex 48db5c22..f36f093c 100644
---- a/test/agent-schema.test.js
++++ b/test/agent-schema.test.js
@@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// The real directory: 0016 applies on top of 0015, once.
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand']]);
++ 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.
ddiff --git a/test/ai-offers.test.js b/test/ai-offers.test.js
nnew file mode 100644
iindex 00000000..89168292
---- /dev/null
++++ b/test/ai-offers.test.js
@@@ -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); });
ddiff --git a/test/user-owned-trust.test.js b/test/user-owned-trust.test.js
iindex 51838083..66795788 100644
---- a/test/user-owned-trust.test.js
++++ b/test/user-owned-trust.test.js
@@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// ── 0020 applies on top of 0019, once, and rewrites nothing.
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand']]);
++ 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 twice
assert.deepStrictEqual(await rows(), seeded, 'no existing row changes');