openvibers/openvibe.network Commits

POST /internal/identity/resolve-batch (500 per call); identity lookups accept identity.subject.resolve tokens; Wave 5 grants: community resolves authors and uploads screenshots, live writes pastes into Community

OP OpenVibers committed 22 Sept 2026 · 8ac5a0f25b · 7 files, +65 −6


Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 0f40e9c8..c1f64a01 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -20,7 +20,7 @@
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.4.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.5.0",
"openvibe-shared": "file:./packages/openvibe-shared",
"uuid": "^11.1.0",
"web-push": "^3.6.7"
@@@ -1464,9 +1464,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.4.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.4.0",
-- "integrity": "sha512-nzlY6MF75Xfl22fIr2LLf96FDv4u/3ry7jYRazpFJzrHGD6jwa7k/FsjDVj/wZ0IcwpUUwPqIMQBqyh4O1/URA==",
++ "version": "0.5.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.5.0",
++ "integrity": "sha512-D9+WCb3A2dja7/dP9kZEZvCRe2ckwq8pEeegevEjVekOZG9vsbehjdfCK/Mcggo+ccFECwJcA0j4IrX8owOZSA==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex 3179b2d1..1fe6040c 100644
---- a/package.json
++++ b/package.json
@@@ -23,7 +23,7 @@
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.4.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.5.0",
"openvibe-shared": "file:./packages/openvibe-shared",
"uuid": "^11.1.0",
"web-push": "^3.6.7"
ddiff --git a/server/identity/internal-routes.js b/server/identity/internal-routes.js
iindex 2942117b..7569025b 100644
---- a/server/identity/internal-routes.js
++++ b/server/identity/internal-routes.js
@@@ -26,6 +26,29 @@ router.get('/resolve', (req, res) => {
res.json(out);
});
++/**
++ * POST /internal/identity/resolve-batch
++ * { subject_ids: [...] } or { system, type = 'user', ids: [...] } (max 500)
++ * -> { results: { <subject_id | id>: projection | null } }
++ * Used by services that show authors (Community pastes) and by importers mapping legacy ids.
++ */
++router.post('/resolve-batch', express.json({ limit: '256kb' }), (req, res) => {
++ const db = req.app.locals.db;
++ const b = req.body || {};
++ const list = Array.isArray(b.subject_ids) ? b.subject_ids : Array.isArray(b.ids) ? b.ids : null;
++ if (!list || !list.length) return http.sendProblem(res, 400, 'identity.bad_request', { detail: 'subject_ids or system + ids required', ctx: req.ov });
++ if (list.length > 500) return http.sendProblem(res, 413, 'identity.too_many_entries', { detail: 'at most 500 per call', ctx: req.ov });
++ if (!b.subject_ids && !b.system) return http.sendProblem(res, 400, 'identity.bad_request', { detail: 'system is required with ids', ctx: req.ov });
++ const results = {};
++ for (const raw of list) {
++ const key = String(raw);
++ results[key] = b.subject_ids
++ ? subjects.resolve(db, { subject_id: key })
++ : subjects.resolve(db, { source_system: String(b.system), source_type: String(b.type || 'user'), source_id: key });
++ }
++ res.json({ results });
++});
++
router.post('/legacy-map', express.json({ limit: '512kb' }), (req, res) => {
const entries = req.body && req.body.entries;
if (!Array.isArray(entries) || !entries.length) {
ddiff --git a/server/identity/principals.js b/server/identity/principals.js
iindex 7998d127..a07c0e46 100644
---- a/server/identity/principals.js
++++ b/server/identity/principals.js
@@@ -29,6 +29,13 @@ const DEFAULT_GRANTS = [
['tools', 'network.modules.write', SELF_AUDIENCE, ['tools.usage']],
['games', 'network.modules.read', SELF_AUDIENCE, ['games.progress.summary']],
['games', 'network.modules.write', SELF_AUDIENCE, ['games.progress.summary']],
++ // Wave 5: Community owns pastes. It resolves authors and uploads screenshot bytes to Media; Live writes
++ // pastes into Community on behalf of its users and its AI jobs.
++ ['community', 'identity.subject.resolve', SELF_AUDIENCE, []],
++ ['community', 'media.object.upload', 'openvibe.media', ['community']],
++ ['live', 'community.paste.create', 'openvibe.community', []],
++ ['live', 'community.paste.write', 'openvibe.community', []],
++ ['live', 'community.paste.moderate', 'openvibe.community', []],
];
function ensureSchema(db) {
ddiff --git a/server/internal/routes.js b/server/internal/routes.js
iindex 2f8ce474..64c7e7e8 100644
---- a/server/internal/routes.js
++++ b/server/internal/routes.js
@@@ -30,12 +30,15 @@ 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(['POST /coins/credit', 'POST /coins/debit', 'POST /coins/transfer', 'POST /notifications/push', 'POST /notifications/push-bulk']);
++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']);
const TOKEN_ROUTE_PATTERNS = [/^(GET|PUT) \/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);
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'));
router.use('/identity', require('../identity/internal-routes'));
// ── Verify Token ─────────────────────────────────────────────
ddiff --git a/test/identity-subjects.test.js b/test/identity-subjects.test.js
iindex 4864be6e..4496196f 100644
---- a/test/identity-subjects.test.js
++++ b/test/identity-subjects.test.js
@@@ -103,6 +103,18 @@ const { signToken } = require('../server/auth/routes');
assert.strictEqual(h.body.code, 'identity.subject_not_found');
h = await call('/resolve');
assert.strictEqual(h.status, 400);
++ h = await call('/resolve-batch', { method: 'POST', body: JSON.stringify({ system: 'live', type: 'user', ids: ['77', '321', '404404'] }) });
++ assert.strictEqual(h.status, 200);
++ assert.strictEqual(h.body.results['77'].subject.id, before);
++ assert.strictEqual(h.body.results['321'].subject.id, beth.subject_id);
++ assert.strictEqual(h.body.results['404404'], null, 'unknown ids resolve to null, not an error');
++ h = await call('/resolve-batch', { method: 'POST', body: JSON.stringify({ subject_ids: [before, 'usr_01JAB2C3D4E5F6G7H8J9K0ZZZZ'] }) });
++ assert.strictEqual(h.body.results[before].username, 'alex');
++ assert.strictEqual(h.body.results['usr_01JAB2C3D4E5F6G7H8J9K0ZZZZ'], null);
++ h = await call('/resolve-batch', { method: 'POST', body: JSON.stringify({ ids: ['1'] }) });
++ assert.strictEqual(h.status, 400, 'ids need a system');
++ h = await call('/resolve-batch', { method: 'POST', body: JSON.stringify({ subject_ids: Array.from({ length: 501 }, (_, i) => String(i)) }) });
++ assert.strictEqual(h.status, 413);
h = await call('/legacy-map', { method: 'POST', body: JSON.stringify({ entries: [{ network_user_id: 3, source_system: 'live', source_id: 99 }] }) });
assert.strictEqual(h.status, 200); assert.strictEqual(h.body.inserted, 1);
h = await fetch(base.replace('/identity', '/link-account'), { method: 'POST', headers: { 'x-internal-key': 'k-test', 'content-type': 'application/json' }, body: JSON.stringify({ user_id: 3, service: 'live', service_user_id: '555' }) });
ddiff --git a/test/principals.test.js b/test/principals.test.js
iindex 1bbb1baa..6bbbf0f9 100644
---- a/test/principals.test.js
++++ b/test/principals.test.js
@@@ -99,6 +99,20 @@ const server = http.createServer(app);
r = await post('/internal/identity/legacy-map', { entries: [] }, { 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.
++ db.prepare("UPDATE oauth_clients SET client_secret = 'community-secret' WHERE client_id = 'community'").run();
++ const com = await token({ client_id: 'community', client_secret: 'community-secret', audience: 'openvibe.network' });
++ assert.strictEqual(com.status, 200, JSON.stringify(com.body));
++ assert.deepStrictEqual(com.body.scope.split(' '), ['identity.subject.resolve']);
++ r = await post('/internal/identity/resolve-batch', { system: 'network', ids: ['7'] }, { authorization: `Bearer ${com.body.access_token}` });
++ assert.strictEqual(r.status, 200); assert.strictEqual(r.body.results['7'].username, 'payee');
++ r = await post('/internal/identity/resolve-batch', { system: 'network', ids: ['7'] }, { authorization: `Bearer ${full}` });
++ assert.strictEqual(r.status, 403, 'live has no identity.subject.resolve grant');
++ const media = await token({ client_id: 'community', client_secret: 'community-secret', audience: 'openvibe.media' });
++ assert.strictEqual(media.body.scope, 'media.object.upload', 'community may upload screenshot bytes to Media');
++ const cm = await token({ client_id: 'live', client_secret: 'live-secret', audience: 'openvibe.community' });
++ assert.deepStrictEqual(cm.body.scope.split(' '), ['community.paste.create', 'community.paste.moderate', 'community.paste.write']);
++
// ── Legacy key keeps working ──
r = await post('/internal/coins/credit', credit(), { 'x-internal-key': 'legacy-key' });
assert.strictEqual(r.status, 200);