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
Changes
@@@ -20,6 +20,16 @@ INTERNAL_API_KEY=change-me-to-a-random-64-char-stringSETUP_TOKEN=change-me-to-a-second-random-secretBOOTSTRAP_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 runADMIN_USERNAME=adminADMIN_PASSWORD=change-me@@@ -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"},@@@ -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 clientconst 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 tokenconst 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'],});});@@@ -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,++ },++// Databasedb: {path: process.env.DB_PATH || './data/network.db',@@@ -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;nnew file mode 100644@@@ -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,++};nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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,++};nnew file mode 100644@@@ -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 };@@@ -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:/, '');@@@ -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));nnew file mode 100644@@@ -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); });
@@ -20,6 +20,16 @@ INTERNAL_API_KEY=change-me-to-a-random-64-char-string
@@ -20,6 +20,16 @@ INTERNAL_API_KEY=change-me-to-a-random-64-char-string
SETUP_TOKEN=change-me-to-a-second-random-secret
SETUP_TOKEN=change-me-to-a-second-random-secret
BOOTSTRAP_PROFILE=local-dev
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 account auto-created on first run
ADMIN_USERNAME=admin
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-me
ADMIN_PASSWORD=change-me
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
"dev": "NODE_ENV=development 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",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
"grant-admin": "node server/grant-admin.js"
},
},
@@ -10,6 +10,7 @@ const crypto = require('crypto');
@@ -10,6 +10,7 @@ const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const jwt = require('jsonwebtoken');
const subjects = require('../identity/subjects');
const subjects = require('../identity/subjects');
const principals = require('../identity/principals');
const principals = require('../identity/principals');
+const devTokens = require('../developer/tokens');
const router = express.Router();
const router = express.Router();
function getDb(req) { return req.app.locals.db; }
function getDb(req) { return req.app.locals.db; }
@@ -55,6 +56,19 @@ router.get('/authorize', (req, res) => {
@@ -55,6 +56,19 @@ router.get('/authorize', (req, res) => {
return res.status(400).json({ error: 'client_id and redirect_uri are required' });
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
// Validate client
const client = db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get(client_id);
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' });
if (!client) return res.status(400).json({ error: 'Unknown client_id' });
@@ -111,17 +125,22 @@ router.post('/confirm', (req, res) => {
@@ -111,17 +125,22 @@ router.post('/confirm', (req, res) => {
return res.status(400).json({ error: 'token, client_id, and redirect_uri are required' });
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
// Verify the token
const publicKey = req.app.locals.publicKey;
const publicKey = req.app.locals.publicKey;
@@ -138,6 +157,13 @@ router.post('/confirm', (req, res) => {
@@ -138,6 +157,13 @@ router.post('/confirm', (req, res) => {
return res.status(403).json({ error: 'User not found or banned' });
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)
// 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 });
const code = issueCode(db, { clientId: client_id, userId: user.id, redirectUri: redirect_uri, scope, pkce });
@@ -156,6 +182,13 @@ router.post('/token', (req, res) => {
@@ -156,6 +182,13 @@ router.post('/token', (req, res) => {
const config = getConfig(req);
const config = getConfig(req);
const { grant_type, client_id, client_secret, code, redirect_uri, refresh_token } = req.body;
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
// 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.
// short-lived, capability-scoped token. Checked before the user-grant path below.
if (grant_type === 'client_credentials') {
if (grant_type === 'client_credentials') {
@@ -353,11 +386,11 @@ router.get('/.well-known/openid-configuration', (req, res) => {
@@ -353,11 +386,11 @@ router.get('/.well-known/openid-configuration', (req, res) => {
userinfo_endpoint: `${config.baseUrl}/api/auth/me`,
userinfo_endpoint: `${config.baseUrl}/api/auth/me`,
jwks_uri: `${config.baseUrl}/api/.well-known/jwks`,
jwks_uri: `${config.baseUrl}/api/.well-known/jwks`,
response_types_supported: ['code'],
response_types_supported: ['code'],
- grant_types_supported: ['authorization_code', 'refresh_token'],
+ grant_types_supported: ['authorization_code', 'refresh_token', 'client_credentials'],
subject_types_supported: ['public'],
subject_types_supported: ['public'],
id_token_signing_alg_values_supported: ['RS256'],
id_token_signing_alg_values_supported: ['RS256'],
scopes_supported: ['profile', 'theme', 'openid'],
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'],
code_challenge_methods_supported: ['S256'],
});
});
});
});
@@ -28,6 +28,19 @@ module.exports = {
@@ -28,6 +28,19 @@ module.exports = {
// Internal API key for server-to-server calls (X-Internal-Key)
// Internal API key for server-to-server calls (X-Internal-Key)
internalKey: process.env.INTERNAL_API_KEY || 'change-me-in-production',
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
// Database
db: {
db: {
path: process.env.DB_PATH || './data/network.db',
path: process.env.DB_PATH || './data/network.db',
@@ -801,6 +801,8 @@ function initDb(dbPath) {
@@ -801,6 +801,8 @@ function initDb(dbPath) {
require('../identity/principals').ensureSchema(db);
require('../identity/principals').ensureSchema(db);
// Versioned user modules (server/identity/modules.js).
// Versioned user modules (server/identity/modules.js).
require('../identity/modules').ensureSchema(db);
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');
console.log('[DB] Central database initialized');
return db;
return db;
new file mode 100644
new file mode 100644
@@ -0,0 +1,80 @@
@@ -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,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,133 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,709 @@
@@ -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,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,162 @@
@@ -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 };
@@ -229,6 +229,18 @@ function guard(capability, { ownApp, namespace, legacy = true } = {}) {
@@ -229,6 +229,18 @@ function guard(capability, { ownApp, namespace, legacy = true } = {}) {
}
}
check(req, res, () => {
check(req, res, () => {
const principal = req.principal;
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) {
if (ownApp && principal && !principal.legacy) {
const app = ownApp(req);
const app = ownApp(req);
const self = String(principal.sub).replace(/^svc:/, '');
const self = String(principal.sub).replace(/^svc:/, '');
@@ -421,6 +421,10 @@ app.use('/api/coins', createCoinsRoutes(db, requireAuth));
@@ -421,6 +421,10 @@ app.use('/api/coins', createCoinsRoutes(db, requireAuth));
// Versioned user modules: portable per-person preferences and summaries (server/identity/modules.js).
// Versioned user modules: portable per-person preferences and summaries (server/identity/modules.js).
app.use('/api/modules', require('./identity/modules').userRouter(requireAuth));
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)
// Notification API (authenticated users)
app.use('/api/notifications', createNotificationRoutes(db, notificationService, requireAuth));
app.use('/api/notifications', createNotificationRoutes(db, notificationService, requireAuth));
new file mode 100644
new file mode 100644
@@ -0,0 +1,360 @@
@@ -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); });