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)
Changes
@@@ -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",@@@ -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",@@@ -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;}@@@ -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'] },@@@ -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.@@@ -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 }));}@@@ -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);}@@@ -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');@@@ -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 {
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -1320,9 +1320,9 @@
@@ -1320,9 +1320,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -24,7 +24,7 @@
@@ -24,7 +24,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -10,7 +10,8 @@
@@ -10,7 +10,8 @@
* network their pending confirmations (server/developer/confirmations.js): waiting, approve or decline here;
* 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;
* 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;
* 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
* 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.
* 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.
* 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');
@@ -22,7 +23,12 @@ const confirmations = require('../developer/confirmations');
const store = require('../developer/store');
const store = require('../developer/store');
const BUCKETS = ['running', 'waiting', 'scheduled', 'done', 'failed'];
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 ACTIONS_TIMEOUT_MS = 2500;
const CACHE_MS = 10_000;
const CACHE_MS = 10_000;
const CACHE_MAX = 500;
const CACHE_MAX = 500;
@@ -116,16 +122,18 @@ function createActions({ db, selfToken = () => null, urls = {}, fetchImpl = (...
@@ -116,16 +122,18 @@ function createActions({ db, selfToken = () => null, urls = {}, fetchImpl = (...
const out = await confirmationsImpl.decide(db, actor, id, action === 'approve' ? 'approve' : 'deny', {}, { ctx });
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() };
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 },
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),
body: JSON.stringify({ decision: action }), signal: AbortSignal.timeout(timeoutMs * 2),
});
});
const body = await res.json().catch(() => ({}));
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;
return body;
}
}
@@ -126,6 +126,8 @@ async function seedDb(db, { log = console } = {}) {
@@ -126,6 +126,8 @@ async function seedDb(db, { log = console } = {}) {
{ client_id: 'today', name: 'OpenVibe.Today', redirect_uris: ['https://openvibe.today/auth/callback'] },
{ 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.
// 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'] },
{ 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'] },
{ 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).
// 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'] },
{ client_id: 'quest', name: 'OpenVibe.Quest', redirect_uris: ['https://openvibe.quest/auth/callback'] },
@@ -143,7 +143,7 @@ const DEFAULT_GRANTS = [
@@ -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
// 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).
// 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',
...['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.export.contribute', SELF_AUDIENCE, []],
[svc, 'network.account.deletion.confirm', SELF_AUDIENCE, []],
[svc, 'network.account.deletion.confirm', SELF_AUDIENCE, []],
]),
]),
@@ -558,6 +558,10 @@ const DEFAULT_GRANTS = [
@@ -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
// 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.
// /v1/chat/completions (x_openvibe.provider, AI#74); each secret's project grant decides.
['ai', 'network.secret.use', SELF_AUDIENCE, []],
['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.
// Grants withdrawn by decision; applied at every boot so an old default can't come back.
@@ -629,6 +629,7 @@ require('./developer/confirmations').start(db);
@@ -629,6 +629,7 @@ require('./developer/confirmations').start(db);
actor: process.env.OV_ACTOR_INTERNAL_URL || 'http://127.0.0.1:4950',
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',
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',
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 }));
app.use('/api/v1/me/actions', rateLimit({ windowMs: 60_000, max: 120 }), require('./actions').router({ actions, requireAuth }));
}
}
@@ -93,11 +93,11 @@ const server = http.createServer(app);
@@ -93,11 +93,11 @@ const server = http.createServer(app);
// ── Export ──
// ── Export ──
const seeded = await accountData.expectedServices(db, 'network.account.export.contribute');
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 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
// 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).
// 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']) {
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);
await db.prepare('UPDATE principal_grants SET revoked_at = ov_now() WHERE client_id = ? AND capability = ? AND revoked_at IS NULL').run(svc, cap);
}
}
@@ -37,7 +37,7 @@ for (const [client, cap, aud] of principals.DEFAULT_GRANTS) {
@@ -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.
// 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(),
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.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');
assert.strictEqual((await events()).length, 0, 'the seed is not announced');
@@ -48,6 +48,9 @@ const svc = http.createServer((req, res) => {
@@ -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 === '/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_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 === '/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' });
json(404, { code: 'not_found' });
});
});
});
});
@@ -63,7 +66,7 @@ const fakeConfirmations = {
@@ -63,7 +66,7 @@ const fakeConfirmations = {
};
};
let clock = T0;
let clock = T0;
const actions = actionsMod.createActions({
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() {} },
selfToken: (aud, caps) => `tok:${aud}:${caps.join(',')}`, confirmationsImpl: fakeConfirmations, log: { warn() {} },
});
});
@@ -88,10 +91,10 @@ try {
@@ -88,10 +91,10 @@ try {
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
const doc = r.body;
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.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.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];
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']]);
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);
const all = Object.values(doc.buckets).flat().map((x) => x.id);
@@ -124,6 +127,12 @@ try {
@@ -124,6 +127,12 @@ try {
const d = calls.find((c) => c.path === '/actor/api/v1/approvals/apr_1/decide');
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.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);
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.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');
assert.strictEqual((await call('POST', '/actor/..%2Fx/approve')).status, 404, 'ids are plain');
} finally {
} finally {