openvibers/openvibe.network Commits
Resolve a person by @username on the internal identity API (#56)
Changes
@@@ -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);});@@@ -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 };@@@ -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.
@@ -5,6 +5,7 @@
@@ -5,6 +5,7 @@
*
*
* GET /internal/identity/resolve?subject_id=usr_...
* GET /internal/identity/resolve?subject_id=usr_...
* GET /internal/identity/resolve?system=live&type=user&id=123
* 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? }] }
* POST /internal/identity/legacy-map { entries: [{ network_user_id | subject_id, source_system, source_type, source_id, verified? }] }
*/
*/
const express = require('express');
const express = require('express');
@@ -18,10 +19,12 @@ const MAX_ENTRIES = 1000;
@@ -18,10 +19,12 @@ const MAX_ENTRIES = 1000;
router.get('/resolve', async (req, res) => {
router.get('/resolve', async (req, res) => {
const db = req.app.locals.db;
const db = req.app.locals.db;
const q = req.query;
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 });
if (!out) return http.sendProblem(res, 404, 'identity.subject_not_found', { detail: 'no subject for that id', ctx: req.ov });
res.json(out);
res.json(out);
});
});
@@ -132,6 +132,20 @@ async function resolve(db, { subject_id, source_system, source_type, source_id }
@@ -132,6 +132,20 @@ async function resolve(db, { subject_id, source_system, source_type, source_id }
return p;
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
* 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'
* `subject_id` or `network_user_id` (what services store today). Rows for source_system 'network'
@@ -170,4 +184,4 @@ async function upsertLegacy(db, entries) {
@@ -170,4 +184,4 @@ async function upsertLegacy(db, entries) {
return out;
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 };
@@ -163,6 +163,14 @@ const server = http.createServer(app);
@@ -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');
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}` });
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');
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}` });
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');
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.
// Media's owner_subject backfill: Live user ids -> subjects, an unknown id answers null.