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
Changes
@@@ -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.@@@ -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.@@@ -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' });@@@ -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);
@@ -17,7 +17,7 @@ Identity and account service for the OpenVibe network. Manages user accounts, OA
@@ -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.
- **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.
- **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.
- **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).
- **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`).
- **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.
- **OpenCoins Wallet** — Network-wide currency. User balance/history at `/api/coins/*`; atomic credit/debit/transfer for services at `/internal/coins/*` with idempotency-key dedupe.
@@ -29,3 +29,23 @@ GROUP BY route ORDER BY calls DESC;
@@ -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.
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).
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.
@@ -56,15 +56,32 @@ function requireInternalKey(req, res, next) {
@@ -56,15 +56,32 @@ function requireInternalKey(req, res, next) {
return res.status(403).json({ error: 'Invalid or missing internal key' });
return res.status(403).json({ error: 'Invalid or missing internal key' });
}
}
const principals = require('../identity/principals');
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 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 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);
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);
router.use(requireInternalKey);
// Identity lookups accept a service token with identity.subject.resolve (or the key, as before).
// 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.get('/identity/resolve', principals.guard('identity.subject.resolve'));
router.post('/identity/resolve-batch', 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'));
router.use('/identity', require('../identity/internal-routes'));
// ── Verify Token ─────────────────────────────────────────────
// ── Verify Token ─────────────────────────────────────────────
@@ -145,7 +162,8 @@ router.get('/users/:id/theme', (req, res) => {
@@ -145,7 +162,8 @@ router.get('/users/:id/theme', (req, res) => {
// ── Sync Linked Account ──────────────────────────────────────
// ── Sync Linked Account ──────────────────────────────────────
// When a user connects their OpenVibe.Live or OpenVibe.Games account,
// When a user connects their OpenVibe.Live or OpenVibe.Games account,
// the service reports the link here.
// 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;
const { user_id, service, service_user_id, service_username, avatar_url, display_name } = req.body;
if (!user_id || !service || !service_user_id) {
if (!user_id || !service || !service_user_id) {
return res.status(400).json({ error: 'user_id, service, and service_user_id required' });
return res.status(400).json({ error: 'user_id, service, and service_user_id required' });
@@ -221,7 +239,9 @@ router.get('/stats', (req, res) => {
@@ -221,7 +239,9 @@ router.get('/stats', (req, res) => {
res.json({ users: userCount, themes: themeCount, linked_accounts: linkedCount, notifications: notifCount });
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 {
try {
const db = getDb(req);
const db = getDb(req);
const resolved = urlRegistry.getResolvedRegistry(db, process.env);
const resolved = urlRegistry.getResolvedRegistry(db, process.env);
@@ -251,7 +271,8 @@ function handleWalletError(res, err) {
@@ -251,7 +271,8 @@ function handleWalletError(res, err) {
// Summing the whole ledger is cheap at our size but pointless to repeat per pageview,
// Summing the whole ledger is cheap at our size but pointless to repeat per pageview,
// so the answer is held for a minute.
// so the answer is held for a minute.
let _coinStats = { at: 0, data: null };
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 {
try {
if (_coinStats.data && Date.now() - _coinStats.at < 60_000) return res.json(_coinStats.data);
if (_coinStats.data && Date.now() - _coinStats.at < 60_000) return res.json(_coinStats.data);
const db = getDb(req);
const db = getDb(req);
@@ -556,7 +577,7 @@ router.post('/issue-token', (req, res) => {
@@ -556,7 +577,7 @@ router.post('/issue-token', (req, res) => {
// Body: { ip }
// Body: { ip }
// Called by first-party services to get or create a unified
// Called by first-party services to get or create a unified
// anon identity for a given IP address. Single source of truth.
// 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;
const { ip } = req.body;
if (!ip) return res.status(400).json({ error: 'ip required' });
if (!ip) return res.status(400).json({ error: 'ip required' });
@@ -99,9 +99,9 @@ const server = http.createServer(app);
@@ -99,9 +99,9 @@ const server = http.createServer(app);
assert.strictEqual(r.body.code, 'token.wrong_audience');
assert.strictEqual(r.body.code, 'token.wrong_audience');
r = await post('/internal/notifications/push', { user_id: 7, service: 'games', title: 'x' }, { authorization: `Bearer ${full}` });
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');
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');
assert.strictEqual(r.status, 403, 'token routes are an explicit list');
// Community resolves authors with its own token; Live's token lacks that grant.
// Community resolves authors with its own token; Live's token lacks that grant.
@@ -131,6 +131,42 @@ const server = http.createServer(app);
@@ -131,6 +131,42 @@ const server = http.createServer(app);
r = await post('/internal/events/stream-live', {}, { authorization: `Bearer ${creditOnly}` });
r = await post('/internal/events/stream-live', {}, { authorization: `Bearer ${creditOnly}` });
assert.strictEqual(r.status, 403);
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 ──
// ── Legacy key keeps working ──
r = await post('/internal/coins/credit', credit(), { 'x-internal-key': 'legacy-key' });
r = await post('/internal/coins/credit', credit(), { 'x-internal-key': 'legacy-key' });
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);