openvibers/openvibe.network Commits

Resolve a person by @username on the internal identity API (#56)

OP OpenVibers committed 5 Oct 2026 · d6c1ca0ed9 · 3 files, +29 −4

* Resolve a person by @username on the internal identity API

GET /internal/identity/resolve?username=<name> (identity.subject.resolve, as
the other lookups) answers the same projection as ?subject_id=, matching the
current username in any case with a leading @ ignored. A banned or deleted
account, or a name nobody holds, is 404, so a service cannot add one as a
member. First caller: OpenVibe.Bot, where a robot's owner adds an operator
by @username instead of a usr_ id.

Test: test/principals.test.js resolves payee, PAYEE and @payee to the same
person and an unknown name to 404.

* resolveUsername judges the merge survivor's ban and deletion, not the alias row (review)

Changes

ddiff --git a/server/identity/internal-routes.js b/server/identity/internal-routes.js
iindex 42320d40..e32915fb 100644
---- a/server/identity/internal-routes.js
++++ b/server/identity/internal-routes.js
@@@ -5,6 +5,7 @@
*
* GET /internal/identity/resolve?subject_id=usr_...
* GET /internal/identity/resolve?system=live&type=user&id=123
++ * GET /internal/identity/resolve?username=alex (a person's current name, any case: an owner adding @alex)
* POST /internal/identity/legacy-map { entries: [{ network_user_id | subject_id, source_system, source_type, source_id, verified? }] }
*/
const express = require('express');
@@@ -18,10 +19,12 @@ const MAX_ENTRIES = 1000;
router.get('/resolve', async (req, res) => {
const db = req.app.locals.db;
const q = req.query;
-- if (!q.subject_id && !(q.system && q.id)) {
-- return http.sendProblem(res, 400, 'identity.bad_request', { detail: 'pass subject_id, or system + id (+ type, default user)', ctx: req.ov });
++ if (!q.subject_id && !(q.system && q.id) && !q.username) {
++ return http.sendProblem(res, 400, 'identity.bad_request', { detail: 'pass subject_id, username, or system + id (+ type, default user)', ctx: req.ov });
}
-- const out = await subjects.resolve(db, { subject_id: q.subject_id, source_system: q.system, source_type: q.type || 'user', source_id: q.id });
++ const out = q.username
++ ? await subjects.resolveUsername(db, String(q.username))
++ : await subjects.resolve(db, { subject_id: q.subject_id, source_system: q.system, source_type: q.type || 'user', source_id: q.id });
if (!out) return http.sendProblem(res, 404, 'identity.subject_not_found', { detail: 'no subject for that id', ctx: req.ov });
res.json(out);
});
ddiff --git a/server/identity/subjects.js b/server/identity/subjects.js
iindex 508cd04f..3f04ae2b 100644
---- a/server/identity/subjects.js
++++ b/server/identity/subjects.js
@@@ -132,6 +132,20 @@ async function resolve(db, { subject_id, source_system, source_type, source_id }
return p;
}
++/**
++ * Resolve a person by their current username, any case (a leading @ is ignored): the same projection as resolve().
++ * A banned or deleted account, or a name nobody holds, answers null, so a service cannot add one as a member.
++ */
++async function resolveUsername(db, name) {
++ const n = String(name || '').trim().replace(/^@/, '');
++ if (!/^[A-Za-z0-9_.-]{1,40}$/.test(n)) return null;
++ const u = await db.prepare('SELECT id, subject_id, created_at FROM users WHERE LOWER(username) = LOWER(?) AND COALESCE(is_banned, 0) = 0 AND deleted_at IS NULL').get(n);
++ if (!u) return null;
++ // A name folded into another account (ADR-029) resolves to the survivor: judge the survivor, not the alias row.
++ const p = await resolve(db, { subject_id: u.subject_id || await ensureUserSubject(db, u) });
++ return p && !p.banned && !p.deleted ? p : null;
++}
++
/**
* Record service-local ids for existing subjects. Each entry names the Network account either by
* `subject_id` or `network_user_id` (what services store today). Rows for source_system 'network'
@@@ -170,4 +184,4 @@ async function upsertLegacy(db, entries) {
return out;
}
--module.exports = { ensureSchema, backfill, seedLegacyMap, ensureUserSubject, newUserSubjectId, newGuestSubjectId, projection, resolve, upsertLegacy, createdMs };
++module.exports = { ensureSchema, backfill, seedLegacyMap, ensureUserSubject, newUserSubjectId, newGuestSubjectId, projection, resolve, upsertLegacy, createdMs, resolveUsername };
ddiff --git a/test/principals.test.js b/test/principals.test.js
iindex 675b8401..bf092554 100644
---- a/test/principals.test.js
++++ b/test/principals.test.js
@@@ -163,6 +163,14 @@ const server = http.createServer(app);
assert.deepStrictEqual(com.body.scope.split(' '), ['identity.subject.resolve', 'network.account.deletion.confirm', 'network.account.export.contribute', 'network.blocks.read', 'network.modules.read', 'network.modules.write'], 'resolve, account export and deletion, platform blocks, plus its community.profile module');
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');
++ // Bot adds an operator by @username: the same projection, any case, a leading @ ignored; an unknown name is a 404.
++ for (const name of ['payee', 'PAYEE', '@payee']) {
++ const byName = await fetch(`${base}/internal/identity/resolve?username=${encodeURIComponent(name)}`, { headers: { authorization: `Bearer ${com.body.access_token}` } });
++ assert.strictEqual(byName.status, 200, name);
++ assert.strictEqual((await byName.json()).username, 'payee', name);
++ }
++ const nobody = await fetch(`${base}/internal/identity/resolve?username=nobody-here`, { headers: { authorization: `Bearer ${com.body.access_token}` } });
++ assert.strictEqual(nobody.status, 404, 'an unknown name resolves to nothing');
r = await post('/internal/identity/resolve-batch', { system: 'network', ids: ['7'] }, { authorization: `Bearer ${creditOnly}` });
assert.strictEqual(r.status, 403, 'a token narrowed to coins cannot resolve identities');
// Media's owner_subject backfill: Live user ids -> subjects, an unknown id answers null.