openvibers/openvibe.network Commits

Export tokens (roadmap WS-N task 9): POST /api/v1/projects/:project/export-tokens { audience, env } mints, for the project's owner or an admin member only (developers, viewers and staff who are not an admin member are refused; archived projects can still be exported), a 5-minute read-only token with which OpenVibe.Codes downloads the project's Media objects and namespaces and its app events. Shaped as an app token so Media and Events accept it unchanged: sub app:app_<project ULID> (the project's export principal, never an app, never issued by /oauth/token), aud openvibe.media (media.object.list, media.object.read) or openvibe.events (events.app.read), ns [project_id, app.<project_id>.*], env as asked, on_behalf_of the person, purpose export. Every mint writes a dev_audit row (project.export_token_issued: audience, env, cap, jti, expiry); the token is never stored or logged. test/developer-export-tokens.test.js

OP OpenVibers committed 26 Sept 2026 · fbbfc975ad · 5 files, +298 −2


Changes

ddiff --git a/docs/developer-projects.md b/docs/developer-projects.md
iindex 2162e6fc..79a0b592 100644
---- a/docs/developer-projects.md
++++ b/docs/developer-projects.md
@@@ -31,6 +31,7 @@ What exists today: the data model, the `/api/v1/projects` API, app tokens from `
| Add or remove admins | | | | yes | |
| Read the audit | | | yes | yes | yes |
| Archive the project | | | | yes | yes |
++| Mint an export token (read-only, for OpenVibe.Codes' project export) | | | yes | yes | only as an admin member |
| Set the allowance, environment policy and quotas | | | | | yes |
Non-members get `404 project.not_found`, so a project's existence is not disclosed. Any member can leave, except the owner.
@@@ -87,6 +88,26 @@ The sandbox check works in two layers.
An audience opts in when it can keep sandbox traffic apart from real data: for example, test-flagged money in Billing (ADR-012 rule 9), or a sandbox tenant in Media. First-party service tokens carry no `env` and are treated as production. The three default audiences must accept sandbox tokens themselves. Issuance working does not mean they do. Media (sandbox tenants keyed by project id) and Events (env-marked app events) take that on in the same Wave 20 step. Tools must accept `env: sandbox` on `/api/v1/jobs`; until it does, a sandbox app's Tools token is refused with `401 token.sandbox_refused` there.
++### Export tokens
++
++A project's owner or admin exports everything the project holds on openvibe.codes (roadmap WS-N task 9). Codes calls `POST /api/v1/projects/:project/export-tokens { audience, env }` with the person's own token, once per audience and environment, and reads with what Network returns. Network checks the role at mint time; developers and viewers get `403 project.forbidden`, non-members `404`, and staff who are not an admin member of the project `403`. An archived project can still be exported.
++
++The token is shaped as an app token, so Media and Events accept it without changes:
++
++| Claim | Value |
++|---|---|
++| `sub` | `app:app_<the project's ULID>`: the project's export principal. No app has this id and `/oauth/token` never issues it, so a receiver log line naming it is an export of that project. |
++| `actor_type` | `app` |
++| `aud` | `[openvibe.media]` or `[openvibe.events]` (anything else is `422 export.invalid_audience`) |
++| `cap` | Media: `media.object.list`, `media.object.read`. Events: `events.app.read`. Never a write, publish or subscribe capability. |
++| `ns`, `project_id` | as for app tokens: `[project_id, app.<project_id>.*]` |
++| `env` | `sandbox` or `production`, as asked (anything else is `422 export.invalid_env`). Each receiver still decides whether it accepts `env: sandbox`. |
++| `on_behalf_of` | the person exporting |
++| `purpose` | `export` |
++| `iat`, `exp`, `jti` | 300-second lifetime, no refresh (Codes asks again during a long export) |
++
++The capabilities do not come from the project's grants or allowance. Those bound what the project's apps may do; an export token reads back what the project already holds, for its owner or admin. Every mint writes a `dev_audit` row, `project.export_token_issued`, with the audience, environment, capabilities, `jti` and expiry. The row is not a platform event, and the token itself is never stored or logged (`test/developer-export-tokens.test.js`).
++
### A new project without staff
With the defaults above, this works with no staff action:
@@@ -155,6 +176,7 @@ Base: `/api/v1/projects`. Every call needs `Authorization: Bearer <Network user
| `GET /:project/quotas` | viewer+ | |
| `PUT /:project/quotas/:capability` / `DELETE` | staff | `{ limit, window, unit }` |
| `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)) |
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.
ddiff --git a/package.json b/package.json
iindex 43ca2cb2..44c2f636 100644
---- a/package.json
++++ b/package.json
@@@ -6,7 +6,7 @@
"scripts": {
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
-- "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/github-integration.test.js && node test/revocation.test.js && node test/usernames.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-manifest-fixture.test.js && node test/staff-claims.test.js && node test/profile-events.test.js && node test/admin-role-events.test.js && node test/grants-admin.test.js && node test/events-ops.test.js && node test/deploy-drift.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js && node test/blocks.test.js && node test/site-config.test.js && node test/perf-budget.test.js && node test/shutdown.test.js && node test/loyalty-policy.test.js && node test/themes-review.test.js && node test/page-checks.test.js && node test/operator-checklist.test.js && node test/release-health.test.js",
++ "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/github-integration.test.js && node test/revocation.test.js && node test/usernames.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-manifest-fixture.test.js && node test/staff-claims.test.js && node test/profile-events.test.js && node test/admin-role-events.test.js && node test/grants-admin.test.js && node test/events-ops.test.js && node test/deploy-drift.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/developer-export-tokens.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js && node test/blocks.test.js && node test/site-config.test.js && node test/perf-budget.test.js && node test/shutdown.test.js && node test/loyalty-policy.test.js && node test/themes-review.test.js && node test/page-checks.test.js && node test/operator-checklist.test.js && node test/release-health.test.js",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
},
ddiff --git a/server/developer/routes.js b/server/developer/routes.js
iindex 17c2d8be..bdd58b90 100644
---- a/server/developer/routes.js
++++ b/server/developer/routes.js
@@@ -36,12 +36,15 @@
* PUT /:project/quotas/:capability { limit, window, unit } staff
* DELETE /:project/quotas/:capability staff
* GET /:project/audit[?before=&limit=] admin+ or staff
++ * POST /:project/export-tokens { audience, env } owner or admin member (not staff as such):
++ * a 5-minute read-only export token (tokens.js)
*/
const express = require('express');
const { http } = require('openvibe-contracts');
const { verifySession } = require('../auth/session');
const store = require('./store');
const policy = require('./policy');
++const tokens = require('./tokens');
function router() {
const r = express.Router();
@@@ -120,6 +123,11 @@ function router() {
return store.listAudit(db, project.id, { before: req.query.before, limit: req.query.limit });
}));
++ // Project export (WS-N task 9): Codes asks with the person's token, once per audience and env.
++ r.post('/:project/export-tokens', handle((db, a, req, o) => tokens.mintExportToken(db, a, req.params.project, req.body || {}, {
++ privateKey: req.app.locals.privateKey, issuer: req.app.locals.config.jwt.issuer, ctx: o.ctx,
++ }), 201));
++
// Malformed JSON and other body errors as problems too.
r.use((err, req, res, _next) => send(req, res, err));
return r;
ddiff --git a/server/developer/tokens.js b/server/developer/tokens.js
iindex dc5594a4..39f77711 100644
---- a/server/developer/tokens.js
++++ b/server/developer/tokens.js
@@@ -80,6 +80,70 @@ function mint({ app, project, audience, scope, limit, onBehalfOf, privateKey, is
return { status: 200, body: { access_token: serviceAuth.signServiceToken(claims, privateKey), token_type: 'Bearer', expires_in: TOKEN_TTL_S, scope: cap.join(' ') } };
}
++// ── Export tokens (roadmap WS-N task 9) ─────────────────────────
++
++/**
++ * What an export token may do at each audience: list and read, never write, publish or subscribe.
++ * Media checks media.object.list for its list verb (media.object.read grants it too, for receivers
++ * whose contracts do not know .list yet); Events answers the pull API to events.app.read.
++ */
++const EXPORT_CAPS = Object.freeze({
++ 'openvibe.media': Object.freeze(['media.object.list', 'media.object.read']),
++ 'openvibe.events': Object.freeze(['events.app.read']),
++});
++const EXPORT_PURPOSE = 'export';
++
++/**
++ * The project's export principal, app:app_<the project's ULID>. No dev_apps row carries it and
++ * /oauth/token never issues it; it exists only in export tokens, so every receiver log line that
++ * names it is an export of that project (the jti leads to the dev_audit row and the person).
++ */
++const exportSubject = (projectId) => `app:app_${String(projectId).replace(/^prj_/, '')}`;
++
++/**
++ * POST /api/v1/projects/:project/export-tokens { audience, env }: a read-only token with which
++ * OpenVibe.Codes downloads what the project holds in one service and environment (its Media objects
++ * and namespaces, its app events) for the person exporting it.
++ *
++ * Who: the project's owner or an admin member (Network's roles, checked here at mint time). Staff
++ * get none for projects they are not an admin of. Archived projects can still be exported.
++ *
++ * Shape: an app token (identity.service-token-claims@1) so Media and Events accept it unchanged:
++ * sub app:app_<project ULID> (exportSubject), actor_type app, aud [audience], cap EXPORT_CAPS,
++ * ns [project_id, app.<project_id>.*], project_id, env, on_behalf_of usr_<person>,
++ * purpose 'export', 5 minutes, no refresh (Codes asks again for a long export).
++ * The caps are not taken from the project's grants or allowance: those bound what the project's
++ * apps may do; this reads back what the project already holds, for its owner or admin.
++ *
++ * Every mint writes a dev_audit row (project.export_token_issued: audience, env, cap, jti, expiry);
++ * the token itself is never stored or logged. Returns the token response; throws DevError.
++ */
++function mintExportToken(db, actor, projectId, body, { privateKey, issuer, ctx }) {
++ const { project } = store.access(db, actor, projectId, { need: 'admin', staffOk: false, allowArchived: true });
++ const audience = String((body && body.audience) || '');
++ const cap = EXPORT_CAPS[audience];
++ if (!cap) throw new store.DevError(422, 'export.invalid_audience', `audience is one of ${Object.keys(EXPORT_CAPS).join(', ')}`);
++ const env = String((body && body.env) || '');
++ if (!policy.ENVIRONMENTS.includes(env)) throw new store.DevError(422, 'export.invalid_env', `env is one of ${policy.ENVIRONMENTS.join(', ')}`);
++ const now = Math.floor(Date.now() / 1000);
++ const claims = {
++ iss: issuer, sub: exportSubject(project.id), actor_type: 'app', aud: [audience], cap: [...cap], ns: projectNamespaces(project.id),
++ project_id: project.id, env, on_behalf_of: actor.subject, purpose: EXPORT_PURPOSE,
++ iat: now, exp: now + TOKEN_TTL_S, jti: `tok_${crypto.randomBytes(12).toString('hex')}`,
++ };
++ assertValid('identity.service-token-claims@1', claims);
++ const token = serviceAuth.signServiceToken(claims, privateKey);
++ const expiresAt = new Date(claims.exp * 1000).toISOString();
++ store.audit(db, {
++ projectId: project.id, actor: actor.label, action: 'project.export_token_issued', target: claims.sub,
++ detail: { audience, env, cap: claims.cap, jti: claims.jti, expires_at: expiresAt, purpose: EXPORT_PURPOSE }, ctx,
++ });
++ return {
++ access_token: token, token_type: 'Bearer', expires_in: TOKEN_TTL_S, expires_at: expiresAt, scope: claims.cap.join(' '),
++ audience, env, purpose: EXPORT_PURPOSE, subject: claims.sub, project_id: project.id, jti: claims.jti,
++ };
++}
++
/** Authenticate the client of a token request. Public apps present no secret. */
function authenticate(db, app, clientSecret) {
if (app.client_type === 'public') {
@@@ -167,4 +231,7 @@ function issueCode(db, { app, project, user, redirectUri, scope, challenge }) {
return { code };
}
--module.exports = { isAppClient, handleTokenRequest, checkAuthorizeRequest, issueCode, effectiveGrants, projectNamespaces, TOKEN_TTL_S };
++module.exports = {
++ isAppClient, handleTokenRequest, checkAuthorizeRequest, issueCode, effectiveGrants, projectNamespaces, TOKEN_TTL_S,
++ mintExportToken, exportSubject, EXPORT_CAPS, EXPORT_PURPOSE,
++};
ddiff --git a/test/developer-export-tokens.test.js b/test/developer-export-tokens.test.js
nnew file mode 100644
iindex 00000000..afd34d68
---- /dev/null
++++ b/test/developer-export-tokens.test.js
@@@ -0,0 +1,199 @@
++'use strict';
++// Export tokens (server/developer/tokens.js mintExportToken, roadmap WS-N task 9):
++// POST /api/v1/projects/:project/export-tokens { audience, env } mints a 5-minute, read-only token
++// shaped as an app token (sub app:app_<project ULID>, purpose export, on_behalf_of the person) for
++// the project's owner or an admin member only, and writes a dev_audit row for each one.
++// node test/developer-export-tokens.test.js
++const assert = require('assert');
++const crypto = require('crypto');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++const express = require('express');
++const jwt = require('jsonwebtoken');
++const { serviceAuth, validate, capabilities } = require('openvibe-contracts');
++const { initDb } = require('../server/db/database');
++const subjects = require('../server/identity/subjects');
++const tokens = require('../server/developer/tokens');
++
++const logged = [];
++const orig = { log: console.log, warn: console.warn, error: console.error };
++for (const k of ['log', 'warn', 'error']) console[k] = (...a) => { logged.push(a.map(String).join(' ')); };
++const out = (...a) => orig.log(...a);
++
++const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-exporttokens-'));
++const db = initDb(path.join(dir, 'network.db'));
++db.prepare(`INSERT INTO users (id, username, password_hash, role) VALUES
++ (10, 'owner', 'x', 'user'), (11, 'admin', 'x', 'user'), (12, 'dev', 'x', 'user'), (13, 'viewer', 'x', 'user'),
++ (14, 'stranger', 'x', 'user'), (15, 'staff', 'x', 'admin')`).run();
++const sid = (id) => subjects.ensureUserSubject(db, 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, internalKey: 'legacy-key',
++ jwt: { issuer: ISSUER, accessTokenExpiry: '1h' },
++ developer: { credentialOverlapS: 60 },
++};
++app.locals.privateKey = keys.privateKey;
++app.locals.publicKey = keys.publicKey;
++app.use('/oauth', require('../server/auth/oauth-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 T = { owner: userToken(10), admin: userToken(11), dev: userToken(12), viewer: userToken(13), stranger: userToken(14), staff: userToken(15) };
++const minted = [];
++
++(async () => {
++ await new Promise(r => server.listen(0, '127.0.0.1', r));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const api = async (who, method, p, body) => {
++ const h = { ...(who ? { authorization: `Bearer ${T[who] || who}` } : {}), ...(body ? { 'content-type': 'application/json' } : {}) };
++ const r = await fetch(`${base}/api/v1/projects${p}`, { method, headers: h, body: body ? JSON.stringify(body) : undefined });
++ const text = await r.text();
++ return { status: r.status, cache: r.headers.get('cache-control'), text, body: text ? JSON.parse(text) : null };
++ };
++ const mint = (who, project, body) => api(who, 'POST', `/${project}/export-tokens`, body);
++ const verify = (t, audience, o = {}) => serviceAuth.verifyServiceToken(t, { publicKey: keys.publicKey, issuer: ISSUER, audience, acceptSandbox: true, ...o });
++ const exportRows = () => db.prepare("SELECT * FROM dev_audit WHERE action = 'project.export_token_issued' ORDER BY id").all();
++
++ // A project with one member of each role, and a project the owner is not in.
++ let r = await api('owner', 'POST', '', { name: 'Exported' });
++ assert.strictEqual(r.status, 201, r.text);
++ const P = r.body.id;
++ for (const [who, role] of [['admin', 'admin'], ['dev', 'developer'], ['viewer', 'viewer']]) {
++ r = await api('owner', 'POST', `/${P}/members`, { username: who, role });
++ assert.strictEqual(r.status, 201, r.text);
++ }
++ r = await api('dev', 'POST', `/${P}/apps`, { name: 'Real app', environment: 'sandbox', type: 'confidential' });
++ assert.strictEqual(r.status, 201, r.text);
++ const realApp = r.body.id;
++ r = await api('stranger', 'POST', '', { name: 'Someone else' });
++ const Q = r.body.id;
++ const ulidOf = (prj) => prj.replace(/^prj_/, '');
++
++ // ── The owner: a Media token for production ──
++ r = await mint('owner', P, { audience: 'openvibe.media', env: 'production' });
++ assert.strictEqual(r.status, 201, r.text);
++ assert.match(r.cache, /no-store/);
++ minted.push(r.body.access_token);
++ let v = verify(r.body.access_token, 'openvibe.media');
++ assert.ok(v.ok, v.reason);
++ assert.ok(validate('identity.service-token-claims@1', v.claims).valid, 'shaped by the service-token contract');
++ assert.strictEqual(v.claims.sub, `app:app_${ulidOf(P)}`, 'the project\'s export principal');
++ assert.strictEqual(v.claims.sub, tokens.exportSubject(P));
++ assert.notStrictEqual(v.claims.sub, `app:${realApp}`, 'not one of the project\'s apps');
++ assert.strictEqual(v.claims.actor_type, 'app', 'receivers take it as an app of the project');
++ assert.deepStrictEqual(v.claims.aud, ['openvibe.media']);
++ assert.deepStrictEqual(v.claims.cap, ['media.object.list', 'media.object.read'], 'list and read only');
++ assert.deepStrictEqual(v.claims.ns, [P, `app.${P}.*`], 'the project\'s namespaces, both environments');
++ assert.strictEqual(v.claims.project_id, P);
++ assert.strictEqual(v.claims.env, 'production');
++ assert.strictEqual(v.claims.on_behalf_of, sid(10), 'the person exporting');
++ assert.strictEqual(v.claims.purpose, 'export');
++ assert.strictEqual(v.claims.exp - v.claims.iat, 300, 'five minutes');
++ assert.ok(Math.abs(v.claims.iat - Date.now() / 1000) < 5);
++ assert.strictEqual(r.body.token_type, 'Bearer');
++ assert.strictEqual(r.body.expires_in, 300);
++ assert.strictEqual(r.body.expires_at, new Date(v.claims.exp * 1000).toISOString());
++ assert.strictEqual(r.body.scope, 'media.object.list media.object.read');
++ assert.deepStrictEqual([r.body.audience, r.body.env, r.body.purpose, r.body.subject, r.body.project_id, r.body.jti],
++ ['openvibe.media', 'production', 'export', v.claims.sub, P, v.claims.jti]);
++ // It expires: five minutes (plus the receivers' 30 s clock skew) later it no longer verifies.
++ assert.strictEqual(verify(r.body.access_token, 'openvibe.media', { now: (v.claims.exp + 31) * 1000 }).code, 'token.expired');
++ assert.strictEqual(verify(r.body.access_token, 'openvibe.events').code, 'token.wrong_audience', 'one audience per token');
++ // No write capability rides along, whatever the project's grants.
++ for (const c of ['media.object.upload', 'media.object.delete', 'events.app.publish', 'events.app.subscribe']) {
++ assert.ok(!capabilities.grants(v.claims.cap, c), `${c} is not in an export token`);
++ }
++
++ // ── An admin: an Events token for the sandbox ──
++ r = await mint('admin', P, { audience: 'openvibe.events', env: 'sandbox' });
++ assert.strictEqual(r.status, 201, r.text);
++ minted.push(r.body.access_token);
++ v = verify(r.body.access_token, 'openvibe.events');
++ assert.ok(v.ok, v.reason);
++ assert.deepStrictEqual(v.claims.cap, ['events.app.read']);
++ assert.strictEqual(v.claims.env, 'sandbox');
++ assert.strictEqual(v.claims.on_behalf_of, sid(11));
++ assert.strictEqual(verify(r.body.access_token, 'openvibe.events', { acceptSandbox: false }).code, 'token.sandbox_refused',
++ 'a receiver that did not opt in to sandbox tokens still refuses it');
++
++ // ── Everyone else is refused, and nothing is minted or audited for them ──
++ const before = exportRows().length;
++ r = await mint('dev', P, { audience: 'openvibe.media', env: 'sandbox' });
++ assert.strictEqual(r.status, 403); assert.strictEqual(r.body.code, 'project.forbidden', 'a developer may not export');
++ r = await mint('viewer', P, { audience: 'openvibe.media', env: 'sandbox' });
++ assert.strictEqual(r.status, 403); assert.strictEqual(r.body.code, 'project.forbidden', 'a viewer may not export');
++ r = await mint('stranger', P, { audience: 'openvibe.media', env: 'sandbox' });
++ assert.strictEqual(r.status, 404); assert.strictEqual(r.body.code, 'project.not_found', 'a non-member learns nothing');
++ r = await mint('staff', P, { audience: 'openvibe.media', env: 'sandbox' });
++ assert.strictEqual(r.status, 403, 'staff who are not an admin of the project get no export token');
++ r = await mint('owner', Q, { audience: 'openvibe.media', env: 'sandbox' });
++ assert.strictEqual(r.status, 404, 'the owner of one project gets nothing for another');
++ r = await mint('owner', 'prj_NOTAPROJECT', { audience: 'openvibe.media', env: 'sandbox' });
++ assert.strictEqual(r.status, 404);
++ r = await mint(null, P, { audience: 'openvibe.media', env: 'sandbox' });
++ assert.strictEqual(r.status, 401, 'a person\'s token is required');
++ r = await mint(minted[0], P, { audience: 'openvibe.media', env: 'sandbox' });
++ assert.strictEqual(r.status, 401, 'an export token is not a user: it cannot mint another');
++ for (const [body, code] of [
++ [{ audience: 'openvibe.tools', env: 'sandbox' }, 'export.invalid_audience'],
++ [{ audience: 'openvibe.network', env: 'production' }, 'export.invalid_audience'],
++ [{ env: 'production' }, 'export.invalid_audience'],
++ [{ audience: 'openvibe.media', env: 'staging' }, 'export.invalid_env'],
++ [{ audience: 'openvibe.media' }, 'export.invalid_env'],
++ ]) {
++ r = await mint('owner', P, body);
++ assert.strictEqual(r.status, 422, JSON.stringify(body));
++ assert.strictEqual(r.body.code, code, JSON.stringify(body));
++ }
++ assert.strictEqual(exportRows().length, before, 'refusals write no export audit row');
++
++ // ── The export principal opens nothing else ──
++ r = await api(minted[0], 'GET', `/${P}`);
++ assert.strictEqual(r.status, 401, 'not a user at Network');
++ const tok = await fetch(`${base}/oauth/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' },
++ body: new URLSearchParams({ grant_type: 'client_credentials', client_id: `app_${ulidOf(P)}`, client_secret: 'ovsec_x', audience: 'openvibe.media' }) });
++ assert.strictEqual(tok.status, 401, '/oauth/token never issues the export principal a token');
++ r = await api('owner', 'GET', `/${P}/apps`);
++ assert.deepStrictEqual(r.body.apps.map(a => a.id), [realApp], 'it is not listed as an app');
++
++ // ── The audit: one row per token, with who, what and until when; never the token ──
++ let rows = exportRows();
++ assert.strictEqual(rows.length, 2);
++ assert.deepStrictEqual(rows.map(x => [x.project_id, x.actor, x.target]),
++ [[P, `user:${sid(10)}`, tokens.exportSubject(P)], [P, `user:${sid(11)}`, tokens.exportSubject(P)]]);
++ const d0 = JSON.parse(rows[0].detail);
++ const c0 = verify(minted[0], 'openvibe.media').claims;
++ assert.deepStrictEqual(d0, { audience: 'openvibe.media', env: 'production', cap: ['media.object.list', 'media.object.read'], jti: c0.jti,
++ expires_at: new Date(c0.exp * 1000).toISOString(), purpose: 'export' });
++ assert.ok(rows.every(x => x.event_type === null), 'an audit row, not a platform event');
++ r = await api('owner', 'GET', `/${P}/audit`);
++ assert.ok(r.body.entries.some(e => e.action === 'project.export_token_issued' && e.detail.jti === c0.jti), 'members with the audit role see it');
++
++ // ── An archived project can still be exported by its owner ──
++ r = await api('owner', 'POST', `/${P}/archive`);
++ assert.strictEqual(r.status, 200, r.text);
++ r = await mint('owner', P, { audience: 'openvibe.events', env: 'production' });
++ assert.strictEqual(r.status, 201, r.text);
++ minted.push(r.body.access_token);
++ assert.strictEqual(exportRows().length, 3);
++
++ // ── Tokens are never stored or logged ──
++ const tables = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map(x => x.name);
++ for (const t of minted) {
++ const sig = t.split('.')[2];
++ for (const name of tables) assert.ok(!JSON.stringify(db.prepare(`SELECT * FROM "${name}"`).all()).includes(sig), `a token in ${name}`);
++ assert.ok(!logged.some(l => l.includes(sig)), 'a token in the logs');
++ }
++
++ out('export tokens: all checks passed');
++ server.close();
++})().catch(err => { orig.error(err); process.exit(1); });