openvibers/openvibe.network Commits

T2 lane B step 3: implement GET /internal/projects/:projectid under… (#28)

OP OpenVibers committed 3 Oct 2026 · e422342ebf · 6 files, +227 −5

local checks ✓ 3/3 · CI ✓ 3/3 · review SHIP by DeepSeek harness

https://github.com/OpenVibers/OpenVibe.Network/pull/28

Changes

ddiff --git a/docs/developer-projects.md b/docs/developer-projects.md
iindex b92092a7..b0f4db61 100644
---- a/docs/developer-projects.md
++++ b/docs/developer-projects.md
@@@ -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.
ddiff --git a/docs/t2-projects-and-grants.md b/docs/t2-projects-and-grants.md
iindex 3ae5977e..fc1e8a42 100644
---- a/docs/t2-projects-and-grants.md
++++ b/docs/t2-projects-and-grants.md
@@@ -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)
ddiff --git a/server/developer/store.js b/server/developer/store.js
iindex db7d5fd9..f67ff8e2 100644
---- a/server/developer/store.js
++++ b/server/developer/store.js
@@@ -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,
ddiff --git a/server/identity/principals.js b/server/identity/principals.js
iindex 66ec18f6..ff820456 100644
---- a/server/identity/principals.js
++++ b/server/identity/principals.js
@@@ -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
ddiff --git a/server/internal/routes.js b/server/internal/routes.js
iindex b140342a..a31e4061 100644
---- a/server/internal/routes.js
++++ b/server/internal/routes.js
@@@ -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;
ddiff --git a/test/internal-projects.test.js b/test/internal-projects.test.js
nnew file mode 100644
iindex 00000000..1404c7cc
---- /dev/null
++++ b/test/internal-projects.test.js
@@@ -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); });