openvibers/openvibe.network Commits
T2 lane B step 3: implement GET /internal/projects/:projectid under… (#28)
Changes
@@@ -2,7 +2,7 @@Roadmap Wave 20 foundation. The binding decision is ADR-014 in OpenVibe.Contracts. Network owns developer projects: who may build on OpenVibe, which apps they run, what those apps may do, and how much they may use. OpenVibe.Codes (the portal, the next step) is a client of this API. It never enforces grants or quotas itself.--What exists today: the data model, the `/api/v1/projects` API, app tokens from `/oauth/token`, the sandbox check in Network's own capability guard, defaults that let a new project's sandbox apps work without staff (sandbox audiences and a sandbox allowance), the audit, and a relay of the audit's events to OpenVibe.Events (off until `OV_EVENTS_INTERNAL_URL` is set). What does not exist yet: a UI (Codes), a consent screen that lists capabilities, refresh tokens for apps, and service-side reads of projects (`network.project.read`, proposed). Quotas are enforced by the owning services, not here.++What exists today: the data model, the `/api/v1/projects` API, app tokens from `/oauth/token`, the sandbox check in Network's own capability guard, defaults that let a new project's sandbox apps work without staff (sandbox audiences and a sandbox allowance), the audit, and a relay of the audit's events to OpenVibe.Events (off until `OV_EVENTS_INTERNAL_URL` is set). What does not exist yet: a UI (Codes), a consent screen that lists capabilities, and refresh tokens for apps. Owning services read a project through `GET /internal/projects/:project_id` ([Service-side project reads](#service-side-project-reads)). Quotas are enforced by the owning services, not here.## Model@@@ -133,7 +133,7 @@ This is ADR-014's acceptance rule: "a revoked credential fails everywhere within## Quotas--A quota says how much of one capability a project may use: for example, `media.object.upload` with limit 1073741824, window `total`, unit `bytes`. Network records quotas and shows them to members. **Network does not enforce them.** The service that owns the capability does (`enforced_by` in the response is that audience). Until an owning service reads project quotas (proposed capability `network.project.read`), a quota is a recorded limit and not a guarantee. What a quota's current window has used is measured from the services' usage rollups ([Usage](#usage)).++A quota says how much of one capability a project may use: for example, `media.object.upload` with limit 1073741824, window `total`, unit `bytes`. Network records quotas and shows them to members. **Network does not enforce them.** The service that owns the capability does (`enforced_by` in the response is that audience). An owning service reads a project's quotas through `GET /internal/projects/:project_id` ([Service-side project reads](#service-side-project-reads)). Until that service enforces what it reads, a quota is a recorded limit and not a guarantee. What a quota's current window has used is measured from the services' usage rollups ([Usage](#usage)).## Usage@@@ -195,6 +195,21 @@ Base: `/api/v1/projects`. Every call needs `Authorization: Bearer <Network user| `GET /:project/audit[?before=&limit=]` | admin+, staff | newest first, paged by `next_before` || `POST /:project/export-tokens` | owner, admin (not staff as such) | `{ audience, env }` → a 5-minute read-only export token ([Export tokens](#export-tokens)) |++### Service-side project reads++++`GET /internal/projects/:project_id` (loopback only, like all of `/internal`) needs a service token holding `network.project.read` for `openvibe.network`. Owning services use it to key tenancy by `project_id` and to enforce the quotas Network records (ADR-014).++++- **Who.** By default only Host holds it (`DEFAULT_GRANTS`), because Host places and runs workloads per project. Another service gets a row only when it ships a caller. The capability is `first-party` and is never granted to apps: staff cannot add it to an allowance, and a person's session or an app token gets `401`/`403`.++- **Result** (`Cache-Control: no-store`): `{ project, allowance, quotas, apps, grants }`.++ - `project`: `id`, `name`, `owner` (`{ type: 'user', id: usr_… }`), `environment_policy`, `home_cell`, `residency` (the home cell's), `preferred_regions`, `created_at`, `archived_at`.++ - `quotas[]`: `capability`, `limit`, `window`, `unit`, `enforced_by`.++ - `apps[]`: `id`, `name`, `environment`, `status` (`active` or `revoked`), `created_at`, `revoked_at`. Revoked apps are listed too.++ - `grants[]`: `app_id`, `capability`, `audience`, `status` (`requested`, `approved`, `denied` or `revoked`), `decided_at`. These are every app's grants, including those of revoked apps: a revoked app's grant that was not denied reads `revoked`, with `decided_at` the app's revocation time (revoking an app or archiving its project leaves the grant rows themselves unchanged).++- **Never in it:** secrets or their hashes, client ids, redirect URIs, credentials, members other than the owner, or who requested or decided a grant.++- **Archived projects** still read, with `archived_at` set and every app revoked.++- **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` is `planned` in openvibe-contracts until Contracts makes it `active`. The guard accepts `planned` and refuses only `retired`.++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.## Events@@@ -242,7 +257,7 @@ Until the relay is turned on in an environment, nobody else receives these event## Known gaps (next steps)- **Consent screen.** The account chooser shows the app's name, not the capabilities it asks for. Codes and Network need a consent step before third-party production apps go live.--- **Service-side project reads.** `network.project.read` (proposed) and `GET /internal/projects/:id` let owning services enforce quotas and check tenancy.++- **Service-side project reads.** The route is built, but no service calls it yet. Host holds the grant. Contracts still has to make `network.project.read` `active`.- **PowerChat as the first project** (ADR-014 migration). This is not done. PowerChat's existing OAuth client is unchanged.- **Receivers.** Every default sandbox audience must refuse `env: sandbox` except on routes that keep sandbox data apart, and must key tenancy by `project_id`. Media and Events do this in Wave 20. Tools does not yet (see [Sandbox tokens](#sandbox-tokens)).- **Revocation fan-out.** The relayed `network.app.revoked` event is the signal receivers can use to disable an app's subscriptions or tenants early. OpenVibe.Events acts on it (and on `network.grant.changed` withdrawing `events.app.subscribe`): it disables the app's subscriptions and refuses its tokens issued before the revocation. That needs this relay switched on. A receiver that does not act on it only stops the app when its issued tokens expire (within 5 minutes); state the app created there (for example a Media object) is untouched by Network.@@@ -25,7 +25,7 @@ allowances changes.| Subjects | `server/identity/subjects.js` resolves `usr_` and `gst_` only | no `agt_` || Capability guard | `principals.js:347` `guard(capability)` throws at boot on an id the catalog does not have | built || Delegated client capability | `docs/capabilities-proposal/network.project.manage.json` (in the catalog at v0.85.0 as `public`, `planned`) | proposed, unused |--| Service-side project read | `network.project.read` (catalog: `first-party`, `planned`; route "GET /internal/projects/:project_id (planned)") | unused |++| Service-side project read | `network.project.read` (catalog: `first-party`, `planned`); `GET /internal/projects/:project_id`, granted to Host only | built (lane B step 3); no caller yet; Contracts still has to make it `active` || Sensitive capabilities, confirmations, budgets, agents | — | missing (agents: built by slice 2) |## 2. Contract vocabulary (v0.85.0, used verbatim)@@@ -156,6 +156,37 @@ async function projectView(db, p, role, settings) {};}++/**++ * The service-facing view of a project (network.project.read, GET /internal/projects/:project_id): tenancy (id, owner,++ * home cell, residency), the allowance, the quotas, and every app with its grants (dev_grants has no project_id, so++ * 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.++ */++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;++ const cell = await db.prepare('SELECT residency FROM platform_cells WHERE id = ?').get(p.home_cell);++ const quotas = await db.prepare('SELECT * FROM dev_quotas WHERE project_id = ? ORDER BY capability').all(p.id);++ const apps = await db.prepare('SELECT id, name, environment, created_at, revoked_at FROM dev_apps WHERE project_id = ? ORDER BY id').all(p.id);++ const grants = await db.prepare(`SELECT g.app_id, g.capability, g.audience, g.status, g.decided_at, a.revoked_at AS app_revoked_at FROM dev_grants g++ JOIN dev_apps a ON a.id = g.app_id WHERE a.project_id = ? ORDER BY g.app_id, g.capability`).all(p.id);++ // Revoking an app (or archiving its project) leaves its grant rows as they were; the effective status is++ // 'revoked' (as of the app's revocation) for every grant of a revoked app that was not denied.++ const effective = g => (g.app_revoked_at && g.status !== 'denied' && g.status !== 'revoked'++ ? { status: 'revoked', decided_at: g.app_revoked_at } : { status: g.status, decided_at: g.decided_at || null });++ return {++ project: {++ id: p.id, name: p.name, owner: { type: 'user', id: p.owner_subject }, environment_policy: p.environment_policy,++ home_cell: p.home_cell || null, residency: cell ? cell.residency : null, preferred_regions: JSON.parse(p.preferred_regions || '[]'),++ created_at: p.created_at, archived_at: p.archived_at || null,++ },++ allowance: JSON.parse(p.allowance || '[]'),++ quotas: quotas.map(q => ({ capability: q.capability, limit: Number(q.limit_value), window: q.quota_window, unit: q.unit, enforced_by: policy.audienceOf(q.capability) })),++ apps: apps.map(a => ({ id: a.id, name: a.name, environment: a.environment, status: a.revoked_at ? 'revoked' : 'active', created_at: a.created_at, revoked_at: a.revoked_at || null })),++ grants: grants.map(g => ({ app_id: g.app_id, capability: g.capability, audience: g.audience, ...effective(g) })),++ };++}++async function createProject(db, actor, { name }, { settings, ctx }) {const n = cleanName(name);const owned = (await db.prepare('SELECT COUNT(*) AS n FROM dev_projects WHERE owner_subject = ? AND archived_at IS NULL').get(actor.subject)).n;@@@ -669,7 +700,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,++ createProject, listProjects, renameProject, archiveProject, setAllowance, setEnvironmentPolicy, setPlacement, projectView, internalProjectView,listMembers, addMember, updateMember, removeMember,listApps, getApp, createApp, updateApp, revokeApp,listCredentials, rotateCredential, revokeCredential, matchSecret, hashSecret,@@@ -152,6 +152,9 @@ const DEFAULT_GRANTS = [['host', 'network.resource.report', SELF_AUDIENCE, []],// Plan T2 N4b: Bot pairs its users' machines with Network and reads or revokes only the principals it paired.['bot', 'network.node.manage', SELF_AUDIENCE, []],++ // Plan T2 lane B step 3: Host places and runs per project, so it reads a project's tenancy, placement and quotas.++ // Other services get this row only when they ship a caller of GET /internal/projects/:project_id.++ ['host', 'network.project.read', SELF_AUDIENCE, []],// WS-N task 12: ovhost incident / maintenance post to the status page.['host', 'network.status.incident', SELF_AUDIENCE, []],// WS-L task 4: OpenVibe.Host's scheduled Tools job proof (openvibe-toolsjob.timer, OpenVibe.Examples@@@ -484,4 +484,18 @@ router.post('/resolve-anon', principals.guard('identity.subject.resolve'), async}});++// ── Read a developer project ─────────────────────────────────++// Owning services key tenancy by project_id and enforce the quotas Network records (docs/developer-projects.md, ADR-014).++// The capability is first-party and never grantable to apps; the view carries no secrets and no members but the owner.++router.get('/projects/:project_id', principals.guard('network.project.read'), async (req, res) => {++ try {++ const out = await require('../developer/store').internalProjectView(getDb(req), req.params.project_id);++ if (!out) return require('openvibe-contracts').http.sendProblem(res, 404, 'project.not_found', { detail: 'no such project' });++ res.set('Cache-Control', 'no-store').json(out);++ } catch (err) {++ console.error('[Internal] project read error:', err.message);++ require('openvibe-contracts').http.sendProblem(res, 500, 'internal.error', { detail: 'project read failed' });++ }++});++module.exports = router;nnew file mode 100644@@@ -0,0 +1,159 @@++'use strict';++// The service-facing project read (plan T2 lane B step 3, docs/developer-projects.md "Service-side project reads"):++// GET /internal/projects/:project_id takes network.project.read on a service token (Host only by default), returns++// the project's tenancy, placement, allowance, quotas, apps and their grants, and never a secret, a client id, an++// actor label or a member other than the owner. Archived projects still read; a missing or malformed id is a 404.++// node test/internal-projects.test.js++const assert = require('assert');++const crypto = require('crypto');++const http = require('http');++const express = require('express');++const jwt = require('jsonwebtoken');++const { getDb } = require('../server/db/database');++const subjects = require('../server/identity/subjects');++const principals = require('../server/identity/principals');++const policy = require('../server/developer/policy');++++(async () => {++const log = console.log; console.log = () => {};++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(`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));++++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });++const ISSUER = 'https://openvibe.network';++const app = express();++app.use(express.json());++app.use(express.urlencoded({ extended: true }));++app.locals.db = db;++app.locals.config = {++ baseUrl: ISSUER, loginUrl: ISSUER,++ jwt: { issuer: ISSUER, accessTokenExpiry: '1h' },++ developer: { sandboxAudiences: 'openvibe.media', sandboxAllowance: '', credentialOverlapS: 60 },++};++app.locals.privateKey = keys.privateKey;++app.locals.publicKey = keys.publicKey;++app.use('/oauth', require('../server/auth/oauth-routes'));++app.use('/internal', require('../server/internal/routes'));++app.use('/api/v1/projects', require('../server/developer/routes').router());++const server = http.createServer(app);++const userToken = (id) => jwt.sign({ sub: id, id }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });++const U = { owner: userToken(10), dev: userToken(11), staff: userToken(14) };++++(async () => {++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));++ const base = `http://127.0.0.1:${server.address().port}`;++ const api = async (who, method, p, body) => {++ const r = await fetch(`${base}/api/v1/projects${p}`, { method, headers: { authorization: `Bearer ${U[who]}`, ...(body ? { 'content-type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined });++ const text = await r.text();++ return { status: r.status, text, body: text ? JSON.parse(text) : null };++ };++ const token = async (id, secret, scope) => (await (await fetch(`${base}/oauth/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'client_credentials', client_id: id, client_secret: secret, audience: 'openvibe.network', ...(scope ? { scope } : {}) }) })).json()).access_token;++ const read = async (id, headers = {}) => {++ const r = await fetch(`${base}/internal/projects/${encodeURIComponent(id)}`, { headers });++ const text = await r.text();++ return { status: r.status, type: r.headers.get('content-type') || '', cache: r.headers.get('cache-control'), text, body: text ? JSON.parse(text) : null };++ };++ try {++ // 0. The grant: Host only by default, and never grantable to apps.++ assert.deepStrictEqual(principals.DEFAULT_GRANTS.filter(g => g[1] === 'network.project.read').map(g => [g[0], g[2]]), [['host', principals.SELF_AUDIENCE]],++ 'only Host holds network.project.read by default');++ assert.strictEqual(policy.grantability('network.project.read').grantable, false, 'network.project.read is never grantable to apps');++++ // A project made through the members' API: allowance, a quota, a second member, two apps with grants, one revoked.++ let r = await api('owner', 'POST', '', { name: 'Tenancy' });++ assert.strictEqual(r.status, 201, r.text);++ const P = r.body.id;++ r = await api('staff', 'PUT', `/${P}/allowance`, { capabilities: ['media.object.upload', 'media.object.read'] });++ assert.strictEqual(r.status, 200, r.text);++ r = await api('staff', 'PUT', `/${P}/quotas/media.object.upload`, { limit: 1073741824, window: 'total', unit: 'bytes' });++ assert.strictEqual(r.status, 200, r.text);++ r = await api('owner', 'POST', `/${P}/members`, { username: 'dev', role: 'developer' });++ assert.strictEqual(r.status, 201, r.text);++ r = await api('owner', 'POST', `/${P}/apps`, { name: 'Uploader', environment: 'sandbox', type: 'confidential' });++ assert.strictEqual(r.status, 201, r.text);++ const A = r.body.id;++ const secrets = [r.body.credential.client_secret];++ r = await api('owner', 'POST', `/${P}/apps`, { name: 'Reader', environment: 'sandbox', type: 'confidential' });++ assert.strictEqual(r.status, 201, r.text);++ const B = r.body.id;++ secrets.push(r.body.credential.client_secret);++ for (const [appId, cap] of [[A, 'media.object.upload'], [B, 'media.object.read']]) {++ assert.strictEqual((await api('owner', 'POST', `/${P}/apps/${appId}/grants`, { capability: cap })).status, 201);++ assert.strictEqual((await api('owner', 'POST', `/${P}/apps/${appId}/grants/${cap}/approve`)).status, 200);++ }++ r = await api('owner', 'DELETE', `/${P}/apps/${B}`);++ assert.strictEqual(r.status, 200, r.text);++++ // 1. Auth matrix.++ const host = { authorization: `Bearer ${await token('host', 'host-secret')}` };++ r = await read(P);++ assert.strictEqual(r.status, 401); assert.strictEqual(r.body.code, 'token.missing', 'no token');++ assert.strictEqual((await read(P, { 'x-internal-key': 'legacy-key' })).status, 401, 'not the retired shared key');++ assert.strictEqual((await read(P, { authorization: `Bearer ${await token('live', 'live-secret')}` })).status, 403, 'Live lacks network.project.read');++ assert.strictEqual((await read(P, { authorization: `Bearer ${await token('host', 'host-secret', 'network.node.report')}` })).status, 403, 'a Host token scoped to another capability');++ assert.ok([401, 403].includes((await read(P, { authorization: `Bearer ${U.owner}` })).status), 'a person\'s session never reads it, not even the owner\'s');++ const appToken = (await (await fetch(`${base}/oauth/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'client_credentials', client_id: A, client_secret: secrets[0], audience: 'openvibe.network' }) })).json()).access_token;++ if (appToken) assert.ok([401, 403].includes((await read(P, { authorization: `Bearer ${appToken}` })).status), 'an app of the project cannot read it');++++ // 2. The document.++ r = await read(P, host);++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.cache, 'no-store');++ const owner = await sid(10);++ assert.deepStrictEqual(Object.keys(r.body).sort(), ['allowance', 'apps', 'grants', 'project', 'quotas']);++ assert.deepStrictEqual(r.body.project, {++ id: P, name: 'Tenancy', owner: { type: 'user', id: owner }, environment_policy: 'sandbox',++ home_cell: r.body.project.home_cell, residency: r.body.project.residency, preferred_regions: [],++ created_at: r.body.project.created_at, archived_at: null,++ });++ const member = (await api('owner', 'GET', `/${P}`)).body;++ assert.strictEqual(r.body.project.home_cell, member.home_cell, 'placement matches the members\' view');++ assert.strictEqual(r.body.project.residency, member.residency);++ assert.strictEqual(r.body.project.created_at, member.created_at);++ assert.deepStrictEqual(r.body.allowance, member.allowance, 'the allowance matches the members\' view');++ assert.deepStrictEqual([...r.body.allowance].sort(), ['media.object.read', 'media.object.upload']);++ assert.deepStrictEqual(r.body.quotas, [{ capability: 'media.object.upload', limit: 1073741824, window: 'total', unit: 'bytes', enforced_by: 'openvibe.media' }]);++ assert.deepStrictEqual(r.body.apps.map(a => [a.id, a.name, a.environment, a.status, !!a.revoked_at]).sort(), [[A, 'Uploader', 'sandbox', 'active', false], [B, 'Reader', 'sandbox', 'revoked', true]].sort());++ assert.deepStrictEqual(r.body.grants.map(g => [g.app_id, g.capability, g.audience]).sort(), [[A, 'media.object.upload', 'openvibe.media'], [B, 'media.object.read', 'openvibe.media']].sort());++ assert.strictEqual(r.body.grants.find(g => g.app_id === A).status, 'approved');++ const gB = r.body.grants.find(g => g.app_id === B);++ assert.strictEqual(gB.status, 'revoked', 'a revoked app\'s grant is listed as revoked');++ assert.strictEqual(gB.decided_at, r.body.apps.find(a => a.id === B).revoked_at, 'as of the app\'s revocation');++++ // 3. Never a secret, a client id, an actor label or a member other than the owner.++ const dev = await sid(11);++ for (const s of secrets) assert.ok(!r.text.includes(s), 'no client secret');++ for (const k of ['client_secret', 'secret_hash', 'client_id', 'redirect_uris', 'token_valid_after', 'members', 'requested_by', 'decided_by', 'created_by', 'credential']) {++ assert.ok(!r.text.includes(`"${k}"`), `no ${k} in the body`);++ }++ assert.ok(!r.text.includes(dev), 'no member but the owner');++ assert.ok(!/:"(owner|dev|staff)"/.test(r.text), 'no usernames as values');++++ // 4. Missing and malformed ids are 404 project.not_found, before or after the shape check.++ for (const id of ['prj_00000000000000000000000000', 'prj_nope', 'nope', `${P}'--`, P.toLowerCase()]) {++ const x = await read(id, host);++ assert.strictEqual(x.status, 404, id); assert.strictEqual(x.body.code, 'project.not_found', id);++ assert.match(x.type, /application\/problem\+json/);++ }++++ // 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);++ r = await read(P, host);++ assert.strictEqual(r.status, 200, r.text);++ assert.ok(r.body.project.archived_at, 'archived_at is set');++ 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');++++ log('internal-projects: all assertions passed');++ } finally {++ server.close();++ }++})().catch((e) => { console.error(e); process.exit(1); });++})().catch((e) => { console.error(e); process.exit(1); });
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
Roadmap Wave 20 foundation. The binding decision is ADR-014 in OpenVibe.Contracts. Network owns developer projects: who may build on OpenVibe, which apps they run, what those apps may do, and how much they may use. OpenVibe.Codes (the portal, the next step) is a client of this API. It never enforces grants or quotas itself.
Roadmap Wave 20 foundation. The binding decision is ADR-014 in OpenVibe.Contracts. Network owns developer projects: who may build on OpenVibe, which apps they run, what those apps may do, and how much they may use. OpenVibe.Codes (the portal, the next step) is a client of this API. It never enforces grants or quotas itself.
-What exists today: the data model, the `/api/v1/projects` API, app tokens from `/oauth/token`, the sandbox check in Network's own capability guard, defaults that let a new project's sandbox apps work without staff (sandbox audiences and a sandbox allowance), the audit, and a relay of the audit's events to OpenVibe.Events (off until `OV_EVENTS_INTERNAL_URL` is set). What does not exist yet: a UI (Codes), a consent screen that lists capabilities, refresh tokens for apps, and service-side reads of projects (`network.project.read`, proposed). Quotas are enforced by the owning services, not here.
+What exists today: the data model, the `/api/v1/projects` API, app tokens from `/oauth/token`, the sandbox check in Network's own capability guard, defaults that let a new project's sandbox apps work without staff (sandbox audiences and a sandbox allowance), the audit, and a relay of the audit's events to OpenVibe.Events (off until `OV_EVENTS_INTERNAL_URL` is set). What does not exist yet: a UI (Codes), a consent screen that lists capabilities, and refresh tokens for apps. Owning services read a project through `GET /internal/projects/:project_id` ([Service-side project reads](#service-side-project-reads)). Quotas are enforced by the owning services, not here.
## Model
## Model
@@ -133,7 +133,7 @@ This is ADR-014's acceptance rule: "a revoked credential fails everywhere within
@@ -133,7 +133,7 @@ This is ADR-014's acceptance rule: "a revoked credential fails everywhere within
## Quotas
## Quotas
-A quota says how much of one capability a project may use: for example, `media.object.upload` with limit 1073741824, window `total`, unit `bytes`. Network records quotas and shows them to members. **Network does not enforce them.** The service that owns the capability does (`enforced_by` in the response is that audience). Until an owning service reads project quotas (proposed capability `network.project.read`), a quota is a recorded limit and not a guarantee. What a quota's current window has used is measured from the services' usage rollups ([Usage](#usage)).
+A quota says how much of one capability a project may use: for example, `media.object.upload` with limit 1073741824, window `total`, unit `bytes`. Network records quotas and shows them to members. **Network does not enforce them.** The service that owns the capability does (`enforced_by` in the response is that audience). An owning service reads a project's quotas through `GET /internal/projects/:project_id` ([Service-side project reads](#service-side-project-reads)). Until that service enforces what it reads, a quota is a recorded limit and not a guarantee. What a quota's current window has used is measured from the services' usage rollups ([Usage](#usage)).
## Usage
## Usage
@@ -195,6 +195,21 @@ Base: `/api/v1/projects`. Every call needs `Authorization: Bearer <Network user
@@ -195,6 +195,21 @@ Base: `/api/v1/projects`. Every call needs `Authorization: Bearer <Network user
| `GET /:project/audit[?before=&limit=]` | admin+, staff | newest first, paged by `next_before` |
| `GET /:project/audit[?before=&limit=]` | admin+, staff | newest first, paged by `next_before` |
| `POST /:project/export-tokens` | owner, admin (not staff as such) | `{ audience, env }` → a 5-minute read-only export token ([Export tokens](#export-tokens)) |
| `POST /:project/export-tokens` | owner, admin (not staff as such) | `{ audience, env }` → a 5-minute read-only export token ([Export tokens](#export-tokens)) |
+### Service-side project reads
+
+`GET /internal/projects/:project_id` (loopback only, like all of `/internal`) needs a service token holding `network.project.read` for `openvibe.network`. Owning services use it to key tenancy by `project_id` and to enforce the quotas Network records (ADR-014).
+
+- **Who.** By default only Host holds it (`DEFAULT_GRANTS`), because Host places and runs workloads per project. Another service gets a row only when it ships a caller. The capability is `first-party` and is never granted to apps: staff cannot add it to an allowance, and a person's session or an app token gets `401`/`403`.
+- **Result** (`Cache-Control: no-store`): `{ project, allowance, quotas, apps, grants }`.
+ - `project`: `id`, `name`, `owner` (`{ type: 'user', id: usr_… }`), `environment_policy`, `home_cell`, `residency` (the home cell's), `preferred_regions`, `created_at`, `archived_at`.
+ - `quotas[]`: `capability`, `limit`, `window`, `unit`, `enforced_by`.
+ - `apps[]`: `id`, `name`, `environment`, `status` (`active` or `revoked`), `created_at`, `revoked_at`. Revoked apps are listed too.
+ - `grants[]`: `app_id`, `capability`, `audience`, `status` (`requested`, `approved`, `denied` or `revoked`), `decided_at`. These are every app's grants, including those of revoked apps: a revoked app's grant that was not denied reads `revoked`, with `decided_at` the app's revocation time (revoking an app or archiving its project leaves the grant rows themselves unchanged).
+- **Never in it:** secrets or their hashes, client ids, redirect URIs, credentials, members other than the owner, or who requested or decided a grant.
+- **Archived projects** still read, with `archived_at` set and every app revoked.
+- **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` is `planned` in openvibe-contracts until Contracts makes it `active`. The guard accepts `planned` and refuses only `retired`.
+
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.
## Events
## Events
@@ -242,7 +257,7 @@ Until the relay is turned on in an environment, nobody else receives these event
@@ -242,7 +257,7 @@ Until the relay is turned on in an environment, nobody else receives these event
## Known gaps (next steps)
## Known gaps (next steps)
- **Consent screen.** The account chooser shows the app's name, not the capabilities it asks for. Codes and Network need a consent step before third-party production apps go live.
- **Consent screen.** The account chooser shows the app's name, not the capabilities it asks for. Codes and Network need a consent step before third-party production apps go live.
-- **Service-side project reads.** `network.project.read` (proposed) and `GET /internal/projects/:id` let owning services enforce quotas and check tenancy.
+- **Service-side project reads.** The route is built, but no service calls it yet. Host holds the grant. Contracts still has to make `network.project.read` `active`.
- **PowerChat as the first project** (ADR-014 migration). This is not done. PowerChat's existing OAuth client is unchanged.
- **PowerChat as the first project** (ADR-014 migration). This is not done. PowerChat's existing OAuth client is unchanged.
- **Receivers.** Every default sandbox audience must refuse `env: sandbox` except on routes that keep sandbox data apart, and must key tenancy by `project_id`. Media and Events do this in Wave 20. Tools does not yet (see [Sandbox tokens](#sandbox-tokens)).
- **Receivers.** Every default sandbox audience must refuse `env: sandbox` except on routes that keep sandbox data apart, and must key tenancy by `project_id`. Media and Events do this in Wave 20. Tools does not yet (see [Sandbox tokens](#sandbox-tokens)).
- **Revocation fan-out.** The relayed `network.app.revoked` event is the signal receivers can use to disable an app's subscriptions or tenants early. OpenVibe.Events acts on it (and on `network.grant.changed` withdrawing `events.app.subscribe`): it disables the app's subscriptions and refuses its tokens issued before the revocation. That needs this relay switched on. A receiver that does not act on it only stops the app when its issued tokens expire (within 5 minutes); state the app created there (for example a Media object) is untouched by Network.
- **Revocation fan-out.** The relayed `network.app.revoked` event is the signal receivers can use to disable an app's subscriptions or tenants early. OpenVibe.Events acts on it (and on `network.grant.changed` withdrawing `events.app.subscribe`): it disables the app's subscriptions and refuses its tokens issued before the revocation. That needs this relay switched on. A receiver that does not act on it only stops the app when its issued tokens expire (within 5 minutes); state the app created there (for example a Media object) is untouched by Network.
@@ -25,7 +25,7 @@ allowances changes.
@@ -25,7 +25,7 @@ allowances changes.
| Subjects | `server/identity/subjects.js` resolves `usr_` and `gst_` only | no `agt_` |
| Subjects | `server/identity/subjects.js` resolves `usr_` and `gst_` only | no `agt_` |
| Capability guard | `principals.js:347` `guard(capability)` throws at boot on an id the catalog does not have | built |
| Capability guard | `principals.js:347` `guard(capability)` throws at boot on an id the catalog does not have | built |
| Delegated client capability | `docs/capabilities-proposal/network.project.manage.json` (in the catalog at v0.85.0 as `public`, `planned`) | proposed, unused |
| Delegated client capability | `docs/capabilities-proposal/network.project.manage.json` (in the catalog at v0.85.0 as `public`, `planned`) | proposed, unused |
-| Service-side project read | `network.project.read` (catalog: `first-party`, `planned`; route "GET /internal/projects/:project_id (planned)") | unused |
+| Service-side project read | `network.project.read` (catalog: `first-party`, `planned`); `GET /internal/projects/:project_id`, granted to Host only | built (lane B step 3); no caller yet; Contracts still has to make it `active` |
| Sensitive capabilities, confirmations, budgets, agents | — | missing (agents: built by slice 2) |
| Sensitive capabilities, confirmations, budgets, agents | — | missing (agents: built by slice 2) |
## 2. Contract vocabulary (v0.85.0, used verbatim)
## 2. Contract vocabulary (v0.85.0, used verbatim)
@@ -156,6 +156,37 @@ async function projectView(db, p, role, settings) {
@@ -156,6 +156,37 @@ async function projectView(db, p, role, settings) {
};
};
}
}
+/**
+ * The service-facing view of a project (network.project.read, GET /internal/projects/:project_id): tenancy (id, owner,
+ * home cell, residency), the allowance, the quotas, and every app with its grants (dev_grants has no project_id, so
+ * 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.
+ */
+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;
+ const cell = await db.prepare('SELECT residency FROM platform_cells WHERE id = ?').get(p.home_cell);
+ const quotas = await db.prepare('SELECT * FROM dev_quotas WHERE project_id = ? ORDER BY capability').all(p.id);
+ const apps = await db.prepare('SELECT id, name, environment, created_at, revoked_at FROM dev_apps WHERE project_id = ? ORDER BY id').all(p.id);
+ const grants = await db.prepare(`SELECT g.app_id, g.capability, g.audience, g.status, g.decided_at, a.revoked_at AS app_revoked_at FROM dev_grants g
+ JOIN dev_apps a ON a.id = g.app_id WHERE a.project_id = ? ORDER BY g.app_id, g.capability`).all(p.id);
+ // Revoking an app (or archiving its project) leaves its grant rows as they were; the effective status is
+ // 'revoked' (as of the app's revocation) for every grant of a revoked app that was not denied.
+ const effective = g => (g.app_revoked_at && g.status !== 'denied' && g.status !== 'revoked'
+ ? { status: 'revoked', decided_at: g.app_revoked_at } : { status: g.status, decided_at: g.decided_at || null });
+ return {
+ project: {
+ id: p.id, name: p.name, owner: { type: 'user', id: p.owner_subject }, environment_policy: p.environment_policy,
+ home_cell: p.home_cell || null, residency: cell ? cell.residency : null, preferred_regions: JSON.parse(p.preferred_regions || '[]'),
+ created_at: p.created_at, archived_at: p.archived_at || null,
+ },
+ allowance: JSON.parse(p.allowance || '[]'),
+ quotas: quotas.map(q => ({ capability: q.capability, limit: Number(q.limit_value), window: q.quota_window, unit: q.unit, enforced_by: policy.audienceOf(q.capability) })),
+ apps: apps.map(a => ({ id: a.id, name: a.name, environment: a.environment, status: a.revoked_at ? 'revoked' : 'active', created_at: a.created_at, revoked_at: a.revoked_at || null })),
+ grants: grants.map(g => ({ app_id: g.app_id, capability: g.capability, audience: g.audience, ...effective(g) })),
+ };
+}
+
async function createProject(db, actor, { name }, { settings, ctx }) {
async function createProject(db, actor, { name }, { settings, ctx }) {
const n = cleanName(name);
const n = cleanName(name);
const owned = (await db.prepare('SELECT COUNT(*) AS n FROM dev_projects WHERE owner_subject = ? AND archived_at IS NULL').get(actor.subject)).n;
const owned = (await db.prepare('SELECT COUNT(*) AS n FROM dev_projects WHERE owner_subject = ? AND archived_at IS NULL').get(actor.subject)).n;
@@ -669,7 +700,7 @@ async function deleteQuota(db, actor, projectId, capability, { ctx }) {
@@ -669,7 +700,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,
+ createProject, listProjects, renameProject, archiveProject, setAllowance, setEnvironmentPolicy, setPlacement, projectView, internalProjectView,
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,
@@ -152,6 +152,9 @@ const DEFAULT_GRANTS = [
@@ -152,6 +152,9 @@ const DEFAULT_GRANTS = [
['host', 'network.resource.report', SELF_AUDIENCE, []],
['host', 'network.resource.report', SELF_AUDIENCE, []],
// Plan T2 N4b: Bot pairs its users' machines with Network and reads or revokes only the principals it paired.
// Plan T2 N4b: Bot pairs its users' machines with Network and reads or revokes only the principals it paired.
['bot', 'network.node.manage', SELF_AUDIENCE, []],
['bot', 'network.node.manage', SELF_AUDIENCE, []],
+ // Plan T2 lane B step 3: Host places and runs per project, so it reads a project's tenancy, placement and quotas.
+ // Other services get this row only when they ship a caller of GET /internal/projects/:project_id.
+ ['host', 'network.project.read', SELF_AUDIENCE, []],
// WS-N task 12: ovhost incident / maintenance post to the status page.
// WS-N task 12: ovhost incident / maintenance post to the status page.
['host', 'network.status.incident', SELF_AUDIENCE, []],
['host', 'network.status.incident', SELF_AUDIENCE, []],
// WS-L task 4: OpenVibe.Host's scheduled Tools job proof (openvibe-toolsjob.timer, OpenVibe.Examples
// WS-L task 4: OpenVibe.Host's scheduled Tools job proof (openvibe-toolsjob.timer, OpenVibe.Examples
@@ -484,4 +484,18 @@ router.post('/resolve-anon', principals.guard('identity.subject.resolve'), async
@@ -484,4 +484,18 @@ router.post('/resolve-anon', principals.guard('identity.subject.resolve'), async
}
}
});
});
+// ── Read a developer project ─────────────────────────────────
+// Owning services key tenancy by project_id and enforce the quotas Network records (docs/developer-projects.md, ADR-014).
+// The capability is first-party and never grantable to apps; the view carries no secrets and no members but the owner.
+router.get('/projects/:project_id', principals.guard('network.project.read'), async (req, res) => {
+ try {
+ const out = await require('../developer/store').internalProjectView(getDb(req), req.params.project_id);
+ if (!out) return require('openvibe-contracts').http.sendProblem(res, 404, 'project.not_found', { detail: 'no such project' });
+ res.set('Cache-Control', 'no-store').json(out);
+ } catch (err) {
+ console.error('[Internal] project read error:', err.message);
+ require('openvibe-contracts').http.sendProblem(res, 500, 'internal.error', { detail: 'project read failed' });
+ }
+});
+
module.exports = router;
module.exports = router;
new file mode 100644
new file mode 100644
@@ -0,0 +1,159 @@
@@ -0,0 +1,159 @@
+'use strict';
+// The service-facing project read (plan T2 lane B step 3, docs/developer-projects.md "Service-side project reads"):
+// GET /internal/projects/:project_id takes network.project.read on a service token (Host only by default), returns
+// the project's tenancy, placement, allowance, quotas, apps and their grants, and never a secret, a client id, an
+// actor label or a member other than the owner. Archived projects still read; a missing or malformed id is a 404.
+// node test/internal-projects.test.js
+const assert = require('assert');
+const crypto = require('crypto');
+const http = require('http');
+const express = require('express');
+const jwt = require('jsonwebtoken');
+const { getDb } = require('../server/db/database');
+const subjects = require('../server/identity/subjects');
+const principals = require('../server/identity/principals');
+const policy = require('../server/developer/policy');
+
+(async () => {
+const log = console.log; console.log = () => {};
+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(`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));
+
+const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
+const ISSUER = 'https://openvibe.network';
+const app = express();
+app.use(express.json());
+app.use(express.urlencoded({ extended: true }));
+app.locals.db = db;
+app.locals.config = {
+ baseUrl: ISSUER, loginUrl: ISSUER,
+ jwt: { issuer: ISSUER, accessTokenExpiry: '1h' },
+ developer: { sandboxAudiences: 'openvibe.media', sandboxAllowance: '', credentialOverlapS: 60 },
+};
+app.locals.privateKey = keys.privateKey;
+app.locals.publicKey = keys.publicKey;
+app.use('/oauth', require('../server/auth/oauth-routes'));
+app.use('/internal', require('../server/internal/routes'));
+app.use('/api/v1/projects', require('../server/developer/routes').router());
+const server = http.createServer(app);
+const userToken = (id) => jwt.sign({ sub: id, id }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });
+const U = { owner: userToken(10), dev: userToken(11), staff: userToken(14) };
+
+(async () => {
+ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const api = async (who, method, p, body) => {
+ const r = await fetch(`${base}/api/v1/projects${p}`, { method, headers: { authorization: `Bearer ${U[who]}`, ...(body ? { 'content-type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined });
+ const text = await r.text();
+ return { status: r.status, text, body: text ? JSON.parse(text) : null };
+ };
+ const token = async (id, secret, scope) => (await (await fetch(`${base}/oauth/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'client_credentials', client_id: id, client_secret: secret, audience: 'openvibe.network', ...(scope ? { scope } : {}) }) })).json()).access_token;
+ const read = async (id, headers = {}) => {
+ const r = await fetch(`${base}/internal/projects/${encodeURIComponent(id)}`, { headers });
+ const text = await r.text();
+ return { status: r.status, type: r.headers.get('content-type') || '', cache: r.headers.get('cache-control'), text, body: text ? JSON.parse(text) : null };
+ };
+ try {
+ // 0. The grant: Host only by default, and never grantable to apps.
+ assert.deepStrictEqual(principals.DEFAULT_GRANTS.filter(g => g[1] === 'network.project.read').map(g => [g[0], g[2]]), [['host', principals.SELF_AUDIENCE]],
+ 'only Host holds network.project.read by default');
+ assert.strictEqual(policy.grantability('network.project.read').grantable, false, 'network.project.read is never grantable to apps');
+
+ // A project made through the members' API: allowance, a quota, a second member, two apps with grants, one revoked.
+ let r = await api('owner', 'POST', '', { name: 'Tenancy' });
+ assert.strictEqual(r.status, 201, r.text);
+ const P = r.body.id;
+ r = await api('staff', 'PUT', `/${P}/allowance`, { capabilities: ['media.object.upload', 'media.object.read'] });
+ assert.strictEqual(r.status, 200, r.text);
+ r = await api('staff', 'PUT', `/${P}/quotas/media.object.upload`, { limit: 1073741824, window: 'total', unit: 'bytes' });
+ assert.strictEqual(r.status, 200, r.text);
+ r = await api('owner', 'POST', `/${P}/members`, { username: 'dev', role: 'developer' });
+ assert.strictEqual(r.status, 201, r.text);
+ r = await api('owner', 'POST', `/${P}/apps`, { name: 'Uploader', environment: 'sandbox', type: 'confidential' });
+ assert.strictEqual(r.status, 201, r.text);
+ const A = r.body.id;
+ const secrets = [r.body.credential.client_secret];
+ r = await api('owner', 'POST', `/${P}/apps`, { name: 'Reader', environment: 'sandbox', type: 'confidential' });
+ assert.strictEqual(r.status, 201, r.text);
+ const B = r.body.id;
+ secrets.push(r.body.credential.client_secret);
+ for (const [appId, cap] of [[A, 'media.object.upload'], [B, 'media.object.read']]) {
+ assert.strictEqual((await api('owner', 'POST', `/${P}/apps/${appId}/grants`, { capability: cap })).status, 201);
+ assert.strictEqual((await api('owner', 'POST', `/${P}/apps/${appId}/grants/${cap}/approve`)).status, 200);
+ }
+ r = await api('owner', 'DELETE', `/${P}/apps/${B}`);
+ assert.strictEqual(r.status, 200, r.text);
+
+ // 1. Auth matrix.
+ const host = { authorization: `Bearer ${await token('host', 'host-secret')}` };
+ r = await read(P);
+ assert.strictEqual(r.status, 401); assert.strictEqual(r.body.code, 'token.missing', 'no token');
+ assert.strictEqual((await read(P, { 'x-internal-key': 'legacy-key' })).status, 401, 'not the retired shared key');
+ assert.strictEqual((await read(P, { authorization: `Bearer ${await token('live', 'live-secret')}` })).status, 403, 'Live lacks network.project.read');
+ assert.strictEqual((await read(P, { authorization: `Bearer ${await token('host', 'host-secret', 'network.node.report')}` })).status, 403, 'a Host token scoped to another capability');
+ assert.ok([401, 403].includes((await read(P, { authorization: `Bearer ${U.owner}` })).status), 'a person\'s session never reads it, not even the owner\'s');
+ const appToken = (await (await fetch(`${base}/oauth/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'client_credentials', client_id: A, client_secret: secrets[0], audience: 'openvibe.network' }) })).json()).access_token;
+ if (appToken) assert.ok([401, 403].includes((await read(P, { authorization: `Bearer ${appToken}` })).status), 'an app of the project cannot read it');
+
+ // 2. The document.
+ r = await read(P, host);
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.cache, 'no-store');
+ const owner = await sid(10);
+ assert.deepStrictEqual(Object.keys(r.body).sort(), ['allowance', 'apps', 'grants', 'project', 'quotas']);
+ assert.deepStrictEqual(r.body.project, {
+ id: P, name: 'Tenancy', owner: { type: 'user', id: owner }, environment_policy: 'sandbox',
+ home_cell: r.body.project.home_cell, residency: r.body.project.residency, preferred_regions: [],
+ created_at: r.body.project.created_at, archived_at: null,
+ });
+ const member = (await api('owner', 'GET', `/${P}`)).body;
+ assert.strictEqual(r.body.project.home_cell, member.home_cell, 'placement matches the members\' view');
+ assert.strictEqual(r.body.project.residency, member.residency);
+ assert.strictEqual(r.body.project.created_at, member.created_at);
+ assert.deepStrictEqual(r.body.allowance, member.allowance, 'the allowance matches the members\' view');
+ assert.deepStrictEqual([...r.body.allowance].sort(), ['media.object.read', 'media.object.upload']);
+ assert.deepStrictEqual(r.body.quotas, [{ capability: 'media.object.upload', limit: 1073741824, window: 'total', unit: 'bytes', enforced_by: 'openvibe.media' }]);
+ assert.deepStrictEqual(r.body.apps.map(a => [a.id, a.name, a.environment, a.status, !!a.revoked_at]).sort(), [[A, 'Uploader', 'sandbox', 'active', false], [B, 'Reader', 'sandbox', 'revoked', true]].sort());
+ assert.deepStrictEqual(r.body.grants.map(g => [g.app_id, g.capability, g.audience]).sort(), [[A, 'media.object.upload', 'openvibe.media'], [B, 'media.object.read', 'openvibe.media']].sort());
+ assert.strictEqual(r.body.grants.find(g => g.app_id === A).status, 'approved');
+ const gB = r.body.grants.find(g => g.app_id === B);
+ assert.strictEqual(gB.status, 'revoked', 'a revoked app\'s grant is listed as revoked');
+ assert.strictEqual(gB.decided_at, r.body.apps.find(a => a.id === B).revoked_at, 'as of the app\'s revocation');
+
+ // 3. Never a secret, a client id, an actor label or a member other than the owner.
+ const dev = await sid(11);
+ for (const s of secrets) assert.ok(!r.text.includes(s), 'no client secret');
+ for (const k of ['client_secret', 'secret_hash', 'client_id', 'redirect_uris', 'token_valid_after', 'members', 'requested_by', 'decided_by', 'created_by', 'credential']) {
+ assert.ok(!r.text.includes(`"${k}"`), `no ${k} in the body`);
+ }
+ assert.ok(!r.text.includes(dev), 'no member but the owner');
+ assert.ok(!/:"(owner|dev|staff)"/.test(r.text), 'no usernames as values');
+
+ // 4. Missing and malformed ids are 404 project.not_found, before or after the shape check.
+ for (const id of ['prj_00000000000000000000000000', 'prj_nope', 'nope', `${P}'--`, P.toLowerCase()]) {
+ const x = await read(id, host);
+ assert.strictEqual(x.status, 404, id); assert.strictEqual(x.body.code, 'project.not_found', id);
+ assert.match(x.type, /application\/problem\+json/);
+ }
+
+ // 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);
+ r = await read(P, host);
+ assert.strictEqual(r.status, 200, r.text);
+ assert.ok(r.body.project.archived_at, 'archived_at is set');
+ 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');
+
+ log('internal-projects: all assertions passed');
+ } finally {
+ server.close();
+ }
+})().catch((e) => { console.error(e); process.exit(1); });
+})().catch((e) => { console.error(e); process.exit(1); });