openvibers/openvibe.run Commits

Run: project delegation: a first-party service starts a job for a person in a project they run (X-OV-Subject + X-OV-Project, checked with Network) (#39)

OP OpenVibers committed 12 Oct 2026 · fb1fb16310 · 10 files, +340 −37

Run's side of project delegation (ADR-003 amendment 2026-10-12, openvibe-contracts v0.153.0). Run bills every job to a project and takes the project only from a token, so a first-party service acting for a person couldn't start a job for them. Watch's `run-job` action needs that. A first-party service may now name a project; Run checks the person's membership with Network every time; the record names project, person and service.

**What changes**
- **`server/api/auth.js`** handles a verified service token carrying `X-OV-Project`. It is accepted only when:
  - the caller is `svc:`, carries no `project_id` of its own, and names a person (`X-OV-Subject: usr_…`) and a well-formed project;
  - Network says that person is an **admin or owner** of the **unarchived** project.

  Anything else is `403 project.not_delegated`, with the same answer whichever check failed. Network unreachable is `503 project.check_unavailable`: Run never guesses. An accepted principal carries `project_id` (the named project) and `delegated: { subject, via: 'service:<id>' }`. The requester is the person.
- **`server/network.js`** adds `createProjectMembership`. Run's own client credentials (`network.project.member.read`, audience `openvibe.network`) read `GET /internal/projects/:project/members/:subject`. The decision is made here: admin or owner, not archived, and the answer must name the same project and subject. A yes is cached 60 s (the amendment's ceiling); a no is never cached.
- **What a delegated caller may do:**
  - **submit**, recorded with `via` (new nullable column, `migrations/0002_job_via.sql`, expand);
  - **read** only the jobs whose project, requester and via are its own. Any other job is the same 404.
  - **list, cancel and stream** (ticket and Bearer) are `403 project.not_delegated`. Those stay with project tokens.
  - Delegation adds no capability: the service still needs `run.job.submit` / `run.job.read` in its token.
- **Idempotency:** a delegated repeat answers its own first job. A key another requester (or service) already spent in the project is `409 run.idempotency.conflict` without naming the job.
- README and the `db.js` schema note updated.

**Tests:** the new `test/delegation.test.js` runs against the stub Network's new members route, through the real client-credentials path. It covers:
- admin and owner members accepted, with requester, via and outbox event checked;
- the delegated read of its own job, and the same 404 for another person, another service or a missing job;
- the project token still reading every job;
- list, cancel and stream refused, and the refused cancel changing nothing;
- developer, viewer, non-member, archived and unknown projects refused, with no rows made;
- app tokens, a service without a subject, a token with its own `project_id`, malformed headers and user tokens refused;
- a missing grant still answering `capability.denied`;
- the 60 s cache, then refusal once the member is removed;
- Network down giving 503;
- idempotent repeats and 409 conflicts.

`ov test`: all 5 Run test files pass.

Changes

ddiff --git a/README.md b/README.md
iindex 5775494..4501198 100644
---- a/README.md
++++ b/README.md
@@@ -47,6 +47,12 @@ placed from the Fabric's offers and actually runs — the poller in `server/jobs
(`run_jobs`, `migrations/0001_jobs.sql`): state, placement, timings, exit, result and the metered wall clock.
- The project scope: a job's `project_id` and `requester` come from the caller's Network token
(`claims.project_id`, `claims.sub`) and never from a request body; another project's job is `404`, never `403`.
++- Project delegation (ADR-003 amendment 2026-10-12): a first-party service acting for a person (`X-OV-Subject`) may
++ name a project with `X-OV-Project`. Run asks Network whether the person is an admin or owner of the unarchived
++ project (`network.project.member.read`, a yes cached 60 s), then the person is the requester and the service is
++ stored as `via` (`migrations/0002_job_via.sql`). The service submits and reads only the jobs it started for that
++ person; list, cancel and stream are `403 project.not_delegated`, and so is any other caller naming a project. Network
++ unreachable is `503 project.check_unavailable`. Watch's `run-job` action is the first user.
- The `job_<ULID>` id (minted here, passed unchanged to Bot as `platform.job@1` `id`, so every metering reading of
the job is `run:<id>:<n>`).
- Idempotent submit: one row per `(project_id, idempotency_key)`, a repeat answering the first job, a different body
@@@ -80,7 +86,8 @@ placed from the Fabric's offers and actually runs — the poller in `server/jobs
- PostgreSQL 18: `run_jobs` and the `run_events_outbox`; `migrations/` applies on boot.
- Valkey (`VALKEY_URL`, optional): shared state between processes; unset, nothing is shared.
- OpenVibe.Network: the RS256 signing key (JWKS) every token is verified against; the offers Run places from
-- (`GET /api/v1/offers`, public); Run's client `run` mints the events relay's service token and its Bot token.
++ (`GET /api/v1/offers`, public); Run's client `run` mints the events relay's service token, its Bot token and the
++ token for project memberships (`GET /internal/projects/:project/members/:subject`, project delegation).
- OpenVibe.Events: `run.job.*` events go to `events.event.publish`; unset, they wait in `run_events_outbox`.
- OpenVibe.Bot (`RUN_BOT_URL`, `RUN_POLL_MS`): the node link and the meter. Run sends each job to it
(`bot.job.dispatch`, audience `openvibe.bot`, minted from `OV_OAUTH_CLIENT_ID`/`OV_OAUTH_CLIENT_SECRET` against
ddiff --git a/migrations/0002_job_via.sql b/migrations/0002_job_via.sql
nnew file mode 100644
iindex 0000000..8bb7399
---- /dev/null
++++ b/migrations/0002_job_via.sql
@@@ -0,0 +1,5 @@
++-- phase: expand
++-- Project delegation (ADR-003 amendment 2026-10-12): the first-party service that started a job for a person in a
++-- project, as a subject-ref string ('service:watch'); the person is the requester. NULL when the project's own
++-- token started the job. A delegated caller reads only the jobs whose requester and via are its own.
++ALTER TABLE run_jobs ADD COLUMN IF NOT EXISTS via text;
ddiff --git a/server/api/auth.js b/server/api/auth.js
iindex bb541c5..6819cde 100644
---- a/server/api/auth.js
++++ b/server/api/auth.js
@@@ -13,11 +13,20 @@
*
* The project a job belongs to comes from the token's own project_id claim (a developer app or agent
* token names one), never from the request body or a query: the requester and the billed project are
-- * the token's, always.
++ * the token's, always — with one exception, project delegation (ADR-003 amendment 2026-10-12): a
++ * first-party service (svc:) acting for a person (X-OV-Subject: usr_…) may name a project with
++ * X-OV-Project once Network confirms the person is an admin or owner of it. Such a principal is
++ *
++ * { kind: 'service', sub: 'svc:watch', ..., project_id: <X-OV-Project>, delegated: { subject: 'usr_…', via: 'service:watch' } }
++ *
++ * The person is the requester and the service is recorded as via; it may submit a job and read the jobs it
++ * submitted for that person, and nothing else (403 project.not_delegated on list, cancel and stream). An
++ * app, mod or agent token, or a service without X-OV-Subject, naming a project is 403 project.not_delegated.
*/
const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');
const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;
++const USER_SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
const ANON = Object.freeze({ kind: 'anonymous' });
@@@ -51,8 +60,9 @@ function verifyService(token, { publicKey, issuer, audience }) {
return { ok: true, claims: r.claims };
}
--/** The project a principal acts for: its token's project_id claim, or null (no project named). */
++/** The project a principal acts for: its token's project_id claim (or its delegated project), or null (no project named). */
function projectOf(principal) {
++ if (principal && principal.delegated) return PROJECT_RE.test(String(principal.project_id)) ? principal.project_id : null;
const id = principal && principal.claims && principal.claims.project_id;
return typeof id === 'string' && PROJECT_RE.test(id) ? id : null;
}
@@@ -65,13 +75,44 @@ function projectOf(principal) {
function requesterOf(principal) {
if (!principal) return null;
if (principal.kind === 'user') return { type: 'user', id: principal.subject };
++ if (principal.delegated) return { type: 'user', id: principal.delegated.subject }; // the person; the service is via
if (principal.kind !== 'service') return null;
const sub = String((principal.claims && principal.claims.sub) || principal.sub || '');
if (sub.startsWith('svc:')) return { type: 'service', id: sub.slice(4) };
return ids.parseSubject(sub);
}
--function createApiAuth({ config, keys, userAuth }) {
++/** The service a delegated principal acts through, as a subject-ref string ('service:watch'), or null. */
++const viaOf = (principal) => (principal && principal.delegated ? principal.delegated.via : null);
++
++function createApiAuth({ config, keys, userAuth, membership = null, log = console }) {
++ const notDelegated = (detail) => ({ error: [403, 'project.not_delegated', detail] });
++
++ /**
++ * X-OV-Project on a verified token: only a first-party service naming the person may use it, and only for a
++ * project Network says the person is an admin or owner of (not archived). The answer never says which check
++ * failed, so it never tells a caller that a project exists.
++ */
++ async function delegate(req, claims) {
++ const project = String(req.get('X-OV-Project') || '').trim();
++ const subject = String(req.get('X-OV-Subject') || '').trim();
++ const sub = String(claims.sub || '');
++ const refuse = notDelegated('a project is named only by a first-party service acting for a person who is an admin or owner of it (ADR-003 amendment 2026-10-12)');
++ if (!sub.startsWith('svc:') || claims.project_id || !USER_SUBJECT.test(subject) || !PROJECT_RE.test(project)) return refuse;
++ if (!membership) return { error: [503, 'project.check_unavailable', 'Run cannot check project memberships here'] };
++ let r;
++ try { r = await membership.allows(project, subject); }
++ catch (e) {
++ log.warn(`[Run] project membership check failed: ${e.message}`);
++ return { error: [503, 'project.check_unavailable', 'Network could not confirm the project membership just now; try again'] };
++ }
++ if (!r.ok) return refuse;
++ return { principal: {
++ kind: 'service', sub, cap: claims.cap || [], claims, project_id: project, jti: claims.jti,
++ delegated: { subject, via: `service:${sub.slice(4)}` },
++ } };
++ }
++
async function resolve(req) {
const header = String(req.headers.authorization || '');
if (!header.startsWith('Bearer ')) return { principal: ANON };
@@@ -82,10 +123,12 @@ function createApiAuth({ config, keys, userAuth }) {
if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {
const r = verifyService(token, { publicKey, issuer: config.network.issuer, audience: config.audience });
if (!r.ok) return { error: [401, r.code, r.reason] };
++ if (req.get('X-OV-Project') != null) return delegate(req, r.claims);
return { principal: { kind: 'service', sub: r.claims.sub, cap: r.claims.cap || [], claims: r.claims, project_id: projectOf({ claims: r.claims }), jti: r.claims.jti } };
}
const claims = await userAuth.verify(token);
if (!claims) return { error: [401, 'token.invalid', 'the user token is invalid or expired'] };
++ if (req.get('X-OV-Project') != null) return notDelegated('a person\'s own token never names a project here');
const p = userPrincipal(claims);
if (!p) return { error: [403, 'identity.no_subject', 'this account has no canonical subject yet; sign in again'] };
return { principal: p };
@@@ -130,7 +173,13 @@ function createApiAuth({ config, keys, userAuth }) {
* when the token names no project (a first-party service token without the project_id claim, or a user
* token): jobs belong to a project, and the billed project is never taken from a request body.
*/
-- function requireProject(req) {
++ function requireProject(req, { delegated = false } = {}) {
++ if (req.principal && req.principal.delegated && !delegated) {
++ const err = new Error('a delegated call submits jobs and reads the ones it submitted; listing, cancelling and streaming need a token that names the project');
++ err.status = 403;
++ err.code = 'project.not_delegated';
++ throw err;
++ }
const project = projectOf(req.principal);
const requester = requesterOf(req.principal);
if (!project || !requester) {
@@@ -139,10 +188,10 @@ function createApiAuth({ config, keys, userAuth }) {
err.code = 'run.project_required';
throw err;
}
-- return { project, requester: ids.formatSubject(requester) };
++ return { project, requester: ids.formatSubject(requester), via: viaOf(req.principal) };
}
-- return { middleware, resolve, granted, requireCapability, requireProject, projectOf, requesterOf };
++ return { middleware, resolve, granted, requireCapability, requireProject, projectOf, requesterOf, viaOf };
}
--module.exports = { createApiAuth, userPrincipal, verifyService, decodePayload, projectOf, requesterOf, CAPABILITIES, PRINCIPAL_SUB, PROJECT_RE };
++module.exports = { createApiAuth, userPrincipal, verifyService, decodePayload, projectOf, requesterOf, viaOf, CAPABILITIES, PRINCIPAL_SUB, PROJECT_RE };
ddiff --git a/server/api/v1.js b/server/api/v1.js
iindex ba894f2..cf901e0 100644
---- a/server/api/v1.js
++++ b/server/api/v1.js
@@@ -16,7 +16,11 @@
* | GET /admin/jobs | run.job.admin | run.job-list-query@1 (with project_id) → run.job-list-result@1 |
*
* The project and the requester always come from the token (claims.project_id, claims.sub), NEVER from the
-- * body: a body field is never read for either. run.job.admin additionally reads and cancels any project's
++ * body: a body field is never read for either. The one exception is project delegation (ADR-003 amendment
++ * 2026-10-12, server/api/auth.js): a first-party service acting for a person names the project in
++ * X-OV-Project once Network confirms the person is an admin or owner of it; the person is the requester and
++ * the service is stored as via. Such a caller submits and reads the jobs it submitted for that person (any
++ * other job is the same 404); list, cancel and stream are 403 project.not_delegated. run.job.admin additionally reads and cancels any project's
* job (the manifest says so) and is the only way to pass project_id to a list. Another project's job is
* always 404 run.job_not_found, never 403: the answer must not say whether the id exists.
*
@@@ -125,23 +129,25 @@ function v1Router({ config, db, apiAuth, stream, tickets, events, dispatch, now
* One job, scoped to the token's project — or to any project with run.job.admin. Null when the id is
* not a job id, does not exist, or belongs to another project: all three are the same 404 to the caller.
*/
-- async function getJob(req, id) {
++ async function getJob(req, id, { delegated = false } = {}) {
if (!isJobId(id)) return null;
-- if (admin(req)) return store.get(db, id, null);
-- return store.get(db, id, apiAuth.requireProject(req).project);
++ if (admin(req) && !req.principal.delegated) return store.get(db, id, null);
++ const scope = apiAuth.requireProject(req, { delegated });
++ if (scope.via) return store.getOwn(db, id, { projectId: scope.project, requester: scope.requester, via: scope.via });
++ return store.get(db, id, scope.project);
}
r.use((req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); });
// ── POST /jobs — run.job.submit ────────────────────────────────────────────
r.post('/jobs', guard('run.job.submit'), wrap(async (req, res) => {
-- const { project: projectId, requester } = apiAuth.requireProject(req);
++ const { project: projectId, requester, via } = apiAuth.requireProject(req, { delegated: true });
const body = validCreate(req.body);
checkLimits(body, config.limits);
const key = idempotencyKey(req, body);
const at = now();
const out = await db.tx(async (t) => {
-- const made = await store.mint(t, body, { projectId, requester, idempotencyKey: key, now: at });
++ const made = await store.mint(t, body, { projectId, requester, via, idempotencyKey: key, now: at });
// The queued event is written in the same transaction as the row (never for an idempotent repeat:
// the row key run:<job id>:queued is already taken, and that replay is the row that exists).
if (made.created) await events.queued(t, made.row, { traceparent: req.ov && req.ov.traceparent });
@@@ -165,7 +171,7 @@ function v1Router({ config, db, apiAuth, stream, tickets, events, dispatch, now
// ── GET /jobs/:id — run.job.read ───────────────────────────────────────────
r.get('/jobs/:id', guard('run.job.read'), wrap(async (req, res) => {
-- const row = await getJob(req, req.params.id);
++ const row = await getJob(req, req.params.id, { delegated: true });
if (!row) fail(404, 'run.job_not_found', 'no such job');
res.json(store.readResult(row));
}));
@@@ -220,6 +226,7 @@ function v1Router({ config, db, apiAuth, stream, tickets, events, dispatch, now
// door is a query string a middleware cannot see.
const p = req.principal;
if (p.kind !== 'service') fail(403, 'capability.denied', 'run.job.stream is granted to service principals only');
++ if (p.delegated) fail(403, 'project.not_delegated', 'a delegated call does not stream; a token that names the project does');
const decision = contracts.capabilities.check(p.claims, 'run.job.stream');
if (!decision.allowed) fail(403, decision.code || 'capability.denied', decision.reason);
const project = apiAuth.projectOf(p);
ddiff --git a/server/app.js b/server/app.js
iindex 8a5c204..bb01f12 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -22,7 +22,7 @@ const { http } = require('openvibe-contracts');
const { isLoopbackDirect } = require('openvibe-shared/metrics');
const { loadConfig } = require('./config');
const { openDb } = require('./db');
--const { createKeyProvider, createUserAuth } = require('./network');
++const { createKeyProvider, createUserAuth, createProjectMembership } = require('./network');
const { createRunOutbox } = require('./events/outbox');
const { createStreamTickets } = require('./jobs/ticket');
const { createJobStream } = require('./jobs/stream');
@@@ -55,7 +55,8 @@ function createApp(opts = {}) {
// offers and the job loop that sends, mirrors and cancels. It is off without OV_OAUTH_CLIENT_SECRET —
// Run cannot mint the Bot token — and nothing runs until server/index.js calls its start().
const dispatch = opts.dispatch || createDispatcher({ config, db, store: require('./jobs/store'), events, stream, log, now, fetchImpl });
-- const apiAuth = createApiAuth({ config, keys, userAuth });
++ const membership = opts.membership || createProjectMembership(config, { fetchImpl, now });
++ const apiAuth = createApiAuth({ config, keys, userAuth, membership, log });
const release = require('openvibe-shared/release').createRelease({ service: 'run', root: path.join(__dirname, '..') });
const app = express();
ddiff --git a/server/db.js b/server/db.js
iindex 32cc54d..3957a45 100644
---- a/server/db.js
++++ b/server/db.js
@@@ -5,7 +5,7 @@
*
* The schema is migrations/NNNN_*.sql, applied at boot by migrate() on the owner's direct connection
* (DATABASE_DIRECT_URL); the service then serves on the pooled runtime role (DATABASE_URL, PgBouncer in
-- * transaction mode). Run is new: migrations/0001_jobs.sql is written as the final schema, no window.
++ * transaction mode). 0001_jobs.sql is the jobs schema; 0002 adds the nullable via column (project delegation).
*
* run_jobs one row per job (plan T14 R1; run.job-create-request@1)
* run_events_outbox the openvibe-sdk transactional outbox (run.job.* events)
ddiff --git a/server/jobs/store.js b/server/jobs/store.js
iindex 7d3a8db..89d3e0b 100644
---- a/server/jobs/store.js
++++ b/server/jobs/store.js
@@@ -74,7 +74,7 @@ function recordOf(row) {
* inputs [], egress none, requirements the contract's default, ttl_ms limits.wall_ms + 600000), the id
* minted by Run (job_<ULID>; Run owns run.job.* and passes it to Bot unchanged).
*/
--function normalize(body, { projectId, requester, idempotencyKey, now }) {
++function normalize(body, { projectId, requester, via = null, idempotencyKey, now }) {
const job = {
id: newJobId(now),
class: body.class,
@@@ -93,6 +93,7 @@ function normalize(body, { projectId, requester, idempotencyKey, now }) {
id: job.id,
project_id: projectId,
requester,
++ via,
idempotency_key: idempotencyKey,
class: job.class,
job,
@@@ -108,11 +109,11 @@ function normalize(body, { projectId, requester, idempotencyKey, now }) {
/** Insert a new job. The same (project_id, idempotency_key) already there returns that row, created:false. */
async function put(db, row) {
const inserted = await db.maybe(
-- `INSERT INTO run_jobs (id, project_id, requester, idempotency_key, class, job, ttl_ms, state, created_at, updated_at, wall_ms, usage_read)
-- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
++ `INSERT INTO run_jobs (id, project_id, requester, via, idempotency_key, class, job, ttl_ms, state, created_at, updated_at, wall_ms, usage_read)
++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
ON CONFLICT (project_id, idempotency_key) DO NOTHING
RETURNING *`,
-- [row.id, row.project_id, row.requester, row.idempotency_key, row.class, JSON.stringify(row.job), row.ttl_ms, row.state, row.created_at, row.updated_at, row.wall_ms, row.usage_read]);
++ [row.id, row.project_id, row.requester, row.via, row.idempotency_key, row.class, JSON.stringify(row.job), row.ttl_ms, row.state, row.created_at, row.updated_at, row.wall_ms, row.usage_read]);
if (inserted) return { row: inserted, created: true };
const existing = await db.maybe('SELECT * FROM run_jobs WHERE project_id = $1 AND idempotency_key = $2', [row.project_id, row.idempotency_key]);
if (!existing) throw new Error('run_jobs: the insert conflicted but no row is visible'); // only a crashed transaction does this
@@@ -125,15 +126,22 @@ async function put(db, row) {
* first job (created:false); with a different body it is 409 run.idempotency.conflict.
*
* The unique index is permanent: a key is spent for its project for good, where run.job-create-request@1
-- * promises the first-job answer "within 24 h". The 24 h window cannot be implemented on this table (no
-- * migration may add a column or relax the index), and answering a repeat with the first job forever is the
-- * safe direction — it can never run the work twice — so a key older than 24 h answers the same way rather
-- * than being recycled. A caller that reuses a key for different work is refused, not silently served.
++ * promises the first-job answer "within 24 h". The 24 h window is not implemented on this table (the
++ * index is never relaxed), and answering a repeat with the first job forever is the safe direction — it
++ * can never run the work twice — so a key older than 24 h answers the same way rather than being
++ * recycled. A caller that reuses a key for different work is refused, not silently served.
++ *
++ * A delegated submit (via set: a first-party service acting for a person in the project) is answered the
++ * first job only when that job is the same person's through the same service: a key another requester spent
++ * is 409 without naming the job, so a delegated caller never learns of work it did not start.
*/
--async function mint(db, body, { projectId, requester, idempotencyKey, now }) {
-- const row = normalize(body, { projectId, requester, idempotencyKey, now });
++async function mint(db, body, { projectId, requester, via = null, idempotencyKey, now }) {
++ const row = normalize(body, { projectId, requester, via, idempotencyKey, now });
const out = await put(db, row);
if (out.created) return out;
++ if ((via || out.row.via) && (out.row.requester !== requester || (out.row.via || null) !== via)) {
++ fail(409, 'run.idempotency.conflict', `idempotency_key ${idempotencyKey} was already used in this project`);
++ }
if (canonical(recordOf(out.row)) !== canonical(recordOf(row))) {
fail(409, 'run.idempotency.conflict', `idempotency_key ${idempotencyKey} was first used by ${out.row.id} with a different body`);
}
@@@ -143,6 +151,10 @@ async function mint(db, body, { projectId, requester, idempotencyKey, now }) {
/** Project-scoped get: null when the job does not exist or belongs to another project (→ 404, never 403). */
const get = (db, id, projectId = null) => db.maybe(`SELECT * FROM run_jobs WHERE id = $1${projectId ? ' AND project_id = $2' : ''}`, projectId ? [id, projectId] : [id]);
++/** A delegated caller's get: the job only if it is this project's, this person's, through this service (else null → 404). */
++const getOwn = (db, id, { projectId, requester, via }) => db.maybe(
++ 'SELECT * FROM run_jobs WHERE id = $1 AND project_id = $2 AND requester = $3 AND via = $4', [id, projectId, requester, via]);
++
/** One page, newest first, keyset cursor (created_at, id) → { rows, nextCursor }. */
async function page(db, where, values, limit, cursor) {
const conds = [...where];
@@@ -326,7 +338,7 @@ function readResult(row) {
module.exports = {
// The store surface the API and the dispatcher bridge (plan T14 step 6) use.
-- put, mint, get, list, adminList, cancel, mirror, settle, toJob, readResult, frameOf,
++ put, mint, get, getOwn, list, adminList, cancel, mirror, settle, toJob, readResult, frameOf,
// The poller's reads (waiting → placed → active → ended).
waiting, active, dueTtl,
};
ddiff --git a/server/network.js b/server/network.js
iindex ebd8ee8..26c66b7 100644
---- a/server/network.js
++++ b/server/network.js
@@@ -1,15 +1,21 @@
'use strict';
/**
-- * Run's side of OpenVibe.Network: the signing key every token is verified against, and offline
-- * verification of a person's user token.
++ * Run's side of OpenVibe.Network: the signing key every token is verified against, offline verification
++ * of a person's user token, and the one Network read Run makes — a person's membership of a project, for
++ * project delegation.
*
-- * Run calls no Network route: it verifies service tokens (audience openvibe.run) with the JWKS key and
-- * accepts a Network user token as a Bearer exactly as Bot does. Only a token that names a project
-- * (an app, agent or service principal with project_id) can act on jobs — every route says so.
++ * Run verifies service tokens (audience openvibe.run) with the JWKS key and accepts a Network user token
++ * as a Bearer exactly as Bot does. Only a token that names a project (an app, agent or service principal
++ * with project_id), or a first-party service delegated into a project (X-OV-Subject + X-OV-Project, see
++ * createProjectMembership), can act on jobs — every route says so.
*/
const crypto = require('crypto');
--const { verifyUserToken } = require('openvibe-sdk/auth');
++const { verifyUserToken, createServiceTokenClient } = require('openvibe-sdk/auth');
++
++const MEMBER_READ = 'network.project.member.read';
++const DELEGABLE_ROLES = new Set(['admin', 'owner']);
++const MEMBER_TTL_MS = 60_000; // the amendment's ceiling: a removed member or an archived project stops new jobs within a minute
/** Network's RS256 public key: OV_NETWORK_PUBLIC_KEY when the operator pins it, else the JWKS at boot. */
function createKeyProvider(config, { fetchImpl = globalThis.fetch, log = console } = {}) {
@@@ -60,4 +66,39 @@ function createUserAuth(config, keys) {
return { verify };
}
--module.exports = { createKeyProvider, createUserAuth };
++/**
++ * Project delegation (ADR-003 amendment 2026-10-12): may a first-party service start a job for this person in
++ * this project? Network states the membership (GET /internal/projects/:project/members/:subject, capability
++ * network.project.member.read, minted through Run's own client); the decision is Run's: only an admin or owner of
++ * a project that is not archived may be acted for. A yes is cached for MEMBER_TTL_MS, a no never (the person may
++ * have just been added). → { ok: true } | { ok: false } (a 404, another role, an archived project), and throws
++ * when Network cannot be asked (no client secret, no token, an error answer, a timeout): the caller answers 503.
++ */
++function createProjectMembership(config, { fetchImpl = globalThis.fetch, now = () => Date.now() } = {}) {
++ const { clientId, clientSecret } = config.oauth;
++ const tokens = clientSecret
++ ? createServiceTokenClient({ tokenUrl: `${config.network.internalUrl}/oauth/token`, clientId, clientSecret, audience: 'openvibe.network', scope: MEMBER_READ, fetch: fetchImpl, now })
++ : null;
++ const yes = new Map(); // `${project} ${subject}` → when the yes expires
++ async function allows(project, subject) {
++ const key = `${project} ${subject}`;
++ const until = yes.get(key);
++ if (until && until > now()) return { ok: true };
++ yes.delete(key);
++ if (!tokens) throw new Error('OV_OAUTH_CLIENT_SECRET is unset: Run cannot ask Network about a project membership');
++ const url = `${config.network.internalUrl}/internal/projects/${encodeURIComponent(project)}/members/${encodeURIComponent(subject)}`;
++ const res = await fetchImpl(url, { headers: { Accept: 'application/json', ...(await tokens.authHeaders()) }, signal: AbortSignal.timeout(5000) });
++ if (res.status === 404) return { ok: false };
++ if (!res.ok) throw new Error(`Network answered ${res.status} to the membership read`);
++ const body = await res.json();
++ const ok = !!body && body.project_id === project && body.subject === subject && DELEGABLE_ROLES.has(body.role) && body.archived === false;
++ if (ok) {
++ if (yes.size > 10_000) yes.clear();
++ yes.set(key, now() + MEMBER_TTL_MS);
++ }
++ return { ok };
++ }
++ return { allows, enabled: Boolean(tokens) };
++}
++
++module.exports = { createKeyProvider, createUserAuth, createProjectMembership, MEMBER_TTL_MS, DELEGABLE_ROLES };
ddiff --git a/test/delegation.test.js b/test/delegation.test.js
nnew file mode 100644
iindex 0000000..98a6be9
---- /dev/null
++++ b/test/delegation.test.js
@@@ -0,0 +1,165 @@
++'use strict';
++/**
++ * Project delegation (ADR-003 amendment 2026-10-12): a first-party service acting for a person (X-OV-Subject)
++ * names a project with X-OV-Project, and Run asks Network (the stub's members route, through Run's own client
++ * credentials) whether the person is an admin or owner of it. The person is the requester, the service is via;
++ * the service submits and reads only its own jobs for that person; everyone else naming a project is refused.
++ *
++ * node test/delegation.test.js
++ */
++const assert = require('assert');
++const contracts = require('openvibe-contracts');
++const { boot, check, done } = require('./helpers/app');
++
++const { ids } = contracts;
++
++async function checks(t) {
++ const P = ids.newId('project');
++ const Q = ids.newId('project');
++ const ada = ids.newId('user');
++ const bo = ids.newId('user');
++ const dev = ids.newId('user');
++ const viewer = ids.newId('user');
++ const old = ids.newId('user');
++ t.network.members.set(`${P} ${ada}`, { role: 'admin' });
++ t.network.members.set(`${P} ${bo}`, { role: 'owner' });
++ t.network.members.set(`${P} ${dev}`, { role: 'developer' });
++ t.network.members.set(`${P} ${viewer}`, { role: 'viewer' });
++ t.network.members.set(`${Q} ${old}`, { role: 'owner', archived: true });
++
++ // A first-party service token (no project of its own) acting for `subject` in `project`.
++ const as = (subject, project, { sub = 'svc:watch', cap = ['run.job.submit', 'run.job.read', 'run.job.list', 'run.job.cancel', 'run.job.stream'] } = {}) => ({
++ token: t.network.signService({ sub, cap }),
++ headers: { 'X-OV-Subject': subject, 'X-OV-Project': project },
++ });
++ const refused = (r, what) => {
++ assert.strictEqual(r.status, 403, `${what}: ${r.text}`);
++ assert.strictEqual(r.json.code, 'project.not_delegated', what);
++ };
++
++ let jobId;
++ await check('an admin member: the job is the project\'s, the person is the requester, the service is via', async () => {
++ const r = await t.call('POST', '/api/v1/jobs', { body: t.request(), ...as(ada, P), key: 'watch.chk_1.0' });
++ assert.strictEqual(r.status, 201, r.text);
++ assert.ok(contracts.validate('run.job-create-result@1', r.json).valid, JSON.stringify(r.json));
++ jobId = r.json.job.id;
++ assert.strictEqual(r.json.job.project_id, P);
++ assert.deepStrictEqual(r.json.job.requester, { type: 'user', id: ada });
++ const [row] = await t.db.many('SELECT requester, via FROM run_jobs WHERE id = $1', [jobId]);
++ assert.deepStrictEqual({ ...row }, { requester: `user:${ada}`, via: 'service:watch' });
++ const queued = await t.outboxRows('run.job.queued', jobId);
++ assert.strictEqual(queued.length, 1);
++ assert.ok(contracts.validate('run.job.queued@1', queued[0].payload).valid);
++ // Run asked Network as itself, for exactly the capability the read needs.
++ assert.ok(t.network.tokenRequests.scopes.includes('network.project.member.read'));
++ assert.ok(t.network.tokenRequests.audiences.includes('openvibe.network'));
++ assert.match(String(t.network.memberState.lastAuth), /^Bearer /);
++ // An owner is accepted too.
++ const owner = await t.call('POST', '/api/v1/jobs', { body: t.request(), ...as(bo, P) });
++ assert.strictEqual(owner.status, 201, owner.text);
++ });
++
++ await check('the delegated caller reads its own job; another person, another service: the same 404', async () => {
++ const mine = await t.call('GET', `/api/v1/jobs/${jobId}`, as(ada, P));
++ assert.strictEqual(mine.status, 200, mine.text);
++ assert.strictEqual(mine.json.id, jobId);
++ const otherPerson = await t.call('GET', `/api/v1/jobs/${jobId}`, as(bo, P));
++ const otherService = await t.call('GET', `/api/v1/jobs/${jobId}`, as(ada, P, { sub: 'svc:actor' }));
++ const missing = await t.call('GET', `/api/v1/jobs/${ids.newId('job')}`, as(ada, P));
++ for (const r of [otherPerson, otherService]) { assert.strictEqual(r.status, 404, r.text); assert.strictEqual(r.json.code, 'run.job_not_found'); }
++ assert.strictEqual(otherPerson.text.replace(/"request_id":"[^"]*"|"trace_id":"[^"]*"/g, ''), missing.text.replace(/"request_id":"[^"]*"|"trace_id":"[^"]*"/g, ''), 'never says the job exists');
++ // The project's own token still sees every job of the project.
++ const project = await t.call('GET', `/api/v1/jobs/${jobId}`, { token: t.network.signService({ sub: `app:${ids.newId('app')}`, actor_type: 'app', env: 'production', cap: ['run.job.read'], project_id: P }) });
++ assert.strictEqual(project.status, 200, project.text);
++ });
++
++ await check('list, cancel and stream stay with project tokens', async () => {
++ refused(await t.call('GET', '/api/v1/jobs', as(ada, P)), 'list');
++ refused(await t.call('POST', `/api/v1/jobs/${jobId}/cancel`, as(ada, P)), 'cancel');
++ refused(await t.call('POST', `/api/v1/jobs/${jobId}/stream/ticket`, as(ada, P)), 'stream ticket');
++ const d = as(ada, P);
++ const stream = await t.sse(`/api/v1/jobs/${jobId}/stream`, { headers: { Authorization: `Bearer ${d.token}`, ...d.headers } });
++ assert.strictEqual(stream.status, 403, stream.text);
++ assert.strictEqual(stream.json.code, 'project.not_delegated');
++ const [row] = await t.db.many('SELECT state FROM run_jobs WHERE id = $1', [jobId]);
++ assert.strictEqual(row.state, 'queued', 'the refused cancel changed nothing');
++ });
++
++ await check('a developer, a viewer, a non-member and an archived project are refused alike', async () => {
++ const stranger = ids.newId('user');
++ for (const [subject, project, what] of [[dev, P, 'developer'], [viewer, P, 'viewer'], [stranger, P, 'non-member'], [old, Q, 'archived project'], [ada, ids.newId('project'), 'unknown project']]) {
++ refused(await t.call('POST', '/api/v1/jobs', { body: t.request(), ...as(subject, project) }), what);
++ }
++ const made = await t.db.many('SELECT id FROM run_jobs WHERE project_id = $1 AND requester = $2', [P, `user:${dev}`]);
++ assert.strictEqual(made.length, 0, 'no job was made for a refused delegation');
++ });
++
++ await check('only a first-party service naming a person may name a project', async () => {
++ const body = t.request();
++ // An app (a mod or an agent is refused by the same svc: test): its project is its token's own.
++ refused(await t.call('POST', '/api/v1/jobs', { body, token: t.network.signService({ sub: `app:${ids.newId('app')}`, actor_type: 'app', env: 'production', cap: ['run.job.submit'], project_id: P }), headers: { 'X-OV-Subject': ada, 'X-OV-Project': P } }), 'an app token');
++ // A service with no person to act for.
++ refused(await t.call('POST', '/api/v1/jobs', { body, token: t.network.signService({ sub: 'svc:watch', cap: ['run.job.submit'] }), headers: { 'X-OV-Project': P } }), 'no X-OV-Subject');
++ // A token that already names a project cannot name another.
++ refused(await t.call('POST', '/api/v1/jobs', { body, token: t.network.signService({ sub: 'svc:watch', cap: ['run.job.submit'], project_id: Q }), headers: { 'X-OV-Subject': ada, 'X-OV-Project': P } }), 'a token with project_id');
++ // Malformed headers.
++ refused(await t.call('POST', '/api/v1/jobs', { body, ...as(ada, 'prj_nope') }), 'a malformed project');
++ refused(await t.call('POST', '/api/v1/jobs', { body, ...as('usr_nope', P) }), 'a malformed subject');
++ // A person's own token.
++ refused(await t.call('POST', '/api/v1/jobs', { body, token: t.network.signUser({ subject: ada }), headers: { 'X-OV-Project': P } }), 'a user token');
++ // The service still needs its own grant: delegation adds no capability.
++ const noGrant = await t.call('POST', '/api/v1/jobs', { body, ...as(ada, P, { cap: ['run.job.read'] }) });
++ assert.strictEqual(noGrant.status, 403, noGrant.text);
++ assert.strictEqual(noGrant.json.code, 'capability.denied');
++ });
++
++ await check('a yes is cached at most 60 s; a removed member is refused after it', async () => {
++ const before = t.network.memberState.reads;
++ assert.strictEqual((await t.call('POST', '/api/v1/jobs', { body: t.request(), ...as(bo, P) })).status, 201);
++ assert.strictEqual(t.network.memberState.reads, before, 'served from the cache (bo was confirmed by an earlier check)');
++ t.network.members.delete(`${P} ${bo}`);
++ assert.strictEqual((await t.call('POST', '/api/v1/jobs', { body: t.request(), ...as(bo, P) })).status, 201, 'within the minute');
++ t.clock.offset += 61_000;
++ try {
++ refused(await t.call('POST', '/api/v1/jobs', { body: t.request(), ...as(bo, P) }), 'after the minute');
++ assert.ok(t.network.memberState.reads > before, 'asked Network again');
++ } finally { t.clock.offset -= 61_000; }
++ });
++
++ await check('Network unavailable: 503 project.check_unavailable, never a guess', async () => {
++ t.clock.offset += 61_000; // past every cached yes
++ t.network.memberState.fail = true;
++ try {
++ const r = await t.call('POST', '/api/v1/jobs', { body: t.request(), ...as(ada, P) });
++ assert.strictEqual(r.status, 503, r.text);
++ assert.strictEqual(r.json.code, 'project.check_unavailable');
++ } finally { t.network.memberState.fail = false; t.clock.offset -= 61_000; }
++ });
++
++ await check('a delegated repeat answers its own first job; a key someone else spent is 409 without naming it', async () => {
++ const again = await t.call('POST', '/api/v1/jobs', { body: t.request(), ...as(ada, P), key: 'watch.chk_1.0' });
++ assert.strictEqual(again.status, 200, again.text);
++ assert.strictEqual(again.json.created, false);
++ assert.strictEqual(again.json.job.id, jobId);
++ const theirs = await t.call('POST', '/api/v1/jobs', { body: t.request(), ...as(ada, P, { sub: 'svc:actor' }), key: 'watch.chk_1.0' });
++ assert.strictEqual(theirs.status, 409, theirs.text);
++ assert.strictEqual(theirs.json.code, 'run.idempotency.conflict');
++ assert.ok(!theirs.text.includes(jobId), 'the conflict does not name the job');
++ // The project's own token reusing a delegated key is refused the same way (the key is spent).
++ const project = await t.call('POST', '/api/v1/jobs', { body: t.request(), cap: ['run.job.submit'], project: P, sub: 'svc:builder', key: 'watch.chk_1.0' });
++ assert.strictEqual(project.status, 409, project.text);
++ });
++}
++
++async function main() {
++ // Run's own client secret is set: the membership read goes through the real client-credentials path.
++ const t = await boot({ env: { OV_OAUTH_CLIENT_SECRET: 'run-test-secret' } });
++ try {
++ await checks(t);
++ } finally {
++ await t.close();
++ }
++ done();
++}
++
++main().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/helpers/app.js b/test/helpers/app.js
iindex 855e86b..599e2d6 100644
---- a/test/helpers/app.js
++++ b/test/helpers/app.js
@@@ -40,7 +40,23 @@ async function startNetwork() {
const issuer = 'http://network.test';
const tokenRequests = { n: 0, audiences: [], scopes: [] };
const offers = []; // what the registry publishes; a test replaces its contents (t.setOffers)
++ // Project memberships Network states (GET /internal/projects/:project/members/:subject, project delegation):
++ // `${project} ${subject}` → { role, archived }; memberReads counts the reads, memberFail makes them answer 500.
++ const members = new Map();
++ const memberState = { reads: 0, fail: false, lastAuth: null };
const server = http.createServer((req, res) => {
++ const member = /^\/internal\/projects\/([^/]+)\/members\/([^/?]+)$/.exec(req.url);
++ if (req.method === 'GET' && member) {
++ memberState.reads++;
++ memberState.lastAuth = req.headers.authorization || null;
++ if (memberState.fail) { res.writeHead(500, { 'Content-Type': 'application/json' }); return res.end('{}'); }
++ const project = decodeURIComponent(member[1]);
++ const subject = decodeURIComponent(member[2]);
++ const m = members.get(`${project} ${subject}`);
++ if (!m) { res.writeHead(404, { 'Content-Type': 'application/problem+json' }); return res.end(JSON.stringify({ code: 'project.member_not_found' })); }
++ res.writeHead(200, { 'Content-Type': 'application/json' });
++ return res.end(JSON.stringify({ project_id: project, subject, role: m.role, archived: !!m.archived, added_at: null }));
++ }
if (req.url === '/api/.well-known/jwks') {
res.writeHead(200, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ keys: [{ kty: 'RSA', kid: 'n1', ...publicKey.export({ format: 'jwk' }) }], public_key: publicPem }));
@@@ -84,7 +100,7 @@ async function startNetwork() {
});
const url = await listen(server);
return {
-- url, issuer, publicPem, privatePem, tokenRequests, offers,
++ url, issuer, publicPem, privatePem, tokenRequests, offers, members, memberState,
/** Publish exactly these platform.resource-offer@1 documents (the array keeps its identity). */
setOffers(list) { offers.splice(0, offers.length, ...list); },
close: () => new Promise((r) => server.close(r)),