openvibers/openvibe.network Commits

Domains in the Action Center (domains.actions.read, approve/decline via domains.proposal.decide); client domains; Domains may reveal a person's provider token; contracts v0.233.0 (#399)

OP OpenVibers committed 16 days ago · 0903329ab5 · 9 files, +45 −21

Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex df4a08bb..497b2620 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -20,7 +20,7 @@
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.232.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.233.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",
"pg": "^8.23.0",
@@@ -1320,9 +1320,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.232.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.232.0",
-- "integrity": "sha512-6HI5nn//nAUEDdG4xRTAlcb40oc/6yO1nx7R5exGZ6Bo3mL5fJ5szw9IONddoLN6qJsO1CXzB5TzTqkBAh3OzQ==",
++ "version": "0.233.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.233.0",
++ "integrity": "sha512-pkX0+tsMP6HF7mwbM6jDa97C8U+kbivs+iVdjylEP0sEaqZsPYvTuYHHvnPMZXNQ6QCLriGRSBNdji1F8i3m5A==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex 94c20979..097fb442 100644
---- a/package.json
++++ b/package.json
@@@ -24,7 +24,7 @@
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.232.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.233.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",
"pg": "^8.23.0",
ddiff --git a/server/actions/index.js b/server/actions/index.js
iindex ca796136..0986e72e 100644
---- a/server/actions/index.js
++++ b/server/actions/index.js
@@@ -10,7 +10,8 @@
* network their pending confirmations (server/developer/confirmations.js): waiting, approve or decline here;
* actor GET <actor>/api/v1/actions?subject= (actor.actions.read) agent tasks, approvals, scheduled agents;
* codes GET <codes>/api/v1/actions?subject= (codes.actions.read) coding tasks;
-- * ai GET <ai>/api/v1/actions?subject= (ai.actions.read) AI runs on their behalf.
++ * ai GET <ai>/api/v1/actions?subject= (ai.actions.read) AI runs on their behalf;
++ * domains GET <domains>/api/v1/actions?subject= (domains.actions.read) DNS change proposals, approved or declined here.
* Network signs its own short tokens for each (server/identity/self-token.js). Each source has ACTIONS_TIMEOUT_MS; a
* slow, failing or not-yet-deployed one is left out and named in `sources` (partial: true), never failing the answer.
* Every item is validated against common.action-item@1; one that is not valid is dropped, never shown half-formed.
@@@ -22,7 +23,12 @@ const confirmations = require('../developer/confirmations');
const store = require('../developer/store');
const BUCKETS = ['running', 'waiting', 'scheduled', 'done', 'failed'];
--const REMOTE = ['actor', 'codes', 'ai'];
++const REMOTE = ['actor', 'codes', 'ai', 'domains'];
++// Who can approve or decline from the Action Center, and how: the capability Network signs for, and the route.
++const DECIDE = {
++ actor: { cap: 'actor.approval.decide', path: (id) => `/api/v1/approvals/${encodeURIComponent(id)}/decide`, name: 'OpenVibe.Actor', what: 'approval' },
++ domains: { cap: 'domains.proposal.decide', path: (id) => `/api/v1/proposals/${encodeURIComponent(id)}/decide`, name: 'OpenVibe.Domains', what: 'proposal' },
++};
const ACTIONS_TIMEOUT_MS = 2500;
const CACHE_MS = 10_000;
const CACHE_MAX = 500;
@@@ -116,16 +122,18 @@ function createActions({ db, selfToken = () => null, urls = {}, fetchImpl = (...
const out = await confirmationsImpl.decide(db, actor, id, action === 'approve' ? 'approve' : 'deny', {}, { ctx });
return { ...confirmationItem(out.confirmation, null), bucket: 'done', actions: ['open'], updated_at: new Date(now()).toISOString() };
}
-- if (service !== 'actor') throw Object.assign(new Error('this item cannot do that here: open it instead'), { status: 422, code: 'actions.unsupported' });
-- const token = urls.actor ? selfToken('openvibe.actor', ['actor.approval.decide']) : null;
-- if (!token) throw Object.assign(new Error('OpenVibe.Actor is not connected'), { status: 503, code: 'actions.not_connected' });
-- const res = await fetchImpl(`${String(urls.actor).replace(/\/+$/, '')}/api/v1/approvals/${encodeURIComponent(id)}/decide`, {
++ const d = DECIDE[service];
++ if (!d) throw Object.assign(new Error('this item cannot do that here: open it instead'), { status: 422, code: 'actions.unsupported' });
++ const token = urls[service] ? selfToken(`openvibe.${service}`, [d.cap]) : null;
++ if (!token) throw Object.assign(new Error(`${d.name} is not connected`), { status: 503, code: 'actions.not_connected' });
++ const res = await fetchImpl(`${String(urls[service]).replace(/\/+$/, '')}${d.path(id)}`, {
method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json', authorization: `Bearer ${token}`, 'x-ov-subject': actor.subject },
body: JSON.stringify({ decision: action }), signal: AbortSignal.timeout(timeoutMs * 2),
});
const body = await res.json().catch(() => ({}));
-- if (res.status === 404) throw Object.assign(new Error('no such approval'), { status: 404, code: 'actions.not_found' });
-- if (!res.ok) throw Object.assign(new Error((body && (body.detail || body.title)) || `OpenVibe.Actor answered ${res.status}`), { status: 502, code: 'actions.upstream' });
++ if (res.status === 404) throw Object.assign(new Error(`no such ${d.what}`), { status: 404, code: 'actions.not_found' });
++ if (res.status === 409) throw Object.assign(new Error((body && body.detail) || `this ${d.what} was decided already`), { status: 409, code: 'actions.decided' });
++ if (!res.ok) throw Object.assign(new Error((body && (body.detail || body.title)) || `${d.name} answered ${res.status}`), { status: 502, code: 'actions.upstream' });
return body;
}
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 71b2208c..9818cdc1 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -126,6 +126,8 @@ async function seedDb(db, { log = console } = {}) {
{ client_id: 'today', name: 'OpenVibe.Today', redirect_uris: ['https://openvibe.today/auth/callback'] },
// Ride maps, surf the web, surf what's on (plan 2026-10-28 §5.6); a stateless front that signs people in for Web.
{ client_id: 'surf', name: 'OpenVibe.Surf', redirect_uris: ['https://openvibe.surf/auth/callback'] },
++ // The Domains control panel (plan 2026-10-28 §5.1, DOM-2): a person's connections and proposals.
++ { client_id: 'domains', name: 'OpenVibe.Domains', redirect_uris: ['https://openvibe.domains/auth/callback'] },
{ client_id: 'work', name: 'OpenVibe.Work', redirect_uris: ['https://openvibe.work/auth/callback'] },
// The shared quest log, listings people post, and Watch's public site (plan T18 step 8, T19).
{ client_id: 'quest', name: 'OpenVibe.Quest', redirect_uris: ['https://openvibe.quest/auth/callback'] },
ddiff --git a/server/identity/principals.js b/server/identity/principals.js
iindex ca3d584c..e563af3c 100644
---- a/server/identity/principals.js
++++ b/server/identity/principals.js
@@@ -143,7 +143,7 @@ const DEFAULT_GRANTS = [
// account-data consumer and subscriptions shipped first in Reviews#77). Host since 2026-10-19 (the console's sealed hosting
// tokens and actions, benchmarks' submissions; Host#120, subscribed first through Network#284).
...['live', 'chat', 'community', 'media', 'games', 'actor', 'food', 'help', 'work', 'quest', 'rent', 'media-hub',
-- 'deals', 'trade', 'tips', 'vip', 'watch', 'services', 'wiki', 'blog', 'bot', 'inventory', 'codes', 'play', 'space', 'billing', 'reviews', 'host'].flatMap((svc) => [
++ 'deals', 'trade', 'tips', 'vip', 'watch', 'services', 'wiki', 'blog', 'bot', 'inventory', 'codes', 'play', 'space', 'billing', 'reviews', 'host', 'domains'].flatMap((svc) => [
[svc, 'network.account.export.contribute', SELF_AUDIENCE, []],
[svc, 'network.account.deletion.confirm', SELF_AUDIENCE, []],
]),
@@@ -558,6 +558,10 @@ const DEFAULT_GRANTS = [
// AI reveals a project's own provider key to that provider's origin for one run of an app that names it on
// /v1/chat/completions (x_openvibe.provider, AI#74); each secret's project grant decides.
['ai', 'network.secret.use', SELF_AUDIENCE, []],
++ // Domains reveals a person's provider token (their own project's secret, granted to domains for that provider's
++ // origin) for one call to the provider's API (plan 2026-10-28 §5.1, DOM-2).
++ ['domains', 'network.secret.use', SELF_AUDIENCE, []],
++ ['domains', 'events.subscription.manage', 'openvibe.events', []],
];
// Grants withdrawn by decision; applied at every boot so an old default can't come back.
ddiff --git a/server/index.js b/server/index.js
iindex 20fbbb24..ed7e4e7f 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -629,6 +629,7 @@ require('./developer/confirmations').start(db);
actor: process.env.OV_ACTOR_INTERNAL_URL || 'http://127.0.0.1:4950',
codes: process.env.OV_CODES_INTERNAL_URL || 'http://127.0.0.1:4900',
ai: process.env.OV_AI_INTERNAL_URL || 'http://127.0.0.1:4700',
++ domains: process.env.OV_DOMAINS_INTERNAL_URL || 'http://127.0.0.1:5070',
} });
app.use('/api/v1/me/actions', rateLimit({ windowMs: 60_000, max: 120 }), require('./actions').router({ actions, requireAuth }));
}
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex c71bbbb6..8c2d4a24 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -93,11 +93,11 @@ const server = http.createServer(app);
// ── Export ──
const seeded = await accountData.expectedServices(db, 'network.account.export.contribute');
-- assert.deepStrictEqual(seeded, ['actor', 'billing', 'blog', 'bot', 'chat', 'codes', 'community', 'deals', 'food', 'games', 'help', 'host', 'inventory', 'live', 'media', 'media-hub', 'play', 'quest', 'rent', 'reviews', 'services', 'space', 'tips', 'trade', 'vip', 'watch', 'wiki', 'work'],
++ assert.deepStrictEqual(seeded, ['actor', 'billing', 'blog', 'bot', 'chat', 'codes', 'community', 'deals', 'domains', 'food', 'games', 'help', 'host', 'inventory', 'live', 'media', 'media-hub', 'play', 'quest', 'rent', 'reviews', 'services', 'space', 'tips', 'trade', 'vip', 'watch', 'wiki', 'work'],
'the holders of the contribute grant: the skeleton services answer too since 2026-10-08 and the older ones since 2026-10-09 (openvibe-sdk/account-data); Codes and Play since 2026-10-14 (run tasks; scores and unlocks); Space again since 2026-10-16 (its spaces); Billing since 2026-10-17 (money records); Reviews since 2026-10-17 (members\' reviews); Host since 2026-10-19 (the console and benchmarks)');
// The scenarios below follow five services; the newer ones are set aside here, exactly as an operator would
// revoke a service's grant (the same rows, the same revoked_at).
-- for (const svc of ['actor', 'billing', 'blog', 'bot', 'codes', 'deals', 'food', 'help', 'host', 'inventory', 'media-hub', 'play', 'quest', 'rent', 'reviews', 'services', 'space', 'tips', 'trade', 'vip', 'watch', 'wiki', 'work']) {
++ for (const svc of ['actor', 'billing', 'blog', 'bot', 'codes', 'deals', 'domains', 'food', 'help', 'host', 'inventory', 'media-hub', 'play', 'quest', 'rent', 'reviews', 'services', 'space', 'tips', 'trade', 'vip', 'watch', 'wiki', 'work']) {
for (const cap of ['network.account.export.contribute', 'network.account.deletion.confirm']) {
await db.prepare('UPDATE principal_grants SET revoked_at = ov_now() WHERE client_id = ? AND capability = ? AND revoked_at IS NULL').run(svc, cap);
}
ddiff --git a/test/grants-admin.test.js b/test/grants-admin.test.js
iindex cfd757ee..05b8fcd3 100644
---- a/test/grants-admin.test.js
++++ b/test/grants-admin.test.js
@@@ -37,7 +37,7 @@ for (const [client, cap, aud] of principals.DEFAULT_GRANTS) {
}
// Trade's scripts/subscribe.js (Trade #9) manages its Events subscriptions like the other publishers.
assert.deepStrictEqual(principals.DEFAULT_GRANTS.filter(([, cap]) => cap === 'network.secret.use').map(([client, , aud]) => [client, aud]).sort(),
-- [['actor', principals.SELF_AUDIENCE], ['ai', principals.SELF_AUDIENCE], ['watch', principals.SELF_AUDIENCE]], 'project secrets: Actor signs, Watch and AI (an app\'s own provider, AI#74) use them');
++ [['actor', principals.SELF_AUDIENCE], ['ai', principals.SELF_AUDIENCE], ['domains', principals.SELF_AUDIENCE], ['watch', principals.SELF_AUDIENCE]], 'project secrets: Actor signs, Watch and AI (an app\'s own provider, AI#74) use them, Domains reveals a person\'s provider token');
assert.ok(principals.DEFAULT_GRANTS.some(([client, cap, aud, scopes]) => client === 'trade' && cap === 'events.subscription.manage' && aud === 'openvibe.events' && Array.isArray(scopes) && !scopes.length), 'Trade holds events.subscription.manage on openvibe.events');
assert.strictEqual((await events()).length, 0, 'the seed is not announced');
ddiff --git a/test/me-actions.test.js b/test/me-actions.test.js
iindex 0cc6bbb1..78dc2512 100644
---- a/test/me-actions.test.js
++++ b/test/me-actions.test.js
@@@ -48,6 +48,9 @@ const svc = http.createServer((req, res) => {
if (u.pathname === '/ai/api/v1/actions') return setTimeout(() => json(200, { items: [item({ service: 'ai', id: 'run_1', kind: 'ai_run', bucket: 'done', state: 'succeeded', href: 'https://ai.openvibe.services/runs/run_1', actions: ['open'] })] }), aiDelay);
if (u.pathname === '/actor/api/v1/approvals/apr_1/decide') return json(200, item({ id: 'apr_1', kind: 'approval', bucket: 'done', state: 'approved', actions: ['open'] }));
if (u.pathname === '/actor/api/v1/approvals/apr_404/decide') return json(404, { code: 'not_found' });
++ if (u.pathname === '/domains/api/v1/actions') return json(200, { items: [item({ service: 'domains', id: 'dpr_1', kind: 'approval', title: 'Add a TXT record to example.com', bucket: 'waiting', state: 'pending', actions: ['approve', 'decline', 'open'], updated_at: iso(20), href: 'https://openvibe.domains/panel/p/dpr_1' })] });
++ if (u.pathname === '/domains/api/v1/proposals/dpr_1/decide') return json(200, item({ service: 'domains', id: 'dpr_1', kind: 'approval', bucket: 'done', state: 'applied', actions: ['open'], href: 'https://openvibe.domains/panel/p/dpr_1' }));
++ if (u.pathname === '/domains/api/v1/proposals/dpr_2/decide') return json(409, { code: 'proposal.decided', detail: 'This proposal is applied already.' });
json(404, { code: 'not_found' });
});
});
@@@ -63,7 +66,7 @@ const fakeConfirmations = {
};
let clock = T0;
const actions = actionsMod.createActions({
-- db, urls: { actor: `${base}/actor`, codes: `${base}/codes`, ai: `${base}/ai` }, timeoutMs: 150, now: () => clock,
++ db, urls: { actor: `${base}/actor`, codes: `${base}/codes`, ai: `${base}/ai`, domains: `${base}/domains` }, timeoutMs: 150, now: () => clock,
selfToken: (aud, caps) => `tok:${aud}:${caps.join(',')}`, confirmationsImpl: fakeConfirmations, log: { warn() {} },
});
@@@ -88,10 +91,10 @@ try {
assert.strictEqual(r.status, 200);
const doc = r.body;
assert.ok(validate('network.me-actions-result@1', doc).valid, JSON.stringify(validate('network.me-actions-result@1', doc).errors));
-- assert.deepStrictEqual(doc.sources, { network: 'ok', actor: 'ok', codes: 'not_connected', ai: 'timeout' });
++ assert.deepStrictEqual(doc.sources, { network: 'ok', actor: 'ok', codes: 'not_connected', ai: 'timeout', domains: 'ok' });
assert.strictEqual(doc.partial, true, 'a timeout makes the answer partial');
-- assert.deepStrictEqual(doc.counts, { running: 1, waiting: 2, scheduled: 1, done: 0, failed: 1 });
-- assert.deepStrictEqual(doc.buckets.waiting.map((x) => x.id), ['cnf_01JAB2C3D4E5F6G7H8J9K0MNPQ', 'apr_1'], 'newest first');
++ assert.deepStrictEqual(doc.counts, { running: 1, waiting: 3, scheduled: 1, done: 0, failed: 1 });
++ assert.deepStrictEqual(doc.buckets.waiting.map((x) => x.id), ['cnf_01JAB2C3D4E5F6G7H8J9K0MNPQ', 'apr_1', 'dpr_1'], 'newest first');
const cnf = doc.buckets.waiting[0];
assert.deepStrictEqual([cnf.service, cnf.kind, cnf.title, cnf.summary, cnf.href, cnf.actions], ['network', 'approval', 'Send one email to the venue', 'Trip planner asks to use mail.send', 'https://openvibe.network/my#approvals', ['approve', 'decline', 'open']]);
const all = Object.values(doc.buckets).flat().map((x) => x.id);
@@@ -124,6 +127,12 @@ try {
const d = calls.find((c) => c.path === '/actor/api/v1/approvals/apr_1/decide');
assert.deepStrictEqual([d.method, d.auth, d.body.decision, d.subject.startsWith('usr_')], ['POST', 'Bearer tok:openvibe.actor:actor.approval.decide', 'decline', true]);
assert.strictEqual((await call('POST', '/actor/apr_404/approve')).status, 404);
++ // A Domains proposal is approved through Domains' decide route, with domains.proposal.decide, for this person.
++ r = await call('POST', '/domains/dpr_1/approve');
++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
++ const dd = calls.find((c) => c.path === '/domains/api/v1/proposals/dpr_1/decide');
++ assert.deepStrictEqual([dd.method, dd.auth, dd.body.decision, dd.subject.startsWith('usr_')], ['POST', 'Bearer tok:openvibe.domains:domains.proposal.decide', 'approve', true]);
++ assert.strictEqual((await call('POST', '/domains/dpr_2/approve')).status, 409, 'decided already, in words');
assert.deepStrictEqual([(await call('POST', '/codes/tsk_1/approve')).body.code, (await call('POST', '/actor/tsk_1/cancel')).body.code], ['actions.unsupported', 'actions.unsupported']);
assert.strictEqual((await call('POST', '/actor/..%2Fx/approve')).status, 404, 'ids are plain');
} finally {