openvibers/openvibe.network Commits

Developer projects foundation (Wave 20, ADR-014): projects, members, apps, hashed credentials, grants within a staff-set allowance, quotas, append-only audit

OP OpenVibers committed 23 Sept 2026 · 1fc254a341 · 12 files, +1531 −13

- dev_projects / dev_project_members / dev_apps / dev_credentials / dev_grants / dev_quotas /
  dev_auth_codes / dev_audit (UPDATE/DELETE refused by triggers)
- /api/v1/projects: Bearer user tokens only (no cookies, never X-Internal-Key), role-checked,
  problem+json with request ids; staff set allowance, environment policy and quotas
- Apps are OAuth clients whose client_id is their app_<ULID> subject, kept out of oauth_clients.
  client_credentials (confidential) or authorization code + PKCE S256 (required for every app);
  tokens: sub app:<id>, project_id, env, ns [project_id], cap = approved grants within the
  allowance that are still grantable; 5 minutes, no refresh tokens
- Only active public capabilities are grantable (partner accepted once contracts adds it);
  first-party and internal never
- Secrets: sha256 only, shown once; rotation overlap (DEV_CREDENTIAL_OVERLAP_S), immediate revoke
- Sandbox: tokens only for DEV_SANDBOX_AUDIENCES; Network's capability guard refuses env=sandbox
- network.app.* / network.credential.* / network.grant.changed recorded as event envelopes in
  dev_audit (Network has no Events producer yet)

Changes

ddiff --git a/.env.example b/.env.example
iindex 046d0fd8..0076ae0d 100644
---- a/.env.example
++++ b/.env.example
@@@ -20,6 +20,16 @@ INTERNAL_API_KEY=change-me-to-a-random-64-char-string
SETUP_TOKEN=change-me-to-a-second-random-secret
BOOTSTRAP_PROFILE=local-dev
++# Developer projects (docs/developer-projects.md)
++# Audiences that accept sandbox app tokens, comma-separated (e.g. openvibe.media). Empty = none.
++DEV_SANDBOX_AUDIENCES=
++# Seconds a rotated client secret keeps working (0..604800; default 86400)
++DEV_CREDENTIAL_OVERLAP_S=86400
++# Public capabilities every new project's allowance starts with, comma-separated. Empty = staff grant each one.
++DEV_DEFAULT_ALLOWANCE=
++DEV_MAX_PROJECTS_PER_OWNER=10
++DEV_MAX_APPS_PER_PROJECT=20
++
# Admin account auto-created on first run
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-me
ddiff --git a/package.json b/package.json
iindex 34d5999c..e32e107b 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/chrome.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/registry-ecosystem.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/observability.test.js && node test/status.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/chrome.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/registry-ecosystem.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/observability.test.js && node test/status.test.js",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
},
ddiff --git a/server/auth/oauth-routes.js b/server/auth/oauth-routes.js
iindex 20fccdd5..f02efe14 100644
---- a/server/auth/oauth-routes.js
++++ b/server/auth/oauth-routes.js
@@@ -10,6 +10,7 @@ const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const subjects = require('../identity/subjects');
const principals = require('../identity/principals');
++const devTokens = require('../developer/tokens');
const router = express.Router();
function getDb(req) { return req.app.locals.db; }
@@@ -55,6 +56,19 @@ router.get('/authorize', (req, res) => {
return res.status(400).json({ error: 'client_id and redirect_uri are required' });
}
++ // Developer apps (app_<ULID>, server/developer): exact redirect URI, PKCE S256 always, no silent
++ // prompt=none (a third-party app never gets a code without the person choosing to continue).
++ if (devTokens.isAppClient(client_id)) {
++ const found = devTokens.checkAuthorizeRequest(db, req.query);
++ if (found.error) return res.status(400).json({ error: found.pkce ? 'invalid_request' : found.error, error_description: found.error });
++ if (String(req.query.prompt || '') === 'none') {
++ return res.redirect(`${redirect_uri}${redirect_uri.includes('?') ? '&' : '?'}error=interaction_required&state=${encodeURIComponent(state || '')}`);
++ }
++ const params = new URLSearchParams({ client_id, client_name: `${found.app.name} (third-party app)`, redirect_uri, response_type, scope: scope || '', state: state || '',
++ code_challenge: String(req.query.code_challenge), code_challenge_method: 'S256' });
++ return res.redirect(`${getConfig(req).loginUrl}/login?${params.toString()}`);
++ }
++
// Validate client
const client = db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get(client_id);
if (!client) return res.status(400).json({ error: 'Unknown client_id' });
@@@ -111,17 +125,22 @@ router.post('/confirm', (req, res) => {
return res.status(400).json({ error: 'token, client_id, and redirect_uri are required' });
}
-- // Validate client
-- const client = db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get(client_id);
-- if (!client) return res.status(400).json({ error: 'Unknown client_id' });
--
-- const allowedUris = JSON.parse(client.redirect_uris || '[]');
-- const uriLower = redirect_uri.toLowerCase();
-- if (!allowedUris.some(u => u.toLowerCase() === uriLower)) {
-- return res.status(400).json({ error: 'Invalid redirect_uri' });
++ const devApp = devTokens.isAppClient(client_id) ? devTokens.checkAuthorizeRequest(db, req.body) : null;
++ if (devApp && devApp.error) return res.status(400).json({ error: devApp.pkce ? 'invalid_request' : devApp.error, error_description: devApp.error });
++ let pkce = null;
++ if (!devApp) {
++ // Validate client
++ const client = db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get(client_id);
++ if (!client) return res.status(400).json({ error: 'Unknown client_id' });
++
++ const allowedUris = JSON.parse(client.redirect_uris || '[]');
++ const uriLower = redirect_uri.toLowerCase();
++ if (!allowedUris.some(u => u.toLowerCase() === uriLower)) {
++ return res.status(400).json({ error: 'Invalid redirect_uri' });
++ }
++ pkce = readChallenge(req.body);
++ if (pkce.error) return res.status(400).json({ error: 'invalid_request', error_description: pkce.error });
}
-- const pkce = readChallenge(req.body);
-- if (pkce.error) return res.status(400).json({ error: 'invalid_request', error_description: pkce.error });
// Verify the token
const publicKey = req.app.locals.publicKey;
@@@ -138,6 +157,13 @@ router.post('/confirm', (req, res) => {
return res.status(403).json({ error: 'User not found or banned' });
}
++ if (devApp) {
++ const out = devTokens.issueCode(db, { app: devApp.app, project: devApp.project, user, redirectUri: redirect_uri, scope, challenge: String(req.body.code_challenge) });
++ if (out.error) return res.status(out.status).json({ error: out.error });
++ require('./session').setSessionCookies(res, token);
++ return res.json({ redirect: withCode(redirect_uri, out.code, state) });
++ }
++
// Issue authorization code (bound to the PKCE challenge when the client sent one)
const code = issueCode(db, { clientId: client_id, userId: user.id, redirectUri: redirect_uri, scope, pkce });
@@@ -156,6 +182,13 @@ router.post('/token', (req, res) => {
const config = getConfig(req);
const { grant_type, client_id, client_secret, code, redirect_uri, refresh_token } = req.body;
++ // Developer apps (app_<ULID>): client_credentials or authorization_code + PKCE, own credential store.
++ if (devTokens.isAppClient(client_id)) {
++ const out = devTokens.handleTokenRequest(db, req.body, { privateKey: req.app.locals.privateKey, issuer: config.jwt.issuer, config });
++ res.set('Cache-Control', 'no-store');
++ return res.status(out.status).json(out.body);
++ }
++
// Service principals (Wave 1): a first-party service trades its client credentials for a
// short-lived, capability-scoped token. Checked before the user-grant path below.
if (grant_type === 'client_credentials') {
@@@ -353,11 +386,11 @@ router.get('/.well-known/openid-configuration', (req, res) => {
userinfo_endpoint: `${config.baseUrl}/api/auth/me`,
jwks_uri: `${config.baseUrl}/api/.well-known/jwks`,
response_types_supported: ['code'],
-- grant_types_supported: ['authorization_code', 'refresh_token'],
++ grant_types_supported: ['authorization_code', 'refresh_token', 'client_credentials'],
subject_types_supported: ['public'],
id_token_signing_alg_values_supported: ['RS256'],
scopes_supported: ['profile', 'theme', 'openid'],
-- token_endpoint_auth_methods_supported: ['client_secret_post'],
++ token_endpoint_auth_methods_supported: ['client_secret_post', 'none'],
code_challenge_methods_supported: ['S256'],
});
});
ddiff --git a/server/config.js b/server/config.js
iindex 9bc014aa..5847cb35 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -28,6 +28,19 @@ module.exports = {
// Internal API key for server-to-server calls (X-Internal-Key)
internalKey: process.env.INTERNAL_API_KEY || 'change-me-in-production',
++ // Developer projects (server/developer, ADR-014)
++ developer: {
++ // Audiences that accept sandbox app tokens (comma list, e.g. openvibe.media). Empty = none:
++ // sandbox apps then get no tokens, and every receiver refuses env=sandbox.
++ sandboxAudiences: process.env.DEV_SANDBOX_AUDIENCES || '',
++ // How long a rotated client secret keeps working (seconds, 0..604800).
++ credentialOverlapS: process.env.DEV_CREDENTIAL_OVERLAP_S || 86400,
++ // Capabilities every new project's allowance starts with (public ones only; comma list).
++ defaultAllowance: process.env.DEV_DEFAULT_ALLOWANCE || '',
++ maxProjectsPerOwner: process.env.DEV_MAX_PROJECTS_PER_OWNER || 10,
++ maxAppsPerProject: process.env.DEV_MAX_APPS_PER_PROJECT || 20,
++ },
++
// Database
db: {
path: process.env.DB_PATH || './data/network.db',
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 5b179646..f02df929 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -801,6 +801,8 @@ function initDb(dbPath) {
require('../identity/principals').ensureSchema(db);
// Versioned user modules (server/identity/modules.js).
require('../identity/modules').ensureSchema(db);
++ // Developer projects, apps, credentials, grants, quotas and audit (server/developer/store.js).
++ require('../developer/store').ensureSchema(db);
console.log('[DB] Central database initialized');
return db;
ddiff --git a/server/developer/policy.js b/server/developer/policy.js
nnew file mode 100644
iindex 00000000..a258cdc2
---- /dev/null
++++ b/server/developer/policy.js
@@@ -0,0 +1,80 @@
++'use strict';
++/**
++ * Developer-project policy (roadmap Wave 20, ADR-014): which capabilities a third-party app may ever
++ * hold, which audience a capability belongs to, and whether a receiver accepts a sandbox token.
++ *
++ * Grantability comes from the capability's `visibility` in openvibe-contracts:
++ * public grantable to apps (may appear in a project's allowance, including a default one)
++ * partner (proposed; not in the contracts enum yet) grantable only when staff put it in one
++ * project's allowance by hand, never through the default allowance
++ * first-party never grantable to apps: only first-party service principals hold these
++ * internal never grantable to apps
++ * A capability must also be `active` (not planned, deprecated or retired) to be granted.
++ */
++const { capabilities } = require('openvibe-contracts');
++
++const GRANTABLE_VISIBILITIES = new Set(['public', 'partner']);
++const DEFAULT_ALLOWANCE_VISIBILITIES = new Set(['public']);
++const ENVIRONMENTS = ['sandbox', 'production'];
++const ENVIRONMENT_POLICIES = { sandbox: ['sandbox'], 'sandbox+production': ['sandbox', 'production'] };
++
++/** { grantable, code, reason } for one capability id. */
++function grantability(id) {
++ const cap = capabilities.get(String(id || ''));
++ if (!cap) return { grantable: false, code: 'grant.unknown_capability', reason: `no capability ${id} in the catalog` };
++ if (!GRANTABLE_VISIBILITIES.has(cap.visibility)) return { grantable: false, code: 'grant.not_grantable', reason: `${id} is ${cap.visibility}; only public capabilities are granted to apps` };
++ if (cap.status !== 'active') return { grantable: false, code: 'grant.not_grantable', reason: `${id} is ${cap.status}` };
++ return { grantable: true, code: null, reason: null, capability: cap };
++}
++
++const isGrantable = (id) => grantability(id).grantable;
++
++/** The audience a capability is invoked at: its owning service (openvibe.<owner>). */
++function audienceOf(id) {
++ const cap = capabilities.get(String(id || ''));
++ return cap ? `openvibe.${cap.owner}` : null;
++}
++
++/** Capabilities an app could ever be granted (the catalog view Codes shows). */
++function grantableCatalog() {
++ return capabilities.manifests.filter(c => isGrantable(c.id)).map(c => ({
++ id: c.id, owner: c.owner, audience: `openvibe.${c.owner}`, visibility: c.visibility, description: c.description || '',
++ resourceConstraints: c.resourceConstraints, quotaClass: c.quotaClass,
++ }));
++}
++
++/** Developer settings with defaults (config.developer is optional so tests can omit it). */
++function settings(config) {
++ const d = (config && config.developer) || {};
++ const list = (v) => (Array.isArray(v) ? v : String(v || '').split(',')).map(s => String(s).trim()).filter(Boolean);
++ const num = (v, dflt, min, max) => { const n = Number(v); return Number.isFinite(n) ? Math.min(max, Math.max(min, Math.floor(n))) : dflt; };
++ return {
++ sandboxAudiences: new Set(list(d.sandboxAudiences)),
++ credentialOverlapS: num(d.credentialOverlapS, 86400, 0, 7 * 86400),
++ defaultAllowance: list(d.defaultAllowance).filter(id => grantability(id).grantable && DEFAULT_ALLOWANCE_VISIBILITIES.has(capabilities.get(id).visibility)),
++ maxProjectsPerOwner: num(d.maxProjectsPerOwner, 10, 1, 1000),
++ maxAppsPerProject: num(d.maxAppsPerProject, 20, 1, 1000),
++ };
++}
++
++/**
++ * Receiver-side environment check. Tokens without `env` (first-party service principals) and
++ * `env: production` pass; `env: sandbox` passes only when this audience opted in.
++ * Returns { ok } or { ok: false, code, reason } (code token.sandbox_refused -> 401).
++ */
++function environmentDecision(claims, { acceptSandbox = false } = {}) {
++ const env = claims ? claims.env : undefined;
++ if (env === undefined || env === 'production') return { ok: true };
++ if (env === 'sandbox') return acceptSandbox ? { ok: true } : { ok: false, code: 'token.sandbox_refused', reason: 'sandbox tokens are not accepted by this audience' };
++ return { ok: false, code: 'token.invalid_claims', reason: `unknown env ${env}` };
++}
++
++/** Claims of a JWT whose signature the caller already verified. */
++function unverifiedClaims(token) {
++ try { return JSON.parse(Buffer.from(String(token).split('.')[1], 'base64url').toString('utf8')); } catch { return null; }
++}
++
++module.exports = {
++ GRANTABLE_VISIBILITIES, ENVIRONMENTS, ENVIRONMENT_POLICIES,
++ grantability, isGrantable, audienceOf, grantableCatalog, settings, environmentDecision, unverifiedClaims,
++};
ddiff --git a/server/developer/routes.js b/server/developer/routes.js
nnew file mode 100644
iindex 00000000..ecb50074
---- /dev/null
++++ b/server/developer/routes.js
@@@ -0,0 +1,133 @@
++'use strict';
++/**
++ * /api/v1/projects — developer projects for signed-in people (roadmap Wave 20 foundation, ADR-014).
++ * The API Codes (the developer portal) calls with the user's Network access token.
++ *
++ * Authentication: `Authorization: Bearer <Network user access token>` only. Cookies are not read
++ * (no CSRF surface), X-Internal-Key is never accepted, and service or app tokens are not users.
++ * Errors are RFC 9457 problems; every response carries X-OpenVibe-Request-Id.
++ *
++ * GET /catalog capabilities apps may ever be granted
++ * POST / { name } create (caller becomes owner)
++ * GET /[?all=1] mine (staff: all=1 lists every project)
++ * GET /:project viewer+ (non-members: 404)
++ * PATCH /:project { name } admin+
++ * POST /:project/archive owner or staff; revokes every app
++ * PUT /:project/allowance { capabilities: [] } staff
++ * PUT /:project/environment-policy { environment_policy } staff (sandbox | sandbox+production)
++ * GET /:project/members viewer+
++ * POST /:project/members { username | subject_id, role } admin+ (owner for admins)
++ * PATCH /:project/members/:subject { role } admin+ (owner for admins)
++ * DELETE /:project/members/:subject admin+, or yourself
++ * GET /:project/apps viewer+
++ * POST /:project/apps { name, environment, type, redirect_uris } developer+ (admin+ production)
++ * GET /:project/apps/:app
++ * PATCH /:project/apps/:app { name, redirect_uris }
++ * DELETE /:project/apps/:app revoke (developer+ sandbox, admin+ production, staff)
++ * GET /:project/apps/:app/credentials metadata only, never secrets
++ * POST /:project/apps/:app/credentials/rotate { overlap_seconds } new secret, shown once
++ * POST /:project/apps/:app/credentials/:credential/revoke immediate
++ * GET /:project/apps/:app/grants
++ * POST /:project/apps/:app/grants { capability } developer+ requests; owner/admin get it approved
++ * POST /:project/apps/:app/grants/:capability/approve owner/admin, inside the allowance only
++ * POST /:project/apps/:app/grants/:capability/deny owner/admin
++ * DELETE /:project/apps/:app/grants/:capability owner/admin or staff
++ * GET /:project/quotas viewer+
++ * PUT /:project/quotas/:capability { limit, window, unit } staff
++ * DELETE /:project/quotas/:capability staff
++ * GET /:project/audit[?before=&limit=] admin+ or staff
++ */
++const express = require('express');
++const { http } = require('openvibe-contracts');
++const { verifySession } = require('../auth/session');
++const store = require('./store');
++const policy = require('./policy');
++
++function router() {
++ const r = express.Router();
++ r.use(http.middleware());
++ r.use(express.json({ limit: '64kb' }));
++ r.use((req, res, next) => { res.set('Cache-Control', 'private, no-store'); next(); });
++
++ // Bearer user tokens only. An expired token is refused here even inside the session grace period:
++ // this API hands out secrets, so it does not slide sessions.
++ r.use((req, res, next) => {
++ const h = String(req.headers.authorization || '');
++ if (!h.startsWith('Bearer ')) return http.sendProblem(res, 401, 'auth.required', { detail: 'send Authorization: Bearer <Network access token>', ctx: req.ov });
++ const { db, publicKey, config } = req.app.locals;
++ const out = verifySession(h.slice(7).trim(), { db, publicKey, config });
++ if (out.error) return http.sendProblem(res, out.status === 403 ? 403 : 401, out.status === 403 ? 'auth.banned' : 'auth.invalid', { detail: out.error, ctx: req.ov });
++ if (out.decoded && typeof out.decoded.exp === 'number' && out.decoded.exp * 1000 < Date.now()) {
++ return http.sendProblem(res, 401, 'auth.expired', { detail: 'access token expired; refresh it', ctx: req.ov });
++ }
++ try { req.actor = store.actorOf(db, out.user); } catch (err) { return send(req, res, err); }
++ next();
++ });
++
++ const ctxOf = (req) => ({ ctx: req.ov, settings: policy.settings(req.app.locals.config) });
++ const handle = (fn, status = 200) => (req, res) => {
++ try {
++ const out = fn(req.app.locals.db, req.actor, req, ctxOf(req));
++ if (out === undefined) return res.status(204).end();
++ res.status(status).json(out);
++ } catch (err) { send(req, res, err); }
++ };
++
++ r.get('/catalog', (req, res) => res.json({ capabilities: policy.grantableCatalog(), rule: 'only active capabilities with visibility public (or partner, by staff allowance) are grantable to apps' }));
++
++ r.post('/', handle((db, a, req, o) => store.createProject(db, a, req.body || {}, o), 201));
++ r.get('/', handle((db, a, req) => ({ projects: store.listProjects(db, a, { all: req.query.all === '1' }) })));
++ r.get('/:project', handle((db, a, req) => {
++ const { project, role } = store.access(db, a, req.params.project, { allowArchived: true });
++ return store.projectView(db, project, role);
++ }));
++ r.patch('/:project', handle((db, a, req, o) => store.renameProject(db, a, req.params.project, req.body || {}, o)));
++ r.post('/:project/archive', handle((db, a, req, o) => store.archiveProject(db, a, req.params.project, o)));
++ r.put('/:project/allowance', handle((db, a, req, o) => store.setAllowance(db, a, req.params.project, req.body || {}, o)));
++ r.put('/:project/environment-policy', handle((db, a, req, o) => store.setEnvironmentPolicy(db, a, req.params.project, req.body || {}, o)));
++
++ r.get('/:project/members', handle((db, a, req) => ({ members: store.listMembers(db, a, req.params.project) })));
++ r.post('/:project/members', handle((db, a, req, o) => store.addMember(db, a, req.params.project, req.body || {}, o), 201));
++ r.patch('/:project/members/:subject', handle((db, a, req, o) => store.updateMember(db, a, req.params.project, req.params.subject, req.body || {}, o)));
++ r.delete('/:project/members/:subject', handle((db, a, req, o) => store.removeMember(db, a, req.params.project, req.params.subject, o)));
++
++ r.get('/:project/apps', handle((db, a, req) => ({ apps: store.listApps(db, a, req.params.project) })));
++ r.post('/:project/apps', handle((db, a, req, o) => store.createApp(db, a, req.params.project, req.body || {}, o), 201));
++ r.get('/:project/apps/:app', handle((db, a, req) => store.getApp(db, a, req.params.project, req.params.app)));
++ r.patch('/:project/apps/:app', handle((db, a, req, o) => store.updateApp(db, a, req.params.project, req.params.app, req.body || {}, o)));
++ r.delete('/:project/apps/:app', handle((db, a, req, o) => store.revokeApp(db, a, req.params.project, req.params.app, o)));
++
++ r.get('/:project/apps/:app/credentials', handle((db, a, req) => ({ credentials: store.listCredentials(db, a, req.params.project, req.params.app) })));
++ r.post('/:project/apps/:app/credentials/rotate', handle((db, a, req, o) => store.rotateCredential(db, a, req.params.project, req.params.app, req.body || {}, o), 201));
++ r.post('/:project/apps/:app/credentials/:credential/revoke', handle((db, a, req, o) => store.revokeCredential(db, a, req.params.project, req.params.app, req.params.credential, o)));
++
++ r.get('/:project/apps/:app/grants', handle((db, a, req) => ({ grants: store.listGrants(db, a, req.params.project, req.params.app) })));
++ r.post('/:project/apps/:app/grants', handle((db, a, req, o) => store.requestGrant(db, a, req.params.project, req.params.app, req.body || {}, o), 201));
++ r.post('/:project/apps/:app/grants/:capability/approve', handle((db, a, req, o) => store.decideGrant(db, a, req.params.project, req.params.app, req.params.capability, 'approved', o)));
++ r.post('/:project/apps/:app/grants/:capability/deny', handle((db, a, req, o) => store.decideGrant(db, a, req.params.project, req.params.app, req.params.capability, 'denied', o)));
++ r.delete('/:project/apps/:app/grants/:capability', handle((db, a, req, o) => store.decideGrant(db, a, req.params.project, req.params.app, req.params.capability, 'revoked', o)));
++
++ r.get('/:project/quotas', handle((db, a, req) => ({ quotas: store.listQuotas(db, a, req.params.project), note: 'quotas are enforced by the service that owns each capability; Network records and exposes them' })));
++ r.put('/:project/quotas/:capability', handle((db, a, req, o) => store.setQuota(db, a, req.params.project, req.params.capability, req.body || {}, o)));
++ r.delete('/:project/quotas/:capability', handle((db, a, req, o) => store.deleteQuota(db, a, req.params.project, req.params.capability, o)));
++
++ r.get('/:project/audit', handle((db, a, req) => {
++ const { project } = store.access(db, a, req.params.project, { need: 'admin', allowArchived: true });
++ return store.listAudit(db, project.id, { before: req.query.before, limit: req.query.limit });
++ }));
++
++ // Malformed JSON and other body errors as problems too.
++ r.use((err, req, res, _next) => send(req, res, err));
++ return r;
++}
++
++function send(req, res, err) {
++ if (err instanceof store.DevError) return http.sendProblem(res, err.status, err.code, { detail: err.detail, ctx: req.ov });
++ if (err && err.type === 'entity.parse.failed') return http.sendProblem(res, 400, 'request.malformed_json', { detail: 'body is not valid JSON', ctx: req.ov });
++ if (err && err.type === 'entity.too.large') return http.sendProblem(res, 413, 'request.too_large', { ctx: req.ov });
++ // Never echo internals (and so never a secret) to the client.
++ console.error('[developer] unexpected error:', err && err.code ? err.code : '', err && err.message ? err.message.slice(0, 200) : err);
++ return http.sendProblem(res, 500, 'internal.error', { detail: 'unexpected error', ctx: req.ov });
++}
++
++module.exports = { router };
ddiff --git a/server/developer/store.js b/server/developer/store.js
nnew file mode 100644
iindex 00000000..09133612
---- /dev/null
++++ b/server/developer/store.js
@@@ -0,0 +1,709 @@
++'use strict';
++/**
++ * Developer projects (roadmap Wave 20 foundation, ADR-014): projects, members, apps, credentials,
++ * grants, quotas and an append-only audit, all owned by Network.
++ *
++ * - A project has one owner subject (usr_), members with roles, apps, an environment policy, and an
++ * allowance: the capabilities staff let its apps hold. Tenancy elsewhere is keyed by project_id.
++ * - An app is an OAuth client whose client_id IS its subject id (app_<ULID>); it is kept apart from
++ * oauth_clients (first-party sites and service principals) so it never inherits first-party trust.
++ * - Client secrets are stored only as SHA-256 hashes (256-bit random secrets, so a fast hash is
++ * enough) and returned once, at creation or rotation. Rotation keeps the previous secret valid for
++ * an overlap; revocation is immediate.
++ * - Grants come only from the capability catalog and never exceed the allowance: approval checks
++ * it, shrinking the allowance revokes what falls outside, and token issuance intersects again.
++ * - Quotas are recorded and exposed here; the owning service enforces them.
++ * - dev_audit is append-only (triggers refuse UPDATE/DELETE). Rows that are platform events carry an
++ * event envelope (events.event-envelope@1).
++ * TODO(events): Network has no Events producer yet. When it gets one, relay dev_audit rows with an
++ * event envelope to OpenVibe.Events in id order (outbox semantics, keyed by event_id) instead of
++ * adding a second write path.
++ */
++const crypto = require('crypto');
++const { ids, validate } = require('openvibe-contracts');
++const subjects = require('../identity/subjects');
++const policy = require('./policy');
++
++const ROLES = ['viewer', 'developer', 'admin', 'owner'];
++const RANK = { viewer: 1, developer: 2, admin: 3, owner: 4 };
++const CLIENT_TYPES = ['confidential', 'public'];
++const QUOTA_WINDOWS = ['minute', 'hour', 'day', 'month', 'total'];
++const APP_ID_RE = /^app_[0-9A-HJKMNP-TV-Z]{26}$/;
++const PROJECT_ID_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
++const CRED_ID_RE = /^crd_[0-9A-HJKMNP-TV-Z]{26}$/;
++const USER_SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++const SECRET_PREFIX = 'ovsec_';
++
++class DevError extends Error {
++ constructor(status, code, detail) { super(detail || code); this.status = status; this.code = code; this.detail = detail; }
++}
++const fail = (status, code, detail) => { throw new DevError(status, code, detail); };
++const nowIso = () => new Date().toISOString();
++
++function ensureSchema(db) {
++ db.exec(`
++ CREATE TABLE IF NOT EXISTS dev_projects (
++ id TEXT PRIMARY KEY,
++ owner_subject TEXT NOT NULL,
++ name TEXT NOT NULL,
++ environment_policy TEXT NOT NULL DEFAULT 'sandbox' CHECK (environment_policy IN ('sandbox', 'sandbox+production')),
++ allowance TEXT NOT NULL DEFAULT '[]',
++ created_at TEXT NOT NULL,
++ created_by TEXT NOT NULL,
++ archived_at TEXT,
++ archived_by TEXT
++ );
++ CREATE INDEX IF NOT EXISTS idx_dev_projects_owner ON dev_projects(owner_subject);
++ CREATE TABLE IF NOT EXISTS dev_project_members (
++ project_id TEXT NOT NULL REFERENCES dev_projects(id),
++ subject_id TEXT NOT NULL,
++ role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'developer', 'viewer')),
++ added_at TEXT NOT NULL,
++ added_by TEXT NOT NULL,
++ PRIMARY KEY (project_id, subject_id)
++ );
++ CREATE INDEX IF NOT EXISTS idx_dev_members_subject ON dev_project_members(subject_id);
++ CREATE TABLE IF NOT EXISTS dev_apps (
++ id TEXT PRIMARY KEY,
++ project_id TEXT NOT NULL REFERENCES dev_projects(id),
++ name TEXT NOT NULL,
++ environment TEXT NOT NULL CHECK (environment IN ('sandbox', 'production')),
++ oauth_client_id TEXT NOT NULL UNIQUE,
++ client_type TEXT NOT NULL CHECK (client_type IN ('confidential', 'public')),
++ redirect_uris TEXT NOT NULL DEFAULT '[]',
++ created_at TEXT NOT NULL,
++ created_by TEXT NOT NULL,
++ revoked_at TEXT,
++ revoked_by TEXT
++ );
++ CREATE INDEX IF NOT EXISTS idx_dev_apps_project ON dev_apps(project_id);
++ CREATE TABLE IF NOT EXISTS dev_credentials (
++ id TEXT PRIMARY KEY,
++ app_id TEXT NOT NULL REFERENCES dev_apps(id),
++ secret_hash TEXT NOT NULL UNIQUE,
++ hint TEXT NOT NULL,
++ created_at TEXT NOT NULL,
++ created_by TEXT NOT NULL,
++ expires_at TEXT,
++ revoked_at TEXT,
++ revoked_by TEXT,
++ last_used_at TEXT
++ );
++ CREATE INDEX IF NOT EXISTS idx_dev_credentials_app ON dev_credentials(app_id);
++ CREATE TABLE IF NOT EXISTS dev_grants (
++ app_id TEXT NOT NULL REFERENCES dev_apps(id),
++ capability TEXT NOT NULL,
++ audience TEXT NOT NULL,
++ status TEXT NOT NULL CHECK (status IN ('requested', 'approved', 'denied', 'revoked')),
++ requested_by TEXT NOT NULL,
++ requested_at TEXT NOT NULL,
++ decided_by TEXT,
++ decided_at TEXT,
++ PRIMARY KEY (app_id, capability)
++ );
++ CREATE TABLE IF NOT EXISTS dev_quotas (
++ project_id TEXT NOT NULL REFERENCES dev_projects(id),
++ capability TEXT NOT NULL,
++ limit_value INTEGER NOT NULL CHECK (limit_value >= 0),
++ quota_window TEXT NOT NULL CHECK (quota_window IN ('minute', 'hour', 'day', 'month', 'total')),
++ unit TEXT NOT NULL DEFAULT 'requests',
++ updated_at TEXT NOT NULL,
++ updated_by TEXT NOT NULL,
++ PRIMARY KEY (project_id, capability)
++ );
++ CREATE TABLE IF NOT EXISTS dev_auth_codes (
++ code_hash TEXT PRIMARY KEY,
++ app_id TEXT NOT NULL REFERENCES dev_apps(id),
++ user_subject TEXT NOT NULL,
++ redirect_uri TEXT NOT NULL,
++ scope TEXT NOT NULL DEFAULT '',
++ code_challenge TEXT NOT NULL,
++ expires_at TEXT NOT NULL,
++ used INTEGER NOT NULL DEFAULT 0
++ );
++ CREATE TABLE IF NOT EXISTS dev_audit (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ at TEXT NOT NULL,
++ project_id TEXT,
++ actor TEXT NOT NULL,
++ action TEXT NOT NULL,
++ target TEXT,
++ detail TEXT NOT NULL DEFAULT '{}',
++ request_id TEXT,
++ event_type TEXT,
++ event TEXT
++ );
++ CREATE INDEX IF NOT EXISTS idx_dev_audit_project ON dev_audit(project_id, id);
++ CREATE TRIGGER IF NOT EXISTS dev_audit_append_only_update BEFORE UPDATE ON dev_audit
++ BEGIN SELECT RAISE(ABORT, 'dev_audit is append-only'); END;
++ CREATE TRIGGER IF NOT EXISTS dev_audit_append_only_delete BEFORE DELETE ON dev_audit
++ BEGIN SELECT RAISE(ABORT, 'dev_audit is append-only'); END;
++ `);
++}
++
++// ── Audit and events ───────────────────────────────────────────
++
++/**
++ * Append one audit row. `actor` is a subject string ('user:usr_…', 'system:network').
++ * With `event` = { type, subject: { type, id }, payload }, the row also carries an event envelope.
++ * `detail` must never contain secret material (callers pass ids, hints and names only).
++ */
++function audit(db, { projectId, actor, action, target, detail, ctx, event }) {
++ let envelope = null;
++ if (event) {
++ const [atype, aid] = String(actor).split(':');
++ envelope = {
++ event_id: ids.newId('event'),
++ event_type: event.type, version: 1, source: 'network',
++ actor: atype === 'user' ? { type: 'user', id: aid } : { type: 'system', id: 'network' },
++ timestamp: nowIso(), visibility: 'internal', subject: event.subject, payload: event.payload || {},
++ };
++ if (ctx && /^[0-9a-f]{32}$/.test(ctx.traceId || '')) envelope.trace_id = ctx.traceId;
++ const v = validate('events.event-envelope@1', envelope);
++ if (!v.valid) throw new Error(`bad event envelope: ${v.errors.map(e => `${e.path} ${e.message}`).join('; ')}`);
++ }
++ db.prepare(`INSERT INTO dev_audit (at, project_id, actor, action, target, detail, request_id, event_type, event)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
++ .run(nowIso(), projectId || null, actor, action, target || null, JSON.stringify(detail || {}), (ctx && ctx.requestId) || null,
++ envelope ? envelope.event_type : null, envelope ? JSON.stringify(envelope) : null);
++}
++
++function listAudit(db, projectId, { before, limit = 50 } = {}) {
++ const n = Math.min(200, Math.max(1, Number(limit) || 50));
++ const rows = before
++ ? db.prepare('SELECT * FROM dev_audit WHERE project_id = ? AND id < ? ORDER BY id DESC LIMIT ?').all(projectId, Number(before), n)
++ : db.prepare('SELECT * FROM dev_audit WHERE project_id = ? ORDER BY id DESC LIMIT ?').all(projectId, n);
++ return {
++ entries: rows.map(r => ({ id: r.id, at: r.at, actor: r.actor, action: r.action, target: r.target, detail: JSON.parse(r.detail || '{}'),
++ request_id: r.request_id, event_type: r.event_type || undefined, event_id: r.event ? JSON.parse(r.event).event_id : undefined })),
++ next_before: rows.length === n ? rows[rows.length - 1].id : null,
++ };
++}
++
++// ── Actors ─────────────────────────────────────────────────────
++
++/** { subject, user, staff } for an authenticated Network user row. */
++function actorOf(db, user) {
++ const subject = subjects.ensureUserSubject(db, user);
++ if (!subject) fail(401, 'auth.no_subject', 'account has no subject id');
++ return { subject, user, staff: user.role === 'admin', label: `user:${subject}` };
++}
++
++function memberRole(db, projectId, subject) {
++ const r = db.prepare('SELECT role FROM dev_project_members WHERE project_id = ? AND subject_id = ?').get(projectId, subject);
++ return r ? r.role : null;
++}
++
++/**
++ * Load a project the actor may see. Non-members get 404 (existence is not disclosed); staff see all.
++ * `need` is the minimum member role; `staffOk` lets staff through regardless of membership.
++ */
++function access(db, actor, projectId, { need = 'viewer', staffOk = true, allowArchived = false } = {}) {
++ const p = PROJECT_ID_RE.test(String(projectId)) ? db.prepare('SELECT * FROM dev_projects WHERE id = ?').get(projectId) : null;
++ const role = p ? memberRole(db, p.id, actor.subject) : null;
++ if (!p || (!role && !actor.staff)) fail(404, 'project.not_found', 'no such project');
++ const ok = (role && RANK[role] >= RANK[need]) || (actor.staff && staffOk);
++ if (!ok) fail(403, 'project.forbidden', `requires ${need} role`);
++ if (p.archived_at && !allowArchived) fail(409, 'project.archived', 'project is archived');
++ return { project: p, role };
++}
++
++// ── Projects ───────────────────────────────────────────────────
++
++const cleanName = (v, field = 'name') => {
++ const s = String(v == null ? '' : v).replace(/[\u0000-\u001f\u007f]/g, '').trim();
++ if (!s || s.length > 80) fail(422, 'project.invalid', `${field} must be 1-80 characters`);
++ return s;
++};
++
++function projectView(db, p, role) {
++ return {
++ id: p.id, name: p.name, owner: { type: 'user', id: p.owner_subject }, role: role || null,
++ environment_policy: p.environment_policy, environments: policy.ENVIRONMENT_POLICIES[p.environment_policy],
++ allowance: JSON.parse(p.allowance || '[]'),
++ created_at: p.created_at, archived_at: p.archived_at || null,
++ counts: {
++ members: db.prepare('SELECT COUNT(*) AS n FROM dev_project_members WHERE project_id = ?').get(p.id).n,
++ apps: db.prepare('SELECT COUNT(*) AS n FROM dev_apps WHERE project_id = ? AND revoked_at IS NULL').get(p.id).n,
++ },
++ };
++}
++
++function createProject(db, actor, { name }, { settings, ctx }) {
++ const n = cleanName(name);
++ const owned = db.prepare('SELECT COUNT(*) AS n FROM dev_projects WHERE owner_subject = ? AND archived_at IS NULL').get(actor.subject).n;
++ if (owned >= settings.maxProjectsPerOwner) fail(409, 'project.limit', `at most ${settings.maxProjectsPerOwner} active projects per owner`);
++ const id = `prj_${ids.ulid()}`;
++ const t = nowIso();
++ db.transaction(() => {
++ db.prepare(`INSERT INTO dev_projects (id, owner_subject, name, environment_policy, allowance, created_at, created_by)
++ VALUES (?, ?, ?, 'sandbox', ?, ?, ?)`).run(id, actor.subject, n, JSON.stringify(settings.defaultAllowance), t, actor.label);
++ db.prepare('INSERT INTO dev_project_members (project_id, subject_id, role, added_at, added_by) VALUES (?, ?, ?, ?, ?)')
++ .run(id, actor.subject, 'owner', t, actor.label);
++ audit(db, { projectId: id, actor: actor.label, action: 'project.created', target: id, detail: { name: n, allowance: settings.defaultAllowance }, ctx });
++ })();
++ return projectView(db, db.prepare('SELECT * FROM dev_projects WHERE id = ?').get(id), 'owner');
++}
++
++function listProjects(db, actor, { all = false } = {}) {
++ const rows = all && actor.staff
++ ? db.prepare('SELECT p.*, m.role FROM dev_projects p LEFT JOIN dev_project_members m ON m.project_id = p.id AND m.subject_id = ? ORDER BY p.id DESC').all(actor.subject)
++ : db.prepare('SELECT p.*, m.role FROM dev_projects p JOIN dev_project_members m ON m.project_id = p.id WHERE m.subject_id = ? ORDER BY p.id DESC').all(actor.subject);
++ return rows.map(r => projectView(db, r, r.role));
++}
++
++function renameProject(db, actor, projectId, { name }, { ctx }) {
++ const { project, role } = access(db, actor, projectId, { need: 'admin', staffOk: false });
++ const n = cleanName(name);
++ db.transaction(() => {
++ db.prepare('UPDATE dev_projects SET name = ? WHERE id = ?').run(n, project.id);
++ audit(db, { projectId: project.id, actor: actor.label, action: 'project.renamed', target: project.id, detail: { from: project.name, to: n }, ctx });
++ })();
++ return projectView(db, db.prepare('SELECT * FROM dev_projects WHERE id = ?').get(project.id), role);
++}
++
++/** Archive: irreversible. Every app of the project is revoked (credentials included). */
++function archiveProject(db, actor, projectId, { ctx }) {
++ const { project, role } = access(db, actor, projectId, { need: 'owner', staffOk: true });
++ db.transaction(() => {
++ for (const app of db.prepare('SELECT * FROM dev_apps WHERE project_id = ? AND revoked_at IS NULL').all(project.id)) revokeAppTx(db, actor, project, app, ctx, 'project archived');
++ db.prepare('UPDATE dev_projects SET archived_at = ?, archived_by = ? WHERE id = ?').run(nowIso(), actor.label, project.id);
++ audit(db, { projectId: project.id, actor: actor.label, action: 'project.archived', target: project.id, ctx });
++ })();
++ return projectView(db, db.prepare('SELECT * FROM dev_projects WHERE id = ?').get(project.id), role);
++}
++
++/** Staff: the capabilities this project's apps may hold. Grants outside the new allowance are revoked. */
++function setAllowance(db, actor, projectId, { capabilities: list }, { ctx }) {
++ if (!actor.staff) fail(403, 'project.staff_only', 'only staff set a project allowance');
++ const { project } = access(db, actor, projectId);
++ if (!Array.isArray(list) || list.length > 200) fail(422, 'project.invalid', 'capabilities must be an array (at most 200)');
++ const wanted = [...new Set(list.map(String))].sort();
++ for (const id of wanted) {
++ const g = policy.grantability(id);
++ if (!g.grantable) fail(422, g.code, g.reason);
++ }
++ const trimmed = [];
++ db.transaction(() => {
++ db.prepare('UPDATE dev_projects SET allowance = ? WHERE id = ?').run(JSON.stringify(wanted), project.id);
++ audit(db, { projectId: project.id, actor: actor.label, action: 'project.allowance_set', target: project.id, detail: { from: JSON.parse(project.allowance), to: wanted }, ctx });
++ const rows = db.prepare(`SELECT g.*, a.project_id FROM dev_grants g JOIN dev_apps a ON a.id = g.app_id
++ WHERE a.project_id = ? AND g.status IN ('approved', 'requested')`).all(project.id);
++ for (const g of rows.filter(r => !wanted.includes(r.capability))) {
++ const next = g.status === 'approved' ? 'revoked' : 'denied';
++ db.prepare('UPDATE dev_grants SET status = ?, decided_by = ?, decided_at = ? WHERE app_id = ? AND capability = ?').run(next, actor.label, nowIso(), g.app_id, g.capability);
++ grantEvent(db, actor, project.id, g.app_id, g.capability, g.audience, g.status, next, ctx, 'outside allowance');
++ trimmed.push({ app_id: g.app_id, capability: g.capability, status: next });
++ }
++ })();
++ return { allowance: wanted, trimmed };
++}
++
++function setEnvironmentPolicy(db, actor, projectId, { environment_policy: value }, { ctx }) {
++ if (!actor.staff) fail(403, 'project.staff_only', 'only staff change the environment policy');
++ const { project } = access(db, actor, projectId);
++ if (!policy.ENVIRONMENT_POLICIES[value]) fail(422, 'project.invalid', `environment_policy is one of ${Object.keys(policy.ENVIRONMENT_POLICIES).join(', ')}`);
++ db.transaction(() => {
++ db.prepare('UPDATE dev_projects SET environment_policy = ? WHERE id = ?').run(value, project.id);
++ audit(db, { projectId: project.id, actor: actor.label, action: 'project.environment_policy_set', target: project.id, detail: { from: project.environment_policy, to: value }, ctx });
++ })();
++ return { environment_policy: value, environments: policy.ENVIRONMENT_POLICIES[value] };
++}
++
++// ── Members ────────────────────────────────────────────────────
++
++function memberView(db, r) {
++ const u = db.prepare('SELECT username, display_name FROM users WHERE subject_id = ?').get(r.subject_id);
++ return { subject: { type: 'user', id: r.subject_id }, username: u ? u.username : null, display_name: u ? (u.display_name || u.username) : null, role: r.role, added_at: r.added_at };
++}
++
++function listMembers(db, actor, projectId) {
++ const { project } = access(db, actor, projectId, { allowArchived: true });
++ return db.prepare('SELECT * FROM dev_project_members WHERE project_id = ? ORDER BY added_at').all(project.id).map(r => memberView(db, r));
++}
++
++function resolveUserSubject(db, { subject_id, username }) {
++ let u = null;
++ if (subject_id && USER_SUBJECT_RE.test(String(subject_id))) u = db.prepare('SELECT id, subject_id, is_banned FROM users WHERE subject_id = ?').get(String(subject_id));
++ else if (username) u = db.prepare('SELECT id, subject_id, created_at, is_banned FROM users WHERE username = ? COLLATE NOCASE').get(String(username));
++ if (!u || u.is_banned) fail(404, 'member.user_not_found', 'no such account');
++ return u.subject_id || subjects.ensureUserSubject(db, u);
++}
++
++/** Admins manage developers and viewers; only the owner manages admins. Ownership is not assignable here. */
++function canAssign(role, targetRole) {
++ if (targetRole === 'owner') return false;
++ if (targetRole === 'admin') return role === 'owner';
++ return RANK[role] >= RANK.admin;
++}
++
++function addMember(db, actor, projectId, body, { ctx }) {
++ const { project, role } = access(db, actor, projectId, { need: 'admin', staffOk: false });
++ const newRole = String(body.role || 'developer');
++ if (!ROLES.includes(newRole)) fail(422, 'member.invalid', `role is one of ${ROLES.filter(r => r !== 'owner').join(', ')}`);
++ if (!canAssign(role, newRole)) fail(403, 'member.forbidden', `a ${role} cannot assign ${newRole}`);
++ const subject = resolveUserSubject(db, body);
++ if (memberRole(db, project.id, subject)) fail(409, 'member.exists', 'already a member');
++ db.transaction(() => {
++ db.prepare('INSERT INTO dev_project_members (project_id, subject_id, role, added_at, added_by) VALUES (?, ?, ?, ?, ?)').run(project.id, subject, newRole, nowIso(), actor.label);
++ audit(db, { projectId: project.id, actor: actor.label, action: 'member.added', target: `user:${subject}`, detail: { role: newRole }, ctx });
++ })();
++ return memberView(db, db.prepare('SELECT * FROM dev_project_members WHERE project_id = ? AND subject_id = ?').get(project.id, subject));
++}
++
++function updateMember(db, actor, projectId, subject, body, { ctx }) {
++ const { project, role } = access(db, actor, projectId, { need: 'admin', staffOk: false });
++ const current = memberRole(db, project.id, subject);
++ if (!current) fail(404, 'member.not_found', 'not a member');
++ const newRole = String(body.role || '');
++ if (!ROLES.includes(newRole)) fail(422, 'member.invalid', 'unknown role');
++ if (current === 'owner') fail(403, 'member.forbidden', 'the owner role is not changed here');
++ if (!canAssign(role, current) || !canAssign(role, newRole)) fail(403, 'member.forbidden', `a ${role} cannot change ${current} to ${newRole}`);
++ db.transaction(() => {
++ db.prepare('UPDATE dev_project_members SET role = ? WHERE project_id = ? AND subject_id = ?').run(newRole, project.id, subject);
++ audit(db, { projectId: project.id, actor: actor.label, action: 'member.role_changed', target: `user:${subject}`, detail: { from: current, to: newRole }, ctx });
++ })();
++ return memberView(db, db.prepare('SELECT * FROM dev_project_members WHERE project_id = ? AND subject_id = ?').get(project.id, subject));
++}
++
++function removeMember(db, actor, projectId, subject, { ctx }) {
++ const self = subject === actor.subject;
++ const { project, role } = access(db, actor, projectId, { need: self ? 'viewer' : 'admin', staffOk: false, allowArchived: true });
++ const current = memberRole(db, project.id, subject);
++ if (!current) fail(404, 'member.not_found', 'not a member');
++ if (current === 'owner') fail(403, 'member.forbidden', 'the owner cannot be removed');
++ if (!self && !canAssign(role, current)) fail(403, 'member.forbidden', `a ${role} cannot remove a ${current}`);
++ db.transaction(() => {
++ db.prepare('DELETE FROM dev_project_members WHERE project_id = ? AND subject_id = ?').run(project.id, subject);
++ audit(db, { projectId: project.id, actor: actor.label, action: self ? 'member.left' : 'member.removed', target: `user:${subject}`, detail: { role: current }, ctx });
++ })();
++}
++
++// ── Apps ───────────────────────────────────────────────────────
++
++function validRedirects(list, environment) {
++ if (list === undefined) return [];
++ if (!Array.isArray(list) || list.length > 10) fail(422, 'app.invalid', 'redirect_uris must be an array of at most 10 URLs');
++ return [...new Set(list.map(u => {
++ let url;
++ try { url = new URL(String(u)); } catch { fail(422, 'app.invalid', `bad redirect URI ${String(u).slice(0, 200)}`); }
++ const loopback = url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
++ if (url.protocol !== 'https:' && !loopback) fail(422, 'app.invalid', 'redirect URIs must be https (http only for loopback)');
++ if (loopback && environment !== 'sandbox') fail(422, 'app.invalid', 'loopback redirect URIs are for sandbox apps only');
++ if (url.hash || url.username || url.password) fail(422, 'app.invalid', 'redirect URIs carry no fragment or credentials');
++ if (String(u).length > 500) fail(422, 'app.invalid', 'redirect URI too long');
++ return url.toString();
++ }))];
++}
++
++/** Role needed to manage an app: developers manage sandbox apps, admins production ones. */
++const manageRole = (environment) => (environment === 'production' ? 'admin' : 'developer');
++
++function appView(db, a) {
++ return {
++ id: a.id, subject: { type: 'app', id: a.id }, project_id: a.project_id, name: a.name, environment: a.environment,
++ client_id: a.oauth_client_id, client_type: a.client_type, redirect_uris: JSON.parse(a.redirect_uris || '[]'),
++ created_at: a.created_at, revoked_at: a.revoked_at || null,
++ grants: db.prepare("SELECT capability FROM dev_grants WHERE app_id = ? AND status = 'approved' ORDER BY capability").all(a.id).map(g => g.capability),
++ };
++}
++
++function loadApp(db, projectId, appId) {
++ const a = APP_ID_RE.test(String(appId)) ? db.prepare('SELECT * FROM dev_apps WHERE id = ? AND project_id = ?').get(appId, projectId) : null;
++ if (!a) fail(404, 'app.not_found', 'no such app');
++ return a;
++}
++
++function newSecret() {
++ const secret = SECRET_PREFIX + crypto.randomBytes(32).toString('base64url');
++ return { secret, hash: hashSecret(secret), hint: secret.slice(-4) };
++}
++const hashSecret = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
++
++function insertCredential(db, app, actor) {
++ const s = newSecret();
++ const id = `crd_${ids.ulid()}`;
++ db.prepare('INSERT INTO dev_credentials (id, app_id, secret_hash, hint, created_at, created_by) VALUES (?, ?, ?, ?, ?, ?)')
++ .run(id, app.id, s.hash, s.hint, nowIso(), actor.label);
++ return { id, secret: s.secret, hint: s.hint };
++}
++
++function listApps(db, actor, projectId) {
++ const { project } = access(db, actor, projectId, { allowArchived: true });
++ return db.prepare('SELECT * FROM dev_apps WHERE project_id = ? ORDER BY id').all(project.id).map(a => appView(db, a));
++}
++
++function getApp(db, actor, projectId, appId) {
++ const { project } = access(db, actor, projectId, { allowArchived: true });
++ return appView(db, loadApp(db, project.id, appId));
++}
++
++/** Create an app. Confidential apps get their first secret, returned here once and never again. */
++function createApp(db, actor, projectId, body, { settings, ctx }) {
++ const environment = String(body.environment || 'sandbox');
++ if (!policy.ENVIRONMENTS.includes(environment)) fail(422, 'app.invalid', 'environment is sandbox or production');
++ const { project } = access(db, actor, projectId, { need: manageRole(environment), staffOk: false });
++ if (!policy.ENVIRONMENT_POLICIES[project.environment_policy].includes(environment)) fail(403, 'app.environment_not_allowed', `this project may only have ${policy.ENVIRONMENT_POLICIES[project.environment_policy].join(', ')} apps (staff enable production)`);
++ const clientType = String(body.type || body.client_type || 'confidential');
++ if (!CLIENT_TYPES.includes(clientType)) fail(422, 'app.invalid', 'type is confidential or public');
++ const name = cleanName(body.name);
++ const redirects = validRedirects(body.redirect_uris, environment);
++ if (clientType === 'public' && !redirects.length) fail(422, 'app.invalid', 'a public app needs at least one redirect URI (it can only use authorization code + PKCE)');
++ const active = db.prepare('SELECT COUNT(*) AS n FROM dev_apps WHERE project_id = ? AND revoked_at IS NULL').get(project.id).n;
++ if (active >= settings.maxAppsPerProject) fail(409, 'app.limit', `at most ${settings.maxAppsPerProject} active apps per project`);
++ const id = ids.newId('app');
++ let credential = null;
++ db.transaction(() => {
++ db.prepare(`INSERT INTO dev_apps (id, project_id, name, environment, oauth_client_id, client_type, redirect_uris, created_at, created_by)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, project.id, name, environment, id, clientType, JSON.stringify(redirects), nowIso(), actor.label);
++ const app = db.prepare('SELECT * FROM dev_apps WHERE id = ?').get(id);
++ if (clientType === 'confidential') credential = insertCredential(db, app, actor);
++ audit(db, { projectId: project.id, actor: actor.label, action: 'app.created', target: `app:${id}`,
++ detail: { name, environment, client_type: clientType, credential_id: credential ? credential.id : undefined }, ctx,
++ event: { type: 'network.app.created', subject: { type: 'app', id }, payload: { project_id: project.id, environment, client_type: clientType } } });
++ })();
++ const out = appView(db, db.prepare('SELECT * FROM dev_apps WHERE id = ?').get(id));
++ if (credential) out.credential = { id: credential.id, client_secret: credential.secret, hint: credential.hint, shown_once: true };
++ return out;
++}
++
++function updateApp(db, actor, projectId, appId, body, { ctx }) {
++ const { project } = access(db, actor, projectId, { need: 'viewer', staffOk: false });
++ const app = loadApp(db, project.id, appId);
++ access(db, actor, projectId, { need: manageRole(app.environment), staffOk: false });
++ if (app.revoked_at) fail(409, 'app.revoked', 'app is revoked');
++ const name = body.name !== undefined ? cleanName(body.name) : app.name;
++ const redirects = body.redirect_uris !== undefined ? validRedirects(body.redirect_uris, app.environment) : JSON.parse(app.redirect_uris);
++ if (app.client_type === 'public' && !redirects.length) fail(422, 'app.invalid', 'a public app needs at least one redirect URI');
++ db.transaction(() => {
++ db.prepare('UPDATE dev_apps SET name = ?, redirect_uris = ? WHERE id = ?').run(name, JSON.stringify(redirects), app.id);
++ audit(db, { projectId: project.id, actor: actor.label, action: 'app.updated', target: `app:${app.id}`, detail: { name, redirect_uris: redirects }, ctx });
++ })();
++ return appView(db, db.prepare('SELECT * FROM dev_apps WHERE id = ?').get(app.id));
++}
++
++function revokeAppTx(db, actor, project, app, ctx, reason) {
++ const t = nowIso();
++ db.prepare('UPDATE dev_apps SET revoked_at = ?, revoked_by = ? WHERE id = ?').run(t, actor.label, app.id);
++ db.prepare('UPDATE dev_credentials SET revoked_at = ?, revoked_by = ? WHERE app_id = ? AND revoked_at IS NULL').run(t, actor.label, app.id);
++ db.prepare('UPDATE dev_auth_codes SET used = 1 WHERE app_id = ?').run(app.id);
++ audit(db, { projectId: project.id, actor: actor.label, action: 'app.revoked', target: `app:${app.id}`, detail: { reason: reason || null }, ctx,
++ event: { type: 'network.app.revoked', subject: { type: 'app', id: app.id }, payload: { project_id: project.id, environment: app.environment, reason: reason || null } } });
++}
++
++/** Revoke an app: immediate for new tokens; issued tokens expire within their 5-minute lifetime. */
++function revokeApp(db, actor, projectId, appId, { ctx }) {
++ const { project } = access(db, actor, projectId, { need: 'viewer', staffOk: true });
++ const app = loadApp(db, project.id, appId);
++ if (!actor.staff) access(db, actor, projectId, { need: manageRole(app.environment), staffOk: false });
++ if (app.revoked_at) return appView(db, app);
++ db.transaction(() => revokeAppTx(db, actor, project, app, ctx, actor.staff && !memberRole(db, project.id, actor.subject) ? 'staff' : null))();
++ return appView(db, db.prepare('SELECT * FROM dev_apps WHERE id = ?').get(app.id));
++}
++
++// ── Credentials ────────────────────────────────────────────────
++
++function credentialState(c, now = Date.now()) {
++ if (c.revoked_at) return 'revoked';
++ if (c.expires_at && Date.parse(c.expires_at) <= now) return 'expired';
++ return c.expires_at ? 'expiring' : 'active';
++}
++
++function credentialView(c) {
++ return { id: c.id, hint: c.hint, state: credentialState(c), created_at: c.created_at, expires_at: c.expires_at || null, revoked_at: c.revoked_at || null, last_used_at: c.last_used_at || null };
++}
++
++function listCredentials(db, actor, projectId, appId) {
++ const { project } = access(db, actor, projectId, { allowArchived: true });
++ const app = loadApp(db, project.id, appId);
++ return db.prepare('SELECT * FROM dev_credentials WHERE app_id = ? ORDER BY id DESC').all(app.id).map(credentialView);
++}
++
++/**
++ * Rotate: a new secret (returned once); every still-valid previous secret stays valid for
++ * `overlap_seconds` (default settings.credentialOverlapS, 0..7 days), never longer than it already had.
++ */
++function rotateCredential(db, actor, projectId, appId, body, { settings, ctx }) {
++ const { project } = access(db, actor, projectId, { need: 'viewer', staffOk: false });
++ const app = loadApp(db, project.id, appId);
++ access(db, actor, projectId, { need: manageRole(app.environment), staffOk: false });
++ if (app.revoked_at) fail(409, 'app.revoked', 'app is revoked');
++ if (app.client_type !== 'confidential') fail(409, 'credential.public_client', 'public apps have no client secret');
++ let overlap = settings.credentialOverlapS;
++ if (body && body.overlap_seconds !== undefined) {
++ const n = Number(body.overlap_seconds);
++ if (!Number.isInteger(n) || n < 0 || n > 7 * 86400) fail(422, 'credential.invalid', 'overlap_seconds is an integer from 0 to 604800');
++ overlap = n;
++ }
++ const until = new Date(Date.now() + overlap * 1000).toISOString();
++ let created;
++ const kept = [];
++ db.transaction(() => {
++ for (const c of db.prepare('SELECT * FROM dev_credentials WHERE app_id = ? AND revoked_at IS NULL').all(app.id)) {
++ if (credentialState(c) === 'expired') continue;
++ const exp = c.expires_at && Date.parse(c.expires_at) < Date.parse(until) ? c.expires_at : until;
++ db.prepare('UPDATE dev_credentials SET expires_at = ? WHERE id = ?').run(exp, c.id);
++ kept.push({ id: c.id, expires_at: exp });
++ }
++ created = insertCredential(db, app, actor);
++ audit(db, { projectId: project.id, actor: actor.label, action: 'credential.rotated', target: `app:${app.id}`,
++ detail: { credential_id: created.id, hint: created.hint, overlap_seconds: overlap, previous: kept }, ctx,
++ event: { type: 'network.credential.rotated', subject: { type: 'app', id: app.id }, payload: { project_id: project.id, credential_id: created.id, previous_valid_until: kept.length ? until : null } } });
++ })();
++ return { credential: { id: created.id, client_secret: created.secret, hint: created.hint, shown_once: true }, previous: kept };
++}
++
++/** Revoke one credential: it stops authenticating at once. */
++function revokeCredential(db, actor, projectId, appId, credId, { ctx }) {
++ const { project } = access(db, actor, projectId, { need: 'viewer', staffOk: true, allowArchived: true });
++ const app = loadApp(db, project.id, appId);
++ if (!actor.staff) access(db, actor, projectId, { need: manageRole(app.environment), staffOk: false, allowArchived: true });
++ const c = CRED_ID_RE.test(String(credId)) ? db.prepare('SELECT * FROM dev_credentials WHERE id = ? AND app_id = ?').get(credId, app.id) : null;
++ if (!c) fail(404, 'credential.not_found', 'no such credential');
++ if (!c.revoked_at) {
++ db.transaction(() => {
++ db.prepare('UPDATE dev_credentials SET revoked_at = ?, revoked_by = ? WHERE id = ?').run(nowIso(), actor.label, c.id);
++ audit(db, { projectId: project.id, actor: actor.label, action: 'credential.revoked', target: `app:${app.id}`, detail: { credential_id: c.id, hint: c.hint }, ctx,
++ event: { type: 'network.credential.revoked', subject: { type: 'app', id: app.id }, payload: { project_id: project.id, credential_id: c.id } } });
++ })();
++ }
++ return credentialView(db.prepare('SELECT * FROM dev_credentials WHERE id = ?').get(c.id));
++}
++
++/** Token path: the credential row a presented secret matches, if it is still valid. Updates last_used_at. */
++function matchSecret(db, appId, secret) {
++ if (typeof secret !== 'string' || !secret.startsWith(SECRET_PREFIX) || secret.length > 200) return null;
++ const want = Buffer.from(hashSecret(secret), 'hex');
++ const now = Date.now();
++ for (const c of db.prepare('SELECT * FROM dev_credentials WHERE app_id = ? AND revoked_at IS NULL').all(appId)) {
++ if (credentialState(c, now) === 'expired') continue;
++ if (crypto.timingSafeEqual(Buffer.from(c.secret_hash, 'hex'), want)) {
++ db.prepare('UPDATE dev_credentials SET last_used_at = ? WHERE id = ?').run(nowIso(), c.id);
++ return c;
++ }
++ }
++ return null;
++}
++
++// ── Grants ─────────────────────────────────────────────────────
++
++function grantView(g) {
++ return { app_id: g.app_id, capability: g.capability, audience: g.audience, status: g.status, requested_by: g.requested_by, requested_at: g.requested_at, decided_by: g.decided_by || null, decided_at: g.decided_at || null };
++}
++
++function grantEvent(db, actor, projectId, appId, capability, audience, from, to, ctx, reason) {
++ audit(db, { projectId, actor: actor.label, action: `grant.${to}`, target: `app:${appId}`, detail: { capability, audience, from, reason: reason || undefined }, ctx,
++ event: { type: 'network.grant.changed', subject: { type: 'app', id: appId }, payload: { project_id: projectId, capability, audience, from, to } } });
++}
++
++function listGrants(db, actor, projectId, appId) {
++ const { project } = access(db, actor, projectId, { allowArchived: true });
++ const app = loadApp(db, project.id, appId);
++ return db.prepare('SELECT * FROM dev_grants WHERE app_id = ? ORDER BY capability').all(app.id).map(grantView);
++}
++
++function withinAllowance(project, capability) {
++ return JSON.parse(project.allowance || '[]').includes(capability);
++}
++
++/**
++ * Request a capability for an app (developer+). Owners and admins get it approved at once when it
++ * is inside the allowance; otherwise it waits for an owner/admin.
++ */
++function requestGrant(db, actor, projectId, appId, { capability }, { ctx }) {
++ const { project, role } = access(db, actor, projectId, { need: 'developer', staffOk: false });
++ const app = loadApp(db, project.id, appId);
++ if (app.revoked_at) fail(409, 'app.revoked', 'app is revoked');
++ const g = policy.grantability(capability);
++ if (!g.grantable) fail(g.code === 'grant.unknown_capability' ? 422 : 403, g.code, g.reason);
++ const audience = policy.audienceOf(capability);
++ const existing = db.prepare('SELECT * FROM dev_grants WHERE app_id = ? AND capability = ?').get(app.id, capability);
++ if (existing && ['approved', 'requested'].includes(existing.status)) return grantView(existing);
++ const approveNow = RANK[role] >= RANK.admin && withinAllowance(project, capability);
++ const status = approveNow ? 'approved' : 'requested';
++ const t = nowIso();
++ db.transaction(() => {
++ db.prepare(`INSERT INTO dev_grants (app_id, capability, audience, status, requested_by, requested_at, decided_by, decided_at)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
++ ON CONFLICT(app_id, capability) DO UPDATE SET audience = excluded.audience, status = excluded.status, requested_by = excluded.requested_by,
++ requested_at = excluded.requested_at, decided_by = excluded.decided_by, decided_at = excluded.decided_at`)
++ .run(app.id, capability, audience, status, actor.label, t, approveNow ? actor.label : null, approveNow ? t : null);
++ if (approveNow) grantEvent(db, actor, project.id, app.id, capability, audience, existing ? existing.status : 'none', 'approved', ctx);
++ else audit(db, { projectId: project.id, actor: actor.label, action: 'grant.requested', target: `app:${app.id}`, detail: { capability, audience }, ctx });
++ })();
++ return grantView(db.prepare('SELECT * FROM dev_grants WHERE app_id = ? AND capability = ?').get(app.id, capability));
++}
++
++function decideGrant(db, actor, projectId, appId, capability, decision, { ctx }) {
++ const { project } = access(db, actor, projectId, { need: 'admin', staffOk: decision === 'revoked', allowArchived: decision === 'revoked' });
++ const app = loadApp(db, project.id, appId);
++ const g = db.prepare('SELECT * FROM dev_grants WHERE app_id = ? AND capability = ?').get(app.id, String(capability));
++ if (!g) fail(404, 'grant.not_found', 'no such grant');
++ if (decision === 'approved') {
++ if (app.revoked_at) fail(409, 'app.revoked', 'app is revoked');
++ const ok = policy.grantability(g.capability);
++ if (!ok.grantable) fail(403, ok.code, ok.reason);
++ if (!withinAllowance(project, g.capability)) fail(403, 'grant.beyond_allowance', `${g.capability} is not in this project's allowance (staff set it)`);
++ if (g.status === 'approved') return grantView(g);
++ } else if (decision === 'denied') {
++ if (g.status !== 'requested') fail(409, 'grant.not_pending', `grant is ${g.status}`);
++ } else if (decision === 'revoked') {
++ if (g.status !== 'approved') fail(409, 'grant.not_active', `grant is ${g.status}`);
++ }
++ db.transaction(() => {
++ db.prepare('UPDATE dev_grants SET status = ?, decided_by = ?, decided_at = ? WHERE app_id = ? AND capability = ?').run(decision, actor.label, nowIso(), app.id, g.capability);
++ grantEvent(db, actor, project.id, app.id, g.capability, g.audience, g.status, decision, ctx);
++ })();
++ return grantView(db.prepare('SELECT * FROM dev_grants WHERE app_id = ? AND capability = ?').get(app.id, g.capability));
++}
++
++// ── Quotas ─────────────────────────────────────────────────────
++
++function quotaView(q) {
++ return { capability: q.capability, limit: q.limit_value, window: q.quota_window, unit: q.unit, enforced_by: policy.audienceOf(q.capability), updated_at: q.updated_at };
++}
++
++function listQuotas(db, actor, projectId) {
++ const { project } = access(db, actor, projectId, { allowArchived: true });
++ return db.prepare('SELECT * FROM dev_quotas WHERE project_id = ? ORDER BY capability').all(project.id).map(quotaView);
++}
++
++function setQuota(db, actor, projectId, capability, body, { ctx }) {
++ if (!actor.staff) fail(403, 'project.staff_only', 'only staff set quotas');
++ const { project } = access(db, actor, projectId);
++ const g = policy.grantability(capability);
++ if (!g.grantable) fail(422, g.code, g.reason);
++ const limit = Number(body.limit);
++ if (!Number.isSafeInteger(limit) || limit < 0) fail(422, 'quota.invalid', 'limit is a non-negative integer');
++ const window = String(body.window || '');
++ if (!QUOTA_WINDOWS.includes(window)) fail(422, 'quota.invalid', `window is one of ${QUOTA_WINDOWS.join(', ')}`);
++ const unit = String(body.unit || 'requests');
++ if (!/^[a-z][a-z0-9_]{0,31}$/.test(unit)) fail(422, 'quota.invalid', 'unit is a short lowercase word (requests, bytes, tokens, ...)');
++ db.transaction(() => {
++ db.prepare(`INSERT INTO dev_quotas (project_id, capability, limit_value, quota_window, unit, updated_at, updated_by) VALUES (?, ?, ?, ?, ?, ?, ?)
++ ON CONFLICT(project_id, capability) DO UPDATE SET limit_value = excluded.limit_value, quota_window = excluded.quota_window, unit = excluded.unit,
++ updated_at = excluded.updated_at, updated_by = excluded.updated_by`)
++ .run(project.id, capability, limit, window, unit, nowIso(), actor.label);
++ audit(db, { projectId: project.id, actor: actor.label, action: 'quota.set', target: project.id, detail: { capability, limit, window, unit }, ctx });
++ })();
++ return quotaView(db.prepare('SELECT * FROM dev_quotas WHERE project_id = ? AND capability = ?').get(project.id, capability));
++}
++
++function deleteQuota(db, actor, projectId, capability, { ctx }) {
++ if (!actor.staff) fail(403, 'project.staff_only', 'only staff set quotas');
++ const { project } = access(db, actor, projectId);
++ const n = db.prepare('DELETE FROM dev_quotas WHERE project_id = ? AND capability = ?').run(project.id, String(capability)).changes;
++ if (!n) fail(404, 'quota.not_found', 'no quota for that capability');
++ audit(db, { projectId: project.id, actor: actor.label, action: 'quota.deleted', target: project.id, detail: { capability }, ctx });
++}
++
++module.exports = {
++ ensureSchema, DevError, ROLES, APP_ID_RE, PROJECT_ID_RE, SECRET_PREFIX,
++ audit, listAudit, actorOf, access, memberRole,
++ createProject, listProjects, renameProject, archiveProject, setAllowance, setEnvironmentPolicy, projectView,
++ listMembers, addMember, updateMember, removeMember,
++ listApps, getApp, createApp, updateApp, revokeApp,
++ listCredentials, rotateCredential, revokeCredential, matchSecret, hashSecret,
++ listGrants, requestGrant, decideGrant,
++ listQuotas, setQuota, deleteQuota,
++};
ddiff --git a/server/developer/tokens.js b/server/developer/tokens.js
nnew file mode 100644
iindex 00000000..0140e386
---- /dev/null
++++ b/server/developer/tokens.js
@@@ -0,0 +1,162 @@
++'use strict';
++/**
++ * App tokens (roadmap Wave 20 foundation, ADR-014). Developer apps use /oauth/token like service
++ * principals do, but they are recognised by their client id (app_<ULID>) and never touch
++ * oauth_clients or principal_grants.
++ *
++ * confidential app grant_type=client_credentials client_id, client_secret, audience[, scope]
++ * any app grant_type=authorization_code client_id, code, redirect_uri, code_verifier,
++ * audience[, scope] (+ client_secret for a confidential app)
++ *
++ * Tokens are RS256, 5 minutes, shaped by identity.service-token-claims@1:
++ * sub app:app_<ULID>, actor_type app, aud [audience], cap (approved grants ∩ allowance ∩ still
++ * grantable, for that audience), ns [project_id], project_id, env sandbox|production,
++ * on_behalf_of usr_<ULID> (authorization code only).
++ * A sandbox app gets a token only for an audience that opted in to sandbox tokens
++ * (DEV_SANDBOX_AUDIENCES); receivers also refuse env=sandbox unless they opted in.
++ * No refresh tokens: a revoked credential or app stops new tokens at once, and issued tokens end
++ * within their 5-minute lifetime.
++ */
++const crypto = require('crypto');
++const { serviceAuth, assertValid } = require('openvibe-contracts');
++const policy = require('./policy');
++const store = require('./store');
++
++const TOKEN_TTL_S = 300;
++const CODE_TTL_MS = 5 * 60 * 1000;
++const PKCE_RE = /^[A-Za-z0-9_-]{43,128}$/;
++
++const isAppClient = (clientId) => store.APP_ID_RE.test(String(clientId || ''));
++const oauthError = (status, error, description) => ({ status, body: { error, error_description: description } });
++
++/** The app plus its project, if both are usable for issuing tokens now. */
++function usableApp(db, clientId) {
++ const app = isAppClient(clientId) ? db.prepare('SELECT * FROM dev_apps WHERE oauth_client_id = ?').get(String(clientId)) : null;
++ if (!app || app.revoked_at) return { error: oauthError(401, 'invalid_client', 'unknown or revoked app') };
++ const project = db.prepare('SELECT * FROM dev_projects WHERE id = ?').get(app.project_id);
++ if (!project || project.archived_at) return { error: oauthError(401, 'invalid_client', 'project archived') };
++ if (!policy.ENVIRONMENT_POLICIES[project.environment_policy].includes(app.environment)) {
++ return { error: oauthError(400, 'unauthorized_client', `${app.environment} apps are not enabled for this project`) };
++ }
++ return { app, project };
++}
++
++/** Capabilities the app holds for an audience right now. */
++function effectiveGrants(db, app, project, audience) {
++ const allowance = new Set(JSON.parse(project.allowance || '[]'));
++ return db.prepare("SELECT capability FROM dev_grants WHERE app_id = ? AND audience = ? AND status = 'approved' ORDER BY capability")
++ .all(app.id, audience).map(r => r.capability)
++ .filter(c => allowance.has(c) && policy.isGrantable(c) && policy.audienceOf(c) === audience);
++}
++
++function mint({ app, project, audience, scope, limit, onBehalfOf, privateKey, issuer, settings, db }) {
++ const aud = String(audience || '').trim();
++ if (!aud || !/^[a-z0-9.-]+$/.test(aud)) return oauthError(400, 'invalid_request', 'audience is required');
++ if (app.environment === 'sandbox' && !settings.sandboxAudiences.has(aud)) {
++ return oauthError(400, 'invalid_target', `${aud} does not accept sandbox tokens`);
++ }
++ const held = effectiveGrants(db, app, project, aud).filter(c => !limit || limit.includes(c));
++ const wanted = scope ? String(scope).split(/\s+/).filter(Boolean) : null;
++ const missing = wanted ? wanted.filter(w => !held.includes(w)) : [];
++ if (missing.length) return oauthError(400, 'invalid_scope', `not granted: ${missing.join(' ')}`);
++ const cap = wanted || held;
++ if (!cap.length) return oauthError(400, 'invalid_scope', `no grants for audience ${aud}`);
++ const now = Math.floor(Date.now() / 1000);
++ const claims = {
++ iss: issuer, sub: `app:${app.id}`, actor_type: 'app', aud: [aud], cap, ns: [project.id],
++ project_id: project.id, env: app.environment,
++ ...(onBehalfOf ? { on_behalf_of: onBehalfOf } : {}),
++ iat: now, exp: now + TOKEN_TTL_S, jti: `tok_${crypto.randomBytes(12).toString('hex')}`,
++ };
++ assertValid('identity.service-token-claims@1', claims);
++ return { status: 200, body: { access_token: serviceAuth.signServiceToken(claims, privateKey), token_type: 'Bearer', expires_in: TOKEN_TTL_S, scope: cap.join(' ') } };
++}
++
++/** Authenticate the client of a token request. Public apps present no secret. */
++function authenticate(db, app, clientSecret) {
++ if (app.client_type === 'public') {
++ if (clientSecret) return oauthError(401, 'invalid_client', 'public apps have no client secret');
++ return null;
++ }
++ if (!store.matchSecret(db, app.id, clientSecret)) return oauthError(401, 'invalid_client', 'Invalid client credentials');
++ return null;
++}
++
++/** /oauth/token for an app client id. Returns { status, body }. */
++function handleTokenRequest(db, body, { privateKey, issuer, config }) {
++ const settings = policy.settings(config);
++ const found = usableApp(db, body.client_id);
++ if (found.error) return found.error;
++ const { app, project } = found;
++ if (body.grant_type === 'client_credentials') {
++ if (app.client_type !== 'confidential') return oauthError(400, 'unauthorized_client', 'public apps use authorization_code with PKCE');
++ const bad = authenticate(db, app, body.client_secret);
++ if (bad) return bad;
++ return mint({ app, project, audience: body.audience, scope: body.scope, privateKey, issuer, settings, db });
++ }
++ if (body.grant_type === 'authorization_code') {
++ const bad = authenticate(db, app, body.client_secret);
++ if (bad) return bad;
++ const code = String(body.code || '');
++ if (!code) return oauthError(400, 'invalid_request', 'Missing code');
++ const hash = store.hashSecret(code);
++ const row = db.prepare('SELECT * FROM dev_auth_codes WHERE code_hash = ?').get(hash);
++ if (!row || row.app_id !== app.id) return oauthError(400, 'invalid_grant', 'Invalid authorization code');
++ // Single use, atomically; a failed verification below still burns the code.
++ if (db.prepare('UPDATE dev_auth_codes SET used = 1 WHERE code_hash = ? AND used = 0').run(hash).changes !== 1) return oauthError(400, 'invalid_grant', 'Code already used');
++ if (Date.parse(row.expires_at) < Date.now()) return oauthError(400, 'invalid_grant', 'Authorization code expired');
++ if (row.redirect_uri !== String(body.redirect_uri || '')) return oauthError(400, 'invalid_grant', 'Redirect URI mismatch');
++ if (!verifierMatches(body.code_verifier, row.code_challenge)) return oauthError(400, 'invalid_grant', 'PKCE verification failed');
++ const user = db.prepare('SELECT subject_id, is_banned FROM users WHERE subject_id = ?').get(row.user_subject);
++ if (!user || user.is_banned) return oauthError(400, 'invalid_grant', 'User not found or banned');
++ // The exchange may narrow what was authorized, never widen it.
++ const authorized = row.scope ? row.scope.split(' ') : null;
++ const asked = body.scope ? String(body.scope).split(/\s+/).filter(Boolean) : null;
++ if (authorized && asked && asked.some(s => !authorized.includes(s))) return oauthError(400, 'invalid_scope', 'scope exceeds what the user authorized');
++ return mint({ app, project, audience: body.audience, scope: body.scope, limit: authorized, onBehalfOf: row.user_subject, privateKey, issuer, settings, db });
++ }
++ return oauthError(400, 'unsupported_grant_type', 'apps use client_credentials or authorization_code');
++}
++
++function verifierMatches(verifier, challenge) {
++ if (!PKCE_RE.test(String(verifier || ''))) return false;
++ const a = Buffer.from(crypto.createHash('sha256').update(String(verifier)).digest('base64url'));
++ const b = Buffer.from(String(challenge));
++ return a.length === b.length && crypto.timingSafeEqual(a, b);
++}
++
++/**
++ * /oauth/authorize and /oauth/confirm checks for an app client id: registered redirect URI and a
++ * PKCE S256 challenge (required for every app). Returns { app, project } or { error: message }.
++ */
++function checkAuthorizeRequest(db, { client_id, redirect_uri, code_challenge, code_challenge_method }) {
++ const found = usableApp(db, client_id);
++ if (found.error) return { error: 'Unknown client_id' };
++ const uris = JSON.parse(found.app.redirect_uris || '[]');
++ if (!redirect_uri || !uris.includes(String(redirect_uri))) return { error: 'Invalid redirect_uri' };
++ if (!code_challenge || code_challenge_method !== 'S256' || !PKCE_RE.test(String(code_challenge))) {
++ return { error: 'apps must send a PKCE code_challenge with code_challenge_method=S256', pkce: true };
++ }
++ return found;
++}
++
++/**
++ * Issue an authorization code to an app for a signed-in user. A sandbox app may only be authorized
++ * by members of its project. Returns { code } or { status, error }.
++ */
++function issueCode(db, { app, project, user, redirectUri, scope, challenge }) {
++ const subjects = require('../identity/subjects');
++ const subject = subjects.ensureUserSubject(db, user);
++ if (!subject) return { status: 403, error: 'account has no subject id' };
++ if (app.environment === 'sandbox' && !store.memberRole(db, project.id, subject)) {
++ return { status: 403, error: 'sandbox apps can only be authorized by members of their project' };
++ }
++ const code = crypto.randomBytes(32).toString('hex');
++ db.prepare(`INSERT INTO dev_auth_codes (code_hash, app_id, user_subject, redirect_uri, scope, code_challenge, expires_at)
++ VALUES (?, ?, ?, ?, ?, ?, ?)`).run(store.hashSecret(code), app.id, subject, redirectUri,
++ String(scope || '').split(/\s+/).filter(s => policy.isGrantable(s)).join(' '), challenge, new Date(Date.now() + CODE_TTL_MS).toISOString());
++ db.prepare('DELETE FROM dev_auth_codes WHERE expires_at < ?').run(new Date(Date.now() - 3600 * 1000).toISOString());
++ return { code };
++}
++
++module.exports = { isAppClient, handleTokenRequest, checkAuthorizeRequest, issueCode, effectiveGrants, TOKEN_TTL_S };
ddiff --git a/server/identity/principals.js b/server/identity/principals.js
iindex db8257c8..3026036d 100644
---- a/server/identity/principals.js
++++ b/server/identity/principals.js
@@@ -229,6 +229,18 @@ function guard(capability, { ownApp, namespace, legacy = true } = {}) {
}
check(req, res, () => {
const principal = req.principal;
++ // Developer sandbox tokens (env: sandbox) are refused unless Network opted in as an audience
++ // (DEV_SANDBOX_AUDIENCES); the signature was verified by check() above.
++ if (principal && !principal.legacy) {
++ const devPolicy = require('../developer/policy');
++ const claims = devPolicy.unverifiedClaims(String(req.headers.authorization || '').slice(7).trim());
++ const env = devPolicy.environmentDecision(claims, { acceptSandbox: devPolicy.settings(req.app.locals.config).sandboxAudiences.has(SELF_AUDIENCE) });
++ if (!env.ok) {
++ record({ req, capability, principal, allowed: false, code: env.code });
++ require('../observability').principalDenied({ req, code: env.code });
++ return http.sendProblem(res, 401, env.code, { detail: env.reason, ctx: req.ov });
++ }
++ }
if (ownApp && principal && !principal.legacy) {
const app = ownApp(req);
const self = String(principal.sub).replace(/^svc:/, '');
ddiff --git a/server/index.js b/server/index.js
iindex 2733802a..2694d745 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -421,6 +421,10 @@ app.use('/api/coins', createCoinsRoutes(db, requireAuth));
// Versioned user modules: portable per-person preferences and summaries (server/identity/modules.js).
app.use('/api/modules', require('./identity/modules').userRouter(requireAuth));
++// Developer projects, apps, credentials, grants and quotas (server/developer, ADR-014). Bearer user
++// tokens only; never X-Internal-Key.
++app.use('/api/v1/projects', rateLimit({ windowMs: 60_000, max: 60 }), require('./developer/routes').router());
++
// Notification API (authenticated users)
app.use('/api/notifications', createNotificationRoutes(db, notificationService, requireAuth));
ddiff --git a/test/developer-projects.test.js b/test/developer-projects.test.js
nnew file mode 100644
iindex 00000000..95cd01e1
---- /dev/null
++++ b/test/developer-projects.test.js
@@@ -0,0 +1,360 @@
++'use strict';
++// Developer projects (server/developer, roadmap Wave 20 foundation, ADR-014): projects, members,
++// apps, credentials, grants, quotas, audit/events, app tokens and the sandbox check.
++// node test/developer-projects.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 } = require('openvibe-contracts');
++const { initDb } = require('../server/db/database');
++const subjects = require('../server/identity/subjects');
++const policy = require('../server/developer/policy');
++
++// Everything the server logs is captured, so the test can prove no secret was ever logged.
++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-devprojects-'));
++const db = initDb(path.join(dir, 'network.db'));
++db.prepare(`INSERT INTO users (id, username, password_hash, role) VALUES
++ (10, 'owner', 'x', 'user'), (11, 'dev', 'x', 'user'), (12, 'viewer', 'x', 'user'), (13, 'stranger', 'x', 'user'), (14, '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: { sandboxAudiences: 'openvibe.media', 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 T = { owner: userToken(10), dev: userToken(11), viewer: userToken(12), stranger: userToken(13), staff: userToken(14) };
++const secretsSeen = [];
++
++(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, headers = {}) => {
++ const h = { ...(who ? { authorization: `Bearer ${T[who]}` } : {}), ...(body ? { 'content-type': 'application/json' } : {}), ...headers };
++ 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, type: r.headers.get('content-type') || '', rid: r.headers.get('x-openvibe-request-id'), text, body: text ? JSON.parse(text) : null };
++ };
++ const token = (form) => fetch(`${base}/oauth/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(form) })
++ .then(async r => ({ status: r.status, cache: r.headers.get('cache-control'), body: await r.json() }));
++ const cc = (appId, secret, audience = 'openvibe.media', extra = {}) => token({ grant_type: 'client_credentials', client_id: appId, client_secret: secret, audience, ...extra });
++ const verify = (t, audience = 'openvibe.media') => serviceAuth.verifyServiceToken(t, { publicKey: keys.publicKey, issuer: ISSUER, audience });
++
++ // ── Authentication: Bearer user tokens only; never X-Internal-Key ──
++ let r = await api(null, 'GET', '', null, { 'x-internal-key': 'legacy-key' });
++ assert.strictEqual(r.status, 401, 'the internal key opens nothing here');
++ assert.match(r.type, /application\/problem\+json/);
++ assert.strictEqual(r.body.code, 'auth.required');
++ assert.ok(r.rid, 'request id on every response');
++ r = await api(null, 'GET', '', null, { cookie: `ov_token=${T.owner}` });
++ assert.strictEqual(r.status, 401, 'cookies are not read (no CSRF surface)');
++ r = await api(null, 'GET', '', null, { 'x-openvibe-request-id': 'req_test_12345678', authorization: `Bearer ${T.owner}` });
++ assert.strictEqual(r.rid, 'req_test_12345678', 'a caller request id is propagated');
++
++ // ── Projects ──
++ r = await api('owner', 'POST', '', { name: 'Demo Project' });
++ assert.strictEqual(r.status, 201, r.text);
++ const P = r.body.id;
++ assert.match(P, /^prj_[0-9A-HJKMNP-TV-Z]{26}$/);
++ assert.strictEqual(r.body.role, 'owner');
++ assert.deepStrictEqual(r.body.owner, { type: 'user', id: sid(10) });
++ assert.deepStrictEqual(r.body.environments, ['sandbox'], 'new projects are sandbox-only');
++ assert.deepStrictEqual(r.body.allowance, []);
++
++ // Non-members cannot see the project (404, existence not disclosed)
++ r = await api('stranger', 'GET', `/${P}`);
++ assert.strictEqual(r.status, 404); assert.strictEqual(r.body.code, 'project.not_found');
++ r = await api('stranger', 'GET', '');
++ assert.deepStrictEqual(r.body.projects, []);
++ r = await api('stranger', 'GET', `/${P}/apps`);
++ assert.strictEqual(r.status, 404);
++ r = await api('stranger', 'POST', `/${P}/members`, { username: 'stranger', role: 'admin' });
++ assert.strictEqual(r.status, 404, 'non-members cannot add themselves');
++ r = await api('staff', 'GET', '?all=1');
++ assert.ok(r.body.projects.some(p => p.id === P), 'staff can list every project');
++
++ // ── Members ──
++ r = await api('owner', 'POST', `/${P}/members`, { username: 'dev', role: 'developer' });
++ assert.strictEqual(r.status, 201, r.text);
++ r = await api('owner', 'POST', `/${P}/members`, { subject_id: sid(12), role: 'viewer' });
++ assert.strictEqual(r.status, 201, r.text);
++ r = await api('owner', 'POST', `/${P}/members`, { username: 'staff', role: 'owner' });
++ assert.strictEqual(r.status, 403, 'ownership is not assignable');
++ r = await api('dev', 'POST', `/${P}/members`, { username: 'stranger', role: 'viewer' });
++ assert.strictEqual(r.status, 403, 'developers do not manage members');
++ r = await api('viewer', 'GET', `/${P}/members`);
++ assert.strictEqual(r.status, 200);
++ assert.deepStrictEqual(r.body.members.map(m => m.role).sort(), ['developer', 'owner', 'viewer']);
++
++ // ── Apps ──
++ r = await api('viewer', 'POST', `/${P}/apps`, { name: 'Nope' });
++ assert.strictEqual(r.status, 403, 'viewers cannot create apps');
++ r = await api('dev', 'POST', `/${P}/apps`, { name: 'Prod', environment: 'production' });
++ assert.strictEqual(r.status, 403);
++ r = await api('dev', 'POST', `/${P}/apps`, { name: 'Uploader', environment: 'sandbox', type: 'confidential' });
++ assert.strictEqual(r.status, 201, r.text);
++ const A = r.body.id;
++ assert.match(A, /^app_[0-9A-HJKMNP-TV-Z]{26}$/);
++ assert.strictEqual(r.body.client_id, A);
++ assert.deepStrictEqual(r.body.subject, { type: 'app', id: A });
++ const secret1 = r.body.credential.client_secret;
++ secretsSeen.push(secret1);
++ assert.match(secret1, /^ovsec_[A-Za-z0-9_-]{43}$/);
++ assert.strictEqual(r.body.credential.shown_once, true);
++ const cred1 = r.body.credential.id;
++
++ // Secrets are never returned again
++ for (const p of [`/${P}/apps/${A}`, `/${P}/apps`, `/${P}/apps/${A}/credentials`, `/${P}`, `/${P}/audit`]) {
++ const g = await api('owner', 'GET', p);
++ assert.strictEqual(g.status, 200, `${p}: ${g.text}`);
++ assert.ok(!g.text.includes(secret1), `${p} does not return the secret`);
++ assert.ok(!/secret_hash/.test(g.text), `${p} does not return the hash`);
++ }
++ r = await api('owner', 'GET', `/${P}/apps/${A}/credentials`);
++ assert.strictEqual(r.body.credentials[0].hint, secret1.slice(-4));
++ assert.strictEqual(r.body.credentials[0].state, 'active');
++
++ // ── Grants: catalog only, never first-party, never beyond the allowance ──
++ r = await api('dev', 'POST', `/${P}/apps/${A}/grants`, { capability: 'network.coins.credit' });
++ assert.strictEqual(r.status, 403); assert.strictEqual(r.body.code, 'grant.not_grantable', 'first-party capabilities are never grantable');
++ r = await api('owner', 'POST', `/${P}/apps/${A}/grants`, { capability: 'identity.subject.resolve' });
++ assert.strictEqual(r.status, 403); assert.strictEqual(r.body.code, 'grant.not_grantable');
++ r = await api('dev', 'POST', `/${P}/apps/${A}/grants`, { capability: 'nope.not.real' });
++ assert.strictEqual(r.status, 422); assert.strictEqual(r.body.code, 'grant.unknown_capability');
++ r = await api('dev', 'POST', `/${P}/apps/${A}/grants`, { capability: 'media.object.upload' });
++ assert.strictEqual(r.status, 201); assert.strictEqual(r.body.status, 'requested', 'developers request');
++ assert.strictEqual(r.body.audience, 'openvibe.media');
++ r = await api('owner', 'POST', `/${P}/apps/${A}/grants/media.object.upload/approve`);
++ assert.strictEqual(r.status, 403); assert.strictEqual(r.body.code, 'grant.beyond_allowance');
++ r = await api('owner', 'PUT', `/${P}/allowance`, { capabilities: ['media.object.upload'] });
++ assert.strictEqual(r.status, 403, 'only staff set the allowance');
++ r = await api('staff', 'PUT', `/${P}/allowance`, { capabilities: ['media.object.upload', 'network.modules.read'] });
++ assert.strictEqual(r.status, 422); assert.strictEqual(r.body.code, 'grant.not_grantable', 'staff cannot put first-party capabilities in an allowance');
++ r = await api('staff', 'PUT', `/${P}/allowance`, { capabilities: ['media.object.upload', 'media.object.read'] });
++ assert.strictEqual(r.status, 200, r.text);
++ r = await api('dev', 'POST', `/${P}/apps/${A}/grants/media.object.upload/approve`);
++ assert.strictEqual(r.status, 403, 'developers cannot approve');
++ r = await api('owner', 'POST', `/${P}/apps/${A}/grants/media.object.upload/approve`);
++ assert.strictEqual(r.status, 200); assert.strictEqual(r.body.status, 'approved');
++ r = await api('dev', 'POST', `/${P}/apps/${A}/grants`, { capability: 'media.object.read' });
++ assert.strictEqual(r.body.status, 'requested', 'pending, so not in tokens');
++ assert.ok(!policy.isGrantable('network.coins.credit') && policy.isGrantable('media.object.upload'));
++ assert.ok(policy.grantableCatalog().every(c => c.visibility === 'public'));
++
++ // ── Tokens carry only the granted capabilities ──
++ let t = await cc(A, secret1);
++ assert.strictEqual(t.status, 200, JSON.stringify(t.body));
++ assert.strictEqual(t.cache, 'no-store');
++ let v = verify(t.body.access_token);
++ assert.ok(v.ok, v.reason);
++ assert.strictEqual(v.claims.sub, `app:${A}`);
++ assert.strictEqual(v.claims.actor_type, 'app');
++ assert.deepStrictEqual(v.claims.cap, ['media.object.upload'], 'only the approved grant');
++ assert.strictEqual(v.claims.project_id, P);
++ assert.strictEqual(v.claims.env, 'sandbox');
++ assert.deepStrictEqual(v.claims.ns, [P]);
++ assert.strictEqual(v.claims.exp - v.claims.iat, 300);
++ assert.ok(validate('identity.service-token-claims@1', v.claims).valid);
++ r = await fetch(`${base}/api/v1/projects`, { headers: { authorization: `Bearer ${t.body.access_token}` } });
++ assert.strictEqual(r.status, 401, 'an app token is not a user');
++ r = await fetch(`${base}/api/v1/projects`, { headers: { authorization: `Bearer ${jwt.sign({ sub: 10, id: 10 }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: -10 })}` } });
++ assert.strictEqual(r.status, 401, 'no session grace here: an expired user token is refused');
++ t = await cc(A, secret1, 'openvibe.media', { scope: 'media.object.read' });
++ assert.strictEqual(t.status, 400); assert.strictEqual(t.body.error, 'invalid_scope');
++ t = await cc(A, 'ovsec_wrong');
++ assert.strictEqual(t.status, 401); assert.strictEqual(t.body.error, 'invalid_client');
++
++ // ── Sandbox: only opted-in audiences; receivers refuse env=sandbox ──
++ t = await cc(A, secret1, 'openvibe.tools');
++ assert.strictEqual(t.status, 400); assert.strictEqual(t.body.error, 'invalid_target', 'no sandbox token for an audience that did not opt in');
++ assert.strictEqual(policy.environmentDecision({ env: 'sandbox' }).code, 'token.sandbox_refused');
++ assert.ok(policy.environmentDecision({ env: 'sandbox' }, { acceptSandbox: true }).ok);
++ assert.ok(policy.environmentDecision({ env: 'production' }).ok);
++ assert.ok(policy.environmentDecision({}).ok, 'first-party service tokens carry no env');
++ const now = Math.floor(Date.now() / 1000);
++ const forged = (env) => serviceAuth.signServiceToken({ iss: ISSUER, sub: `app:${A}`, actor_type: 'app', aud: ['openvibe.network'], cap: ['identity.subject.resolve'],
++ project_id: P, env, iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(6).toString('hex')}` }, keys.privateKey);
++ const resolve = (tok) => fetch(`${base}/internal/identity/resolve?subject_id=${sid(10)}`, { headers: { authorization: `Bearer ${tok}` } }).then(async x => ({ status: x.status, body: await x.json() }));
++ r = await resolve(forged('sandbox'));
++ assert.strictEqual(r.status, 401); assert.strictEqual(r.body.code, 'token.sandbox_refused', 'Network (a production audience) refuses sandbox tokens');
++ r = await resolve(forged('production'));
++ assert.strictEqual(r.status, 200, 'the same token in production passes: only env differs');
++
++ // ── Rotation overlap, then expiry; revocation is immediate ──
++ r = await api('dev', 'POST', `/${P}/apps/${A}/credentials/rotate`, { overlap_seconds: 1 });
++ assert.strictEqual(r.status, 201, r.text);
++ const secret2 = r.body.credential.client_secret;
++ secretsSeen.push(secret2);
++ const cred2 = r.body.credential.id;
++ assert.notStrictEqual(secret2, secret1);
++ assert.strictEqual((await cc(A, secret1)).status, 200, 'old secret still valid during the overlap');
++ assert.strictEqual((await cc(A, secret2)).status, 200, 'new secret valid');
++ await new Promise(res => setTimeout(res, 1200));
++ assert.strictEqual((await cc(A, secret1)).status, 401, 'old secret dead after the overlap');
++ assert.strictEqual((await cc(A, secret2)).status, 200);
++ r = await api('owner', 'GET', `/${P}/apps/${A}/credentials`);
++ assert.strictEqual(r.body.credentials.find(c => c.id === cred1).state, 'expired');
++ r = await api('dev', 'POST', `/${P}/apps/${A}/credentials/${cred2}/revoke`);
++ assert.strictEqual(r.status, 200); assert.strictEqual(r.body.state, 'revoked');
++ t = await cc(A, secret2);
++ assert.strictEqual(t.status, 401, 'a revoked secret fails at once');
++ r = await api('dev', 'POST', `/${P}/apps/${A}/credentials/rotate`, { overlap_seconds: 0 });
++ const secret3 = r.body.credential.client_secret;
++ secretsSeen.push(secret3);
++ assert.strictEqual((await cc(A, secret3)).status, 200);
++
++ // ── Public app: authorization code + PKCE S256 required ──
++ r = await api('dev', 'POST', `/${P}/apps`, { name: 'Browser', type: 'public' });
++ assert.strictEqual(r.status, 422, 'a public app needs a redirect URI');
++ const REDIRECT = 'https://dev.example.com/callback';
++ r = await api('dev', 'POST', `/${P}/apps`, { name: 'Browser', type: 'public', redirect_uris: [REDIRECT] });
++ assert.strictEqual(r.status, 201, r.text);
++ assert.strictEqual(r.body.credential, undefined, 'public apps have no secret');
++ const B = r.body.id;
++ r = await api('owner', 'POST', `/${P}/apps/${B}/grants`, { capability: 'media.object.upload' });
++ assert.strictEqual(r.body.status, 'approved', 'owner inside the allowance approves at once');
++ const verifier = crypto.randomBytes(32).toString('base64url');
++ const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
++ const authorize = (q) => fetch(`${base}/oauth/authorize?${new URLSearchParams({ client_id: B, redirect_uri: REDIRECT, response_type: 'code', state: 's', ...q })}`, { redirect: 'manual' });
++ let a = await authorize({});
++ assert.strictEqual(a.status, 400, 'no PKCE, no authorization');
++ a = await authorize({ code_challenge: challenge, code_challenge_method: 'plain' });
++ assert.strictEqual(a.status, 400, 'plain PKCE refused');
++ a = await authorize({ code_challenge: challenge, code_challenge_method: 'S256', redirect_uri: 'https://evil.example.com/cb' });
++ assert.strictEqual(a.status, 400, 'unregistered redirect refused');
++ a = await authorize({ code_challenge: challenge, code_challenge_method: 'S256' });
++ assert.strictEqual(a.status, 302);
++ assert.match(new URL(a.headers.get('location')).searchParams.get('client_name'), /third-party app/);
++ a = await authorize({ code_challenge: challenge, code_challenge_method: 'S256', prompt: 'none' });
++ assert.strictEqual(new URL(a.headers.get('location')).searchParams.get('error'), 'interaction_required', 'no silent codes for apps');
++ const confirm = (who, extra = {}) => fetch(`${base}/oauth/confirm`, { method: 'POST', headers: { 'content-type': 'application/json' },
++ body: JSON.stringify({ token: T[who], client_id: B, redirect_uri: REDIRECT, state: 's', ...extra }) }).then(async x => ({ status: x.status, body: await x.json() }));
++ let c = await confirm('dev');
++ assert.strictEqual(c.status, 400, 'confirm without PKCE refused');
++ c = await confirm('stranger', { code_challenge: challenge, code_challenge_method: 'S256' });
++ assert.strictEqual(c.status, 403, 'sandbox apps are authorized only by project members');
++ const codeFor = async () => new URL((await confirm('dev', { code_challenge: challenge, code_challenge_method: 'S256' })).body.redirect).searchParams.get('code');
++ const exchange = (code, extra = {}) => token({ grant_type: 'authorization_code', client_id: B, code, redirect_uri: REDIRECT, audience: 'openvibe.media', ...extra });
++ t = await cc(B, '');
++ assert.strictEqual(t.body.error, 'unauthorized_client', 'public apps cannot use client_credentials');
++ let code = await codeFor();
++ t = await exchange(code);
++ assert.strictEqual(t.status, 400); assert.match(t.body.error_description, /PKCE/);
++ t = await exchange(code, { code_verifier: verifier });
++ assert.strictEqual(t.status, 400, 'a failed verification burns the code');
++ code = await codeFor();
++ t = await exchange(code, { code_verifier: verifier });
++ assert.strictEqual(t.status, 200, JSON.stringify(t.body));
++ v = verify(t.body.access_token);
++ assert.strictEqual(v.claims.sub, `app:${B}`);
++ assert.strictEqual(v.claims.on_behalf_of, sid(11));
++ assert.deepStrictEqual(v.claims.cap, ['media.object.upload']);
++ t = await exchange(code, { code_verifier: verifier });
++ assert.strictEqual(t.status, 400, 'codes are single-use');
++
++ // ── Shrinking the allowance revokes grants outside it ──
++ r = await api('staff', 'PUT', `/${P}/allowance`, { capabilities: ['media.object.read'] });
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.body.trimmed.some(x => x.app_id === A && x.capability === 'media.object.upload' && x.status === 'revoked'));
++ t = await cc(A, secret3);
++ assert.strictEqual(t.status, 400); assert.strictEqual(t.body.error, 'invalid_scope', 'no capability outside the allowance survives');
++ r = await api('staff', 'PUT', `/${P}/allowance`, { capabilities: ['media.object.upload', 'media.object.read'] });
++ r = await api('owner', 'POST', `/${P}/apps/${A}/grants`, { capability: 'media.object.upload' });
++ assert.strictEqual(r.body.status, 'approved');
++
++ // ── Production needs staff; developers cannot manage production apps ──
++ r = await api('owner', 'POST', `/${P}/apps`, { name: 'Prod', environment: 'production' });
++ assert.strictEqual(r.status, 403); assert.strictEqual(r.body.code, 'app.environment_not_allowed');
++ r = await api('owner', 'PUT', `/${P}/environment-policy`, { environment_policy: 'sandbox+production' });
++ assert.strictEqual(r.status, 403);
++ r = await api('staff', 'PUT', `/${P}/environment-policy`, { environment_policy: 'sandbox+production' });
++ assert.strictEqual(r.status, 200);
++ r = await api('owner', 'POST', `/${P}/apps`, { name: 'Prod', environment: 'production', redirect_uris: ['http://localhost:3000/cb'] });
++ assert.strictEqual(r.status, 422, 'loopback redirects are sandbox-only');
++ r = await api('owner', 'POST', `/${P}/apps`, { name: 'Prod', environment: 'production' });
++ assert.strictEqual(r.status, 201, r.text);
++ const C = r.body.id;
++ secretsSeen.push(r.body.credential.client_secret);
++ r = await api('dev', 'POST', `/${P}/apps/${C}/credentials/rotate`);
++ assert.strictEqual(r.status, 403, 'developers do not rotate production secrets');
++ await api('owner', 'POST', `/${P}/apps/${C}/grants`, { capability: 'media.object.read' });
++ t = await cc(C, secretsSeen[secretsSeen.length - 1]);
++ assert.strictEqual(t.status, 200, 'production app tokens need no sandbox opt-in');
++ assert.strictEqual(verify(t.body.access_token).claims.env, 'production');
++
++ // ── Quotas: staff set, members read ──
++ r = await api('owner', 'PUT', `/${P}/quotas/media.object.upload`, { limit: 10, window: 'day', unit: 'bytes' });
++ assert.strictEqual(r.status, 403);
++ r = await api('staff', 'PUT', `/${P}/quotas/media.object.upload`, { limit: 1073741824, window: 'total', unit: 'bytes' });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.body.enforced_by, 'openvibe.media');
++ r = await api('viewer', 'GET', `/${P}/quotas`);
++ assert.deepStrictEqual(r.body.quotas.map(q => [q.capability, q.limit, q.window, q.unit]), [['media.object.upload', 1073741824, 'total', 'bytes']]);
++ r = await api('viewer', 'GET', `/${P}/audit`);
++ assert.strictEqual(r.status, 403, 'audit is for admins and owners');
++
++ // ── App revocation is immediate for new tokens ──
++ r = await api('dev', 'DELETE', `/${P}/apps/${A}`);
++ assert.strictEqual(r.status, 200); assert.ok(r.body.revoked_at);
++ t = await cc(A, secret3);
++ assert.strictEqual(t.status, 401); assert.strictEqual(t.body.error, 'invalid_client');
++
++ // ── Events recorded in the append-only audit ──
++ r = await api('owner', 'GET', `/${P}/audit?limit=200`);
++ assert.strictEqual(r.status, 200);
++ const types = new Set(r.body.entries.map(e => e.event_type).filter(Boolean));
++ for (const e of ['network.app.created', 'network.app.revoked', 'network.credential.rotated', 'network.credential.revoked', 'network.grant.changed']) assert.ok(types.has(e), `event ${e}`);
++ for (const row of db.prepare('SELECT event FROM dev_audit WHERE event IS NOT NULL').all()) {
++ const env = JSON.parse(row.event);
++ assert.ok(validate('events.event-envelope@1', env).valid, JSON.stringify(env));
++ assert.strictEqual(env.source, 'network');
++ }
++ assert.throws(() => db.prepare('UPDATE dev_audit SET action = ?').run('x'), /append-only/);
++ assert.throws(() => db.prepare('DELETE FROM dev_audit').run(), /append-only/);
++
++ // ── Archive revokes every app ──
++ r = await api('dev', 'POST', `/${P}/archive`);
++ assert.strictEqual(r.status, 403);
++ r = await api('owner', 'POST', `/${P}/archive`);
++ assert.strictEqual(r.status, 200); assert.ok(r.body.archived_at);
++ t = await cc(C, secretsSeen[secretsSeen.length - 1]);
++ assert.strictEqual(t.status, 401);
++ r = await api('owner', 'POST', `/${P}/apps`, { name: 'Late' });
++ assert.strictEqual(r.status, 409);
++
++ // ── Secrets: never stored in plaintext, never logged ──
++ const tables = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map(x => x.name);
++ for (const s of secretsSeen) {
++ for (const name of tables) {
++ const dump = JSON.stringify(db.prepare(`SELECT * FROM "${name}"`).all());
++ assert.ok(!dump.includes(s), `secret stored in plaintext in ${name}`);
++ }
++ assert.ok(!logged.some(l => l.includes(s)), 'secret appeared in logs');
++ }
++
++ out('developer projects: all checks passed');
++ server.close();
++})().catch(err => { orig.error(err); process.exit(1); });