openvibers/openvibe.network Commits

Internal-key retirement (register C-50/C-52): GET /internal/url-registry/resolved, GET /internal/coins/stats, POST /internal/resolve-anon, POST /internal/identity/legacy-map and POST /internal/link-account (the routes principal_usage still shows called with X-Internal-Key) now also take a service token through principals.guard, and keep the key so callers switch on their own schedule. Capabilities are existing contracts names: identity.subject.resolve for the registry read, anon resolution, legacy-map writes and account links, network.coins.credit for the coin totals. A token may only map ids of its own source_system and only report links for its own service (capability.owner_denied); the key keeps its old behaviour. resolve-batch was already guarded. No grant was added: the only caller of the five is Live, which already holds both capabilities on openvibe.network (Media's resolve-batch grant landed in c3b7cf3; Chat calls none of them, Tools none). docs/retirement.md lists every token route with its capability, rule and holders, and what Live changes (TOKEN_PATHS in server/net/network-principal.js). test/principals.test.js: token, narrow token, ownership, key and usage rows for each

OP OpenVibers committed 24 Sept 2026 · 829dafa5cc · 4 files, +86 −9


Changes

ddiff --git a/README.md b/README.md
iindex 0ab18eb2..939f406b 100644
---- a/README.md
++++ b/README.md
@@@ -17,7 +17,7 @@ Identity and account service for the OpenVibe network. Manages user accounts, OA
- **Multi-Account Switching** — Google-style account management. Users can add multiple accounts and switch instantly, including an anonymous mode.
- **Theme Catalog** — Shared theme system with ~30 built-in themes and community submissions. Theme preferences sync across all services.
- **Admin Panel** — email configuration, user management (role changes, bans), broadcast notifications, system health dashboard, and audit log.
--- **Internal API** — Server-to-server endpoints for token verification, user lookup, notification push, account linking, and audit logging. Protected by `X-Internal-Key`.
++- **Internal API** — Server-to-server endpoints for token verification, user lookup, notification push, account linking, and audit logging. Protected by `X-Internal-Key`; the routes listed in [docs/retirement.md](docs/retirement.md#routes-that-take-a-token-network-side-of-step-2) also take a capability-scoped service token, and the key is retired route by route.
- **Developer projects (foundation)** — projects, members, apps (OAuth clients with `app_` subjects), hashed client secrets with rotation and revocation, capability grants within a staff-set allowance (sandbox apps also get a default sandbox allowance of public Media, Events-app and Tools-job capabilities, so a new project works without staff), recorded quotas and an append-only audit at `/api/v1/projects`. Its events can be relayed to OpenVibe.Events through an outbox when `OV_EVENTS_INTERNAL_URL` is set. Bearer user tokens only; no portal UI yet (that is OpenVibe.Codes). See [docs/developer-projects.md](docs/developer-projects.md).
- **Public discovery** — `/.well-known/openvibe`, `/api/v1/registry/*` and `/contracts/*.json` (services, capabilities, contract schemas) answer any origin with `Access-Control-Allow-Origin: *`, preflight included, so a browser app can discover the platform directly. Every other route keeps the first-party CORS allow-list (`server/public-cors.js`).
- **OpenCoins Wallet** — Network-wide currency. User balance/history at `/api/coins/*`; atomic credit/debit/transfer for services at `/internal/coins/*` with idempotency-key dedupe.
ddiff --git a/docs/retirement.md b/docs/retirement.md
iindex b8e8794a..79eb758d 100644
---- a/docs/retirement.md
++++ b/docs/retirement.md
@@@ -29,3 +29,23 @@ GROUP BY route ORDER BY calls DESC;
5. When no route accepts the key, delete `INTERNAL_API_KEY` from every env file and the middleware.
First observation (2026-09-23 04:50 UTC): `GET /internal/url-registry/resolved` (Live's registry refresh).
++
++## Routes that take a token (Network side of step 2)
++
++| Route | Capability | Rule for a token | Default grant holders |
++| --- | --- | --- | --- |
++| `GET /internal/identity/resolve`, `POST /internal/identity/resolve-batch` | `identity.subject.resolve` | — | Live, Media, Chat, Community, Billing, Tips, VIP, Blog, News, Deals, Games |
++| `POST /internal/coins/credit`, `/debit` (`/transfer`: nobody holds it) | `network.coins.credit` / `.debit` | `app_id` = its own service | Live |
++| `POST /internal/notifications/push`, `/push-bulk`, `/events/stream-live` | `network.notifications.push` | `service` = its own service | Live |
++| `GET\|PUT\|DELETE /internal/modules/:ns/:subject` | `network.modules.read` / `.write` | granted namespaces | Live, Chat, Tools, Games |
++| `GET /internal/url-registry/resolved` | `identity.subject.resolve` | — | Live (its only caller; still sends the key) |
++| `GET /internal/coins/stats` | `network.coins.credit` | — | Live (its only caller; still sends the key) |
++| `POST /internal/resolve-anon` | `identity.subject.resolve` | — | Live (its only caller; still sends the key) |
++| `POST /internal/identity/legacy-map` | `identity.subject.resolve` | every `source_system` = its own service | Live (its only caller; still sends the key) |
++| `POST /internal/link-account` | `identity.subject.resolve` | `service` = its own service | Live (its only caller; still sends the key) |
++
++The last five accept a token since 2026-09-24 and keep the key. No new grant was needed: their only caller,
++Live, already holds `identity.subject.resolve` and `network.coins.credit` on `openvibe.network`. Live switches by
++adding the five paths to `TOKEN_PATHS` in its `server/net/network-principal.js` and sending those calls through
++`headersFor(path)`. The capability names are the closest existing ones in openvibe-contracts 0.33; narrower ones
++(`network.registry.read`, `network.coins.read`, an identity legacy-map write) are a contracts change.
ddiff --git a/server/internal/routes.js b/server/internal/routes.js
iindex 10424f52..00972810 100644
---- a/server/internal/routes.js
++++ b/server/internal/routes.js
@@@ -56,15 +56,32 @@ function requireInternalKey(req, res, next) {
return res.status(403).json({ error: 'Invalid or missing internal key' });
}
const principals = require('../identity/principals');
--const TOKEN_ROUTES = new Set(['GET /identity/resolve', 'POST /identity/resolve-batch', 'POST /coins/credit', 'POST /coins/debit', 'POST /coins/transfer', 'POST /notifications/push', 'POST /notifications/push-bulk', 'POST /events/stream-live']);
++const TOKEN_ROUTES = new Set(['GET /identity/resolve', 'POST /identity/resolve-batch', 'POST /coins/credit', 'POST /coins/debit', 'POST /coins/transfer', 'POST /notifications/push', 'POST /notifications/push-bulk', 'POST /events/stream-live',
++ // Internal-key retirement (register C-50/C-52): the routes Live still calls with the key also take a token.
++ 'GET /url-registry/resolved', 'GET /coins/stats', 'POST /resolve-anon', 'POST /identity/legacy-map', 'POST /link-account']);
const TOKEN_ROUTE_PATTERNS = [/^(GET|PUT|DELETE) \/modules\/[a-z0-9_.]+\/[A-Za-z0-9_]+$/];
const forApp = (req) => (req.body && req.body.app_id !== undefined ? String(req.body.app_id) : undefined);
const forService = (req) => (req.body && req.body.service !== undefined ? String(req.body.service) : undefined);
++// A service token may only map ids of its own system (svc:live -> source_system 'live'); the key is unchanged.
++function ownSourceSystem(req, res, next) {
++ const p = req.principal;
++ if (!p || p.legacy) return next();
++ const self = String(p.sub).replace(/^svc:/, '');
++ const entries = req.body && Array.isArray(req.body.entries) ? req.body.entries : [];
++ if (entries.some(e => !e || String(e.source_system) !== self)) {
++ return require('openvibe-contracts').http.sendProblem(res, 403, 'capability.owner_denied', { detail: `${p.sub} may only map ids whose source_system is '${self}'` });
++ }
++ next();
++}
++
router.use(requireInternalKey);
// Identity lookups accept a service token with identity.subject.resolve (or the key, as before).
router.get('/identity/resolve', principals.guard('identity.subject.resolve'));
router.post('/identity/resolve-batch', principals.guard('identity.subject.resolve'));
++// Writing the legacy map is part of identity resolution (ADR-001; identity.subject.resolve takes
++// identity.legacy-identity-map@1). TODO(contracts): a narrower identity.legacy_map.write capability.
++router.post('/identity/legacy-map', principals.guard('identity.subject.resolve'), ownSourceSystem);
router.use('/identity', require('../identity/internal-routes'));
// ── Verify Token ─────────────────────────────────────────────
@@@ -145,7 +162,8 @@ router.get('/users/:id/theme', (req, res) => {
// ── Sync Linked Account ──────────────────────────────────────
// When a user connects their OpenVibe.Live or OpenVibe.Games account,
// the service reports the link here.
--router.post('/link-account', (req, res) => {
++// A service token may only report links for its own service (svc:live -> service 'live').
++router.post('/link-account', principals.guard('identity.subject.resolve', { ownApp: forService }), (req, res) => {
const { user_id, service, service_user_id, service_username, avatar_url, display_name } = req.body;
if (!user_id || !service || !service_user_id) {
return res.status(400).json({ error: 'user_id, service, and service_user_id required' });
@@@ -221,7 +239,9 @@ router.get('/stats', (req, res) => {
res.json({ users: userCount, themes: themeCount, linked_accounts: linkedCount, notifications: notifCount });
});
--router.get('/url-registry/resolved', (req, res) => {
++// Service URLs only (no secrets); any first-party service that resolves identities may read them.
++// TODO(contracts): a network.registry.read capability would say it more exactly.
++router.get('/url-registry/resolved', principals.guard('identity.subject.resolve'), (req, res) => {
try {
const db = getDb(req);
const resolved = urlRegistry.getResolvedRegistry(db, process.env);
@@@ -251,7 +271,8 @@ function handleWalletError(res, err) {
// Summing the whole ledger is cheap at our size but pointless to repeat per pageview,
// so the answer is held for a minute.
let _coinStats = { at: 0, data: null };
--router.get('/coins/stats', (req, res) => {
++// Guarded by the ledger capability its caller (Live's home hero) holds. TODO(contracts): network.coins.read.
++router.get('/coins/stats', principals.guard('network.coins.credit'), (req, res) => {
try {
if (_coinStats.data && Date.now() - _coinStats.at < 60_000) return res.json(_coinStats.data);
const db = getDb(req);
@@@ -556,7 +577,7 @@ router.post('/issue-token', (req, res) => {
// Body: { ip }
// Called by first-party services to get or create a unified
// anon identity for a given IP address. Single source of truth.
--router.post('/resolve-anon', (req, res) => {
++router.post('/resolve-anon', principals.guard('identity.subject.resolve'), (req, res) => {
const { ip } = req.body;
if (!ip) return res.status(400).json({ error: 'ip required' });
ddiff --git a/test/principals.test.js b/test/principals.test.js
iindex 854d4a88..ba15e92b 100644
---- a/test/principals.test.js
++++ b/test/principals.test.js
@@@ -99,9 +99,9 @@ const server = http.createServer(app);
assert.strictEqual(r.body.code, 'token.wrong_audience');
r = await post('/internal/notifications/push', { user_id: 7, service: 'games', title: 'x' }, { authorization: `Bearer ${full}` });
assert.strictEqual(r.status, 403, 'notifications are sent only as the principal\'s own service');
-- r = await fetch(`${base}/internal/coins/stats`, { headers: { authorization: `Bearer ${full}` } }).then(x => ({ status: x.status }));
-- assert.strictEqual(r.status, 403, 'unguarded internal routes still need the key');
-- r = await post('/internal/identity/legacy-map', { entries: [] }, { authorization: `Bearer ${full}` });
++ r = await fetch(`${base}/internal/stats`, { headers: { authorization: `Bearer ${full}` } }).then(x => ({ status: x.status }));
++ assert.strictEqual(r.status, 403, 'key-only internal routes still need the key');
++ r = await post('/internal/audit', { action: 'x' }, { authorization: `Bearer ${full}` });
assert.strictEqual(r.status, 403, 'token routes are an explicit list');
// Community resolves authors with its own token; Live's token lacks that grant.
@@@ -131,6 +131,42 @@ const server = http.createServer(app);
r = await post('/internal/events/stream-live', {}, { authorization: `Bearer ${creditOnly}` });
assert.strictEqual(r.status, 403);
++ // ── The routes Live still called with the key (register C-50/C-52) take its token too ──
++ const get = (p, headers) => fetch(base + p, { headers }).then(async x => ({ status: x.status, body: await x.json() }));
++ const KEY = { 'x-internal-key': 'legacy-key' };
++ const LIVE = { authorization: `Bearer ${full}` };
++ r = await get('/internal/url-registry/resolved', LIVE);
++ assert.strictEqual(r.status, 200, JSON.stringify(r.body)); assert.strictEqual(r.body.ok, true);
++ assert.strictEqual((await get('/internal/url-registry/resolved', { authorization: `Bearer ${creditOnly}` })).status, 403, 'needs identity.subject.resolve');
++ r = await get('/internal/coins/stats', LIVE);
++ assert.strictEqual(r.status, 200, JSON.stringify(r.body)); assert.ok(Number.isFinite(r.body.earned) && r.body.recent);
++ assert.strictEqual((await get('/internal/coins/stats', { authorization: `Bearer ${mediaResolve}` })).status, 403, 'needs network.coins.credit');
++ r = await post('/internal/resolve-anon', { ip: '203.0.113.9' }, LIVE);
++ assert.strictEqual(r.status, 200, JSON.stringify(r.body)); assert.ok(r.body.anon_number >= 1);
++ assert.strictEqual((await post('/internal/resolve-anon', { ip: '203.0.113.9' }, { authorization: `Bearer ${creditOnly}` })).status, 403);
++ r = await post('/internal/identity/legacy-map', { entries: [{ network_user_id: 7, source_system: 'live', source_type: 'user', source_id: '900' }] }, LIVE);
++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
++ r = await post('/internal/identity/legacy-map', { entries: [{ network_user_id: 7, source_system: 'games', source_type: 'user', source_id: '900' }] }, LIVE);
++ assert.strictEqual(r.status, 403); assert.strictEqual(r.body.code, 'capability.owner_denied', 'a token maps only its own system\'s ids');
++ r = await post('/internal/identity/legacy-map', { entries: [{ network_user_id: 7, source_system: 'live', source_id: '901' }] }, { authorization: `Bearer ${creditOnly}` });
++ assert.strictEqual(r.status, 403);
++ r = await post('/internal/link-account', { user_id: 7, service: 'live', service_user_id: '4242' }, LIVE);
++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
++ assert.strictEqual(db.prepare("SELECT service_user_id FROM linked_accounts WHERE user_id = 7 AND service = 'live'").get().service_user_id, '4242');
++ r = await post('/internal/link-account', { user_id: 7, service: 'games', service_user_id: '4242' }, LIVE);
++ assert.strictEqual(r.status, 403); assert.strictEqual(r.body.code, 'capability.owner_denied', 'a token links accounts only for its own service');
++ // The key still works on all five (callers switch later), unchanged: no ownership rule for the key.
++ assert.strictEqual((await get('/internal/url-registry/resolved', KEY)).status, 200);
++ assert.strictEqual((await get('/internal/coins/stats', KEY)).status, 200);
++ assert.strictEqual((await post('/internal/resolve-anon', { ip: '203.0.113.10' }, KEY)).status, 200);
++ assert.strictEqual((await post('/internal/identity/legacy-map', { entries: [{ network_user_id: 8, source_system: 'games', source_id: '77' }] }, KEY)).status, 200);
++ assert.strictEqual((await post('/internal/link-account', { user_id: 8, service: 'games', service_user_id: '77' }, KEY)).status, 200);
++ for (const p of ['/internal/url-registry/resolved', '/internal/coins/stats']) assert.strictEqual((await get(p, {})).status, 403, `${p}: neither key nor token`);
++ const decided = db.prepare("SELECT route FROM principal_usage WHERE principal = 'svc:live' AND allowed = 1").all().map(x => x.route);
++ for (const route of ['GET /internal/url-registry/resolved', 'GET /internal/coins/stats', 'POST /internal/resolve-anon', 'POST /internal/identity/legacy-map', 'POST /internal/link-account']) {
++ assert.ok(decided.includes(route), `token use of ${route} is recorded`);
++ }
++
// ── Legacy key keeps working ──
r = await post('/internal/coins/credit', credit(), { 'x-internal-key': 'legacy-key' });
assert.strictEqual(r.status, 200);