openvibers/openvibe.network Commits

Network: project delegation's membership read (network.project.member.read); Run and Watch hold it, Watch may submit Run jobs (#170)

OP OpenVibers committed 12 Oct 2026 · 2ca00ed6fb · 7 files, +99 −6

Network's side of project delegation (ADR-003 amendment 2026-10-12, openvibe-contracts v0.153.0). A first-party service acting for a person (`X-OV-Subject`) may now name one project (`X-OV-Project`). The receiver must check, every time, that the person is an admin or owner of the unarchived project. This PR adds the read it checks with, and the grants for the first two users, Run and Watch.

**What changes**
- `GET /internal/projects/:project_id/members/:subject` (capability `network.project.member.read`, first-party, never grantable) answers `network.project-member-result@1`: `{ project_id, subject, role, archived, added_at }`, `Cache-Control: no-store`. It carries no other members, apps or secrets.
- A non-member, an unknown project and a malformed id are all the same `404 project.member_not_found`. The answer never tells a service that a project exists, because `store.internalMemberView` returns null for each of them.
- The decision (admin or owner, not archived) is the receiver's. The route only states the facts.
- `DEFAULT_GRANTS`:
  - `run` and `watch` get `network.project.member.read`: Run checks before every delegated job; Watch checks when a watch names a project.
  - `watch` gets `run.job.submit` for `openvibe.run`, for the `run-job` action.

  New default rows are seeded at boot. None of them was ever revoked, so `RESTORED_GRANTS` isn't needed.
- openvibe-contracts pinned to v0.153.0. The guard needs the new capability's manifest.
- `docs/developer-projects.md` gains a "Project delegation" section.

**Tests:** `test/internal-projects.test.js` covers:
- the auth matrix: no token 401; Host and Live 403; a person's session refused; Watch's token 200;
- the owner's and a developer's membership as their own roles;
- an archived project read as `archived: true`;
- a non-member and an unknown project giving byte-identical 404s;
- malformed ids giving 404;
- the result fitting the contract;
- the grant rows, and that the capability is never grantable.

`ov test`: 45 files the change reaches pass. The pipeline runs the rest because the lockfile changed.

Changes

ddiff --git a/docs/developer-projects.md b/docs/developer-projects.md
iindex c3d3c85d..787be484 100644
---- a/docs/developer-projects.md
++++ b/docs/developer-projects.md
@@@ -247,6 +247,22 @@ agent's name and the summary as plain text, never the details; a standing rule's
- **Errors:** an unknown or malformed id is `404 project.not_found`; no token is `401 token.missing`; a token without the capability is `403`.
- **Catalog.** `network.project.read` has been `active` in openvibe-contracts since v0.88.0 (first-party). The guard accepts `active` and `planned` and refuses only `retired`.
++### Project delegation: one person's membership
++
++A first-party service acting for a person (`X-OV-Subject`) may name one project with `X-OV-Project` (ADR-003 amendment
++2026-10-12, openvibe-contracts v0.153.0). The receiver checks the membership here before it does the project's work.
++
++- **Route.** `GET /internal/projects/:project_id/members/:subject` with a service token holding
++ `network.project.member.read` for `openvibe.network` (first-party, never granted to apps). Run and Watch hold it by
++ default: Run checks before every delegated job, and Watch checks when a watch names a project.
++- **Result** (`Cache-Control: no-store`, `network.project-member-result@1`): `{ project_id, subject, role, archived,
++ added_at }`. No other member, app or secret.
++- **The decision is the receiver's:** delegation accepts only an `admin` or `owner` of a project that is not archived,
++ and may cache a yes for at most 60 seconds.
++- **Errors:** a non-member, an unknown project and a malformed id are the same `404 project.member_not_found`, so a
++ membership check never tells a service that a project exists. No token is `401`; a token without the capability is
++ `403`.
++
Redirect URIs must be https. `http://localhost`, `127.0.0.1` and `[::1]` are allowed for sandbox apps only. Fragments and embedded credentials are refused. At most 10 per app. A public app needs at least one. A project has at most `DEV_MAX_APPS_PER_PROJECT` active apps.
### Service-side agent reads and confirmations
ddiff --git a/package-lock.json b/package-lock.json
iindex e6e1f6b5..d39c0948 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.152.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.153.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
"pg": "^8.23.0",
@@@ -1320,9 +1320,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.152.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.152.0",
-- "integrity": "sha512-LmoitOQUZ+8HiimR6mYm9Yx5SjAb4xPuRkLPAtOtlM7eGUevp8O2KYyRkuafpk3OXIhDScO3+lsFp3vE9LfeQA==",
++ "version": "0.153.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.153.0",
++ "integrity": "sha512-WQ7p7ATKtTky9nI9yE9Agkm3rNKuyWljmrMCdZaqxg+FjVaziTqrFz0a3YMBTXJmNf5JTGXn/ioOmWjO95/lCA==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex 857e1701..c8ec2be1 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.152.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.153.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
"pg": "^8.23.0",
ddiff --git a/server/developer/store.js b/server/developer/store.js
iindex 0fd66a39..fe90b9c5 100644
---- a/server/developer/store.js
++++ b/server/developer/store.js
@@@ -164,6 +164,21 @@ async function projectView(db, p, role, settings) {
* through dev_apps). No secrets, no client ids, no members other than the owner, no actor labels. Archived projects
* still return (archived_at set). -> null when the id is not a project.
*/
++/**
++ * One person's membership of a project, as a receiving service sees it (network.project.member.read, GET
++ * /internal/projects/:project_id/members/:subject): the role, when they were added, and whether the project is
++ * archived. Project delegation (ADR-003 amendment 2026-10-12) accepts only an admin or owner of an unarchived project;
++ * that decision is the receiver's. -> null for a non-member and for an unknown project alike, so the answer never
++ * tells a service that a project exists.
++ */
++async function internalMemberView(db, projectId, subject) {
++ if (!PROJECT_ID_RE.test(String(projectId)) || !/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(subject))) return null;
++ const r = await db.prepare(`SELECT m.role, m.added_at, p.archived_at FROM dev_project_members m JOIN dev_projects p ON p.id = m.project_id
++ WHERE m.project_id = ? AND m.subject_id = ?`).get(String(projectId), String(subject));
++ if (!r) return null;
++ return { project_id: String(projectId), subject: String(subject), role: r.role, archived: !!r.archived_at, added_at: r.added_at || null };
++}
++
async function internalProjectView(db, projectId) {
const p = PROJECT_ID_RE.test(String(projectId)) ? await db.prepare('SELECT * FROM dev_projects WHERE id = ?').get(String(projectId)) : null;
if (!p) return null;
@@@ -708,7 +723,7 @@ async function deleteQuota(db, actor, projectId, capability, { ctx }) {
module.exports = {
ensureSchema, DevError, ROLES, APP_ID_RE, PROJECT_ID_RE, SECRET_PREFIX,
audit, listAudit, actorOf, access, memberRole, lockProject,
-- createProject, listProjects, renameProject, archiveProject, setAllowance, setEnvironmentPolicy, setPlacement, projectView, internalProjectView,
++ createProject, listProjects, renameProject, archiveProject, setAllowance, setEnvironmentPolicy, setPlacement, projectView, internalProjectView, internalMemberView,
listMembers, addMember, updateMember, removeMember,
listApps, getApp, createApp, updateApp, revokeApp,
listCredentials, rotateCredential, revokeCredential, matchSecret, hashSecret,
ddiff --git a/server/identity/principals.js b/server/identity/principals.js
iindex b469ca2c..69becd14 100644
---- a/server/identity/principals.js
++++ b/server/identity/principals.js
@@@ -231,6 +231,12 @@ const DEFAULT_GRANTS = [
['watch', 'actor.task.create', 'openvibe.actor', []],
// A watch's actor-agent action runs the owner's personal agent (contracts 0.143.0, Actor's actor.agent.run).
['watch', 'actor.agent.run', 'openvibe.actor', []],
++ // Project delegation (ADR-003 amendment 2026-10-12, contracts 0.153.0): a watch that names a project has its
++ // run-job actions start Run jobs for the owner in that project (X-OV-Subject + X-OV-Project). Watch checks the
++ // owner's membership when the watch is saved; Run checks it again before every delegated job.
++ ['watch', 'run.job.submit', 'openvibe.run', []],
++ ['watch', 'network.project.member.read', SELF_AUDIENCE, []],
++ ['run', 'network.project.member.read', SELF_AUDIENCE, []],
// Work's agent pillar: "Ask my Actor agent" on a tracked application gives Actor a task for that person (X-OV-Subject).
['work', 'actor.task.create', 'openvibe.actor', []],
// Food's recipes pillar: food.suggest_recipes on OpenVibe.AI for the signed-in person (AI#57).
ddiff --git a/server/internal/routes.js b/server/internal/routes.js
iindex 46205f29..98c84a1a 100644
---- a/server/internal/routes.js
++++ b/server/internal/routes.js
@@@ -404,6 +404,21 @@ router.get('/projects/:project_id', principals.guard('network.project.read'), as
}
});
++// ── One person's membership of a project ─────────────────────
++// Project delegation (ADR-003 amendment 2026-10-12): before doing a project's work for a person a first-party service
++// names (X-OV-Subject + X-OV-Project), the receiver checks here that they are an admin or owner of the unarchived
++// project. First-party only; a non-member and an unknown project are the same 404.
++router.get('/projects/:project_id/members/:subject', principals.guard('network.project.member.read'), async (req, res) => {
++ try {
++ const out = await require('../developer/store').internalMemberView(getDb(req), req.params.project_id, req.params.subject);
++ if (!out) return require('openvibe-contracts').http.sendProblem(res, 404, 'project.member_not_found', { detail: 'no such member' });
++ res.set('Cache-Control', 'no-store').json(out);
++ } catch (err) {
++ console.error('[Internal] project member read error:', err.message);
++ require('openvibe-contracts').http.sendProblem(res, 500, 'internal.error', { detail: 'project member read failed' });
++ }
++});
++
// ── Read an agent ────────────────────────────────────────────
// Set before the guard so its refusals are not cached either.
const noStore = (req, res, next) => { res.set('Cache-Control', 'no-store'); next(); };
ddiff --git a/test/internal-projects.test.js b/test/internal-projects.test.js
iindex 1404c7cc..4ebcbd85 100644
---- a/test/internal-projects.test.js
++++ b/test/internal-projects.test.js
@@@ -20,6 +20,7 @@ const db = getDb();
console.log = log;
await db.prepare("UPDATE oauth_clients SET client_secret = 'host-secret' WHERE client_id = 'host'").run();
await db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();
++await db.prepare("UPDATE oauth_clients SET client_secret = 'watch-secret' WHERE client_id = 'watch'").run();
await db.prepare(`INSERT INTO users (id, username, password_hash, role) VALUES
(10, 'owner', 'x', 'user'), (11, 'dev', 'x', 'user'), (14, 'staff', 'x', 'admin')`).run();
const sid = async (id) => await subjects.ensureUserSubject(db, await db.prepare('SELECT * FROM users WHERE id = ?').get(id));
@@@ -141,6 +142,43 @@ const U = { owner: userToken(10), dev: userToken(11), staff: userToken(14) };
assert.match(x.type, /application\/problem\+json/);
}
++ // 6. One person's membership (project delegation, ADR-003 amendment 2026-10-12): network.project.member.read,
++ // Run and Watch only, never grantable; members only, and a non-member reads exactly like an unknown project.
++ assert.deepStrictEqual(principals.DEFAULT_GRANTS.filter(g => g[1] === 'network.project.member.read').map(g => [g[0], g[2]]).sort(),
++ [['run', principals.SELF_AUDIENCE], ['watch', principals.SELF_AUDIENCE]], 'Run and Watch hold network.project.member.read');
++ assert.strictEqual(policy.grantability('network.project.member.read').grantable, false, 'never grantable to apps');
++ assert.ok(principals.DEFAULT_GRANTS.some(g => g[0] === 'watch' && g[1] === 'run.job.submit' && g[2] === 'openvibe.run'), 'Watch may submit Run jobs');
++ const memberRead = async (project, subject, headers = {}) => {
++ const x = await fetch(`${base}/internal/projects/${encodeURIComponent(project)}/members/${encodeURIComponent(subject)}`, { headers });
++ const text = await x.text();
++ return { status: x.status, cache: x.headers.get('cache-control'), text, body: text ? JSON.parse(text) : null };
++ };
++ // Watch's own client (Run has no sign-in client to seed here; both hold the same grant).
++ const run = { authorization: `Bearer ${await token('watch', 'watch-secret')}` };
++ assert.strictEqual((await memberRead(P, owner)).status, 401, 'no token');
++ assert.strictEqual((await memberRead(P, owner, host)).status, 403, 'Host lacks network.project.member.read');
++ assert.strictEqual((await memberRead(P, owner, { authorization: `Bearer ${await token('live', 'live-secret')}` })).status, 403, 'Live lacks it');
++ assert.ok([401, 403].includes((await memberRead(P, owner, { authorization: `Bearer ${U.owner}` })).status), 'a person\'s session never reads it');
++ r = await memberRead(P, owner, run);
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.cache, 'no-store');
++ assert.deepStrictEqual({ ...r.body, added_at: null }, { project_id: P, subject: owner, role: 'owner', archived: false, added_at: null });
++ assert.ok(r.body.added_at, 'when they were added');
++ const contractCheck = require('openvibe-contracts').validate('network.project-member-result@1', r.body);
++ assert.ok(contractCheck.valid, `fits network.project-member-result@1: ${JSON.stringify(contractCheck.errors)}`);
++ r = await memberRead(P, dev, run);
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.body.role, 'developer', 'the member\'s own role, for the receiver to judge');
++ const staffSid = await sid(14);
++ const notMember = await memberRead(P, staffSid, run);
++ const unknownProject = await memberRead('prj_00000000000000000000000000', owner, run);
++ for (const x of [notMember, unknownProject]) { assert.strictEqual(x.status, 404); assert.strictEqual(x.body.code, 'project.member_not_found'); }
++ assert.strictEqual(notMember.text, unknownProject.text, 'a non-member and an unknown project answer the same');
++ for (const [pid, subj] of [[P, 'usr_nope'], [P, owner.toLowerCase()], ['nope', owner], [`${P}'--`, owner]]) {
++ const x = await memberRead(pid, subj, run);
++ assert.strictEqual(x.status, 404, `${pid} ${subj}`); assert.strictEqual(x.body.code, 'project.member_not_found');
++ }
++
// 5. An archived project still reads, with archived_at set and every app revoked.
r = await api('owner', 'POST', `/${P}/archive`);
assert.strictEqual(r.status, 200, r.text);
@@@ -150,6 +188,9 @@ const U = { owner: userToken(10), dev: userToken(11), staff: userToken(14) };
assert.ok(r.body.apps.every(a => a.status === 'revoked'), 'archiving revoked every app');
assert.ok(r.body.grants.length && r.body.grants.every(g => g.status === 'revoked'), 'and every grant of its apps reads revoked');
assert.deepStrictEqual(r.body.quotas.map(q => q.capability), ['media.object.upload'], 'quotas outlive the archive');
++ r = await memberRead(P, owner, run);
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.body.archived, true, 'membership of an archived project says so (the receiver refuses it)');
log('internal-projects: all assertions passed');
} finally {