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)
Changes
@@@ -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@@@ -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",@@@ -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",@@@ -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,@@@ -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).@@@ -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(); };@@@ -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 {
@@ -247,6 +247,22 @@ agent's name and the summary as plain text, never the details; a standing rule's
@@ -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`.
- **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`.
- **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.
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
### Service-side agent reads and confirmations
@@ -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.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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
"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.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",
"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.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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -164,6 +164,21 @@ async function projectView(db, p, role, settings) {
@@ -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
* 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.
* 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) {
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;
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;
if (!p) return null;
@@ -708,7 +723,7 @@ async function deleteQuota(db, actor, projectId, capability, { ctx }) {
@@ -708,7 +723,7 @@ async function deleteQuota(db, actor, projectId, capability, { ctx }) {
module.exports = {
module.exports = {
ensureSchema, DevError, ROLES, APP_ID_RE, PROJECT_ID_RE, SECRET_PREFIX,
ensureSchema, DevError, ROLES, APP_ID_RE, PROJECT_ID_RE, SECRET_PREFIX,
audit, listAudit, actorOf, access, memberRole, lockProject,
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,
listMembers, addMember, updateMember, removeMember,
listApps, getApp, createApp, updateApp, revokeApp,
listApps, getApp, createApp, updateApp, revokeApp,
listCredentials, rotateCredential, revokeCredential, matchSecret, hashSecret,
listCredentials, rotateCredential, revokeCredential, matchSecret, hashSecret,
@@ -231,6 +231,12 @@ const DEFAULT_GRANTS = [
@@ -231,6 +231,12 @@ const DEFAULT_GRANTS = [
['watch', 'actor.task.create', 'openvibe.actor', []],
['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).
// 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', []],
['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'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', []],
['work', 'actor.task.create', 'openvibe.actor', []],
// Food's recipes pillar: food.suggest_recipes on OpenVibe.AI for the signed-in person (AI#57).
// Food's recipes pillar: food.suggest_recipes on OpenVibe.AI for the signed-in person (AI#57).
@@ -404,6 +404,21 @@ router.get('/projects/:project_id', principals.guard('network.project.read'), as
@@ -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 ────────────────────────────────────────────
// ── Read an agent ────────────────────────────────────────────
// Set before the guard so its refusals are not cached either.
// Set before the guard so its refusals are not cached either.
const noStore = (req, res, next) => { res.set('Cache-Control', 'no-store'); next(); };
const noStore = (req, res, next) => { res.set('Cache-Control', 'no-store'); next(); };
@@ -20,6 +20,7 @@ const db = getDb();
@@ -20,6 +20,7 @@ const db = getDb();
console.log = log;
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 = '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 = '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
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();
(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));
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) };
@@ -141,6 +142,43 @@ const U = { owner: userToken(10), dev: userToken(11), staff: userToken(14) };
assert.match(x.type, /application\/problem\+json/);
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.
// 5. An archived project still reads, with archived_at set and every app revoked.
r = await api('owner', 'POST', `/${P}/archive`);
r = await api('owner', 'POST', `/${P}/archive`);
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.status, 200, r.text);
@@ -150,6 +188,9 @@ const U = { owner: userToken(10), dev: userToken(11), staff: userToken(14) };
@@ -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.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.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');
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');
log('internal-projects: all assertions passed');
} finally {
} finally {