openvibers/openvibe.bot Commits
Adding a person by @username works (Network's subject is a subject-ref) (#304)
Changes
@@@ -154,11 +154,15 @@ function createIdentity(config, { fetchImpl = globalThis.fetch, now = () => Dateif (cache.size >= MAX_ENTRIES) cache.delete(cache.keys().next().value);cache.set(subject, { username: p.username, display_name: typeof p.display_name === 'string' && p.display_name ? p.display_name : p.username, at: now() });}++ // identity.subject-projection@1: `subject` is a subject-ref, { type, id } (2026-11-06: Bot read it as a string, so every++ // @name lookup Network answered with a person came back "No OpenVibe account is called @…").++ const subjectId = (subject) => (typeof subject === 'string' ? subject : subject && typeof subject.id === 'string' ? subject.id : null);async function byUsername(name) {const out = await call('GET', `/internal/identity/resolve?username=${encodeURIComponent(name)}`);-- if (!out || typeof out.subject !== 'string') return null;-- remember(out.subject, out);-- return { subject: out.subject, username: out.username, display_name: out.display_name || out.username };++ const subject = out ? subjectId(out.subject) : null;++ if (!subject) return null;++ remember(subject, out);++ return { subject, username: out.username, display_name: out.display_name || out.username };}async function names(subjectIds) {const ids = [...new Set((subjectIds || []).filter((s) => typeof s === 'string' && s))];@@@ -111,7 +111,8 @@ async function startNetwork() {const r = serviceAuth.verifyServiceToken(h.slice(7), { publicKey: publicPem, issuer, audience: 'openvibe.network' });return r.ok && (r.claims.cap || []).includes('identity.subject.resolve');};-- const identityView = (u) => ({ subject: u.subject, username: u.username, display_name: u.display_name });++ // The shape Network sends (identity.subject-projection@1): subject is a subject-ref { type, id }, never a bare string.++ const identityView = (u) => ({ subject: { type: 'user', id: u.subject }, username: u.username, display_name: u.display_name, avatar_url: null, banned: false });const server = http.createServer(async (req, res) => {if (req.url === '/api/.well-known/jwks') return send(res, 200, { public_key: publicPem, algorithm: 'RS256' });nnew file mode 100644@@@ -0,0 +1,36 @@++'use strict';++// The owner adds a person by @username from the panel (POST /robots/:id/operators). Network answers++// identity.subject-projection@1, whose `subject` is a subject-ref { type, id }: until 2026-11-06 Bot read it as a++// string and answered "No OpenVibe account is called @…" for every person Network found (a community member could not++// add @Quinn). The stub now speaks the contract's shape, so this test fails on that reading.++const assert = require('assert');++const { boot, check, done } = require('./helpers/app');++++(async () => {++ const t = await boot();++ const alex = t.network.newUser('alex');++ const quinn = t.network.newUser('Quinn');++ const cookie = (user) => ({ Cookie: `ov_token=${t.network.signUser(user)}` });++ const post = (p, user, form) => fetch(t.base + p, {++ method: 'POST', redirect: 'manual', body: new URLSearchParams(form).toString(),++ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Origin: new URL(t.base).origin, ...cookie(user) },++ });++ const { robot } = await t.robot(alex, { name: 'LOOI' });++ const members = async () => (await t.call("GET", `/api/v1/robots/${robot.id}/operators`, { user: alex })).json;++++ await check('an @username Network knows is added, whatever its case and with or without the @', async () => {++ const r = await post(`/robots/${robot.id}/operators`, alex, { username: '@Quinn', role: 'operator' });++ assert.ok(r.status === 303 || r.status === 302, `added (got ${r.status})`);++ const m = await members();++ const ops = (m.operators || m.members || []);++ assert.ok(ops.some((o) => o.subject === quinn.subject && o.role === 'operator'), 'Quinn is an operator');++ });++++ await check('a name Network does not know is still "No OpenVibe account"', async () => {++ const r = await post(`/robots/${robot.id}/operators`, alex, { username: 'nobody_here', role: 'viewer' });++ assert.strictEqual(r.status, 422);++ assert.match(await r.text(), /No OpenVibe account is called @nobody_here/);++ });++++ await done();++})();
@@ -154,11 +154,15 @@ function createIdentity(config, { fetchImpl = globalThis.fetch, now = () => Date
@@ -154,11 +154,15 @@ function createIdentity(config, { fetchImpl = globalThis.fetch, now = () => Date
if (cache.size >= MAX_ENTRIES) cache.delete(cache.keys().next().value);
if (cache.size >= MAX_ENTRIES) cache.delete(cache.keys().next().value);
cache.set(subject, { username: p.username, display_name: typeof p.display_name === 'string' && p.display_name ? p.display_name : p.username, at: now() });
cache.set(subject, { username: p.username, display_name: typeof p.display_name === 'string' && p.display_name ? p.display_name : p.username, at: now() });
}
}
+ // identity.subject-projection@1: `subject` is a subject-ref, { type, id } (2026-11-06: Bot read it as a string, so every
+ // @name lookup Network answered with a person came back "No OpenVibe account is called @…").
+ const subjectId = (subject) => (typeof subject === 'string' ? subject : subject && typeof subject.id === 'string' ? subject.id : null);
async function byUsername(name) {
async function byUsername(name) {
const out = await call('GET', `/internal/identity/resolve?username=${encodeURIComponent(name)}`);
const out = await call('GET', `/internal/identity/resolve?username=${encodeURIComponent(name)}`);
- if (!out || typeof out.subject !== 'string') return null;
- remember(out.subject, out);
- return { subject: out.subject, username: out.username, display_name: out.display_name || out.username };
+ const subject = out ? subjectId(out.subject) : null;
+ if (!subject) return null;
+ remember(subject, out);
+ return { subject, username: out.username, display_name: out.display_name || out.username };
}
}
async function names(subjectIds) {
async function names(subjectIds) {
const ids = [...new Set((subjectIds || []).filter((s) => typeof s === 'string' && s))];
const ids = [...new Set((subjectIds || []).filter((s) => typeof s === 'string' && s))];
@@ -111,7 +111,8 @@ async function startNetwork() {
@@ -111,7 +111,8 @@ async function startNetwork() {
const r = serviceAuth.verifyServiceToken(h.slice(7), { publicKey: publicPem, issuer, audience: 'openvibe.network' });
const r = serviceAuth.verifyServiceToken(h.slice(7), { publicKey: publicPem, issuer, audience: 'openvibe.network' });
return r.ok && (r.claims.cap || []).includes('identity.subject.resolve');
return r.ok && (r.claims.cap || []).includes('identity.subject.resolve');
};
};
- const identityView = (u) => ({ subject: u.subject, username: u.username, display_name: u.display_name });
+ // The shape Network sends (identity.subject-projection@1): subject is a subject-ref { type, id }, never a bare string.
+ const identityView = (u) => ({ subject: { type: 'user', id: u.subject }, username: u.username, display_name: u.display_name, avatar_url: null, banned: false });
const server = http.createServer(async (req, res) => {
const server = http.createServer(async (req, res) => {
if (req.url === '/api/.well-known/jwks') return send(res, 200, { public_key: publicPem, algorithm: 'RS256' });
if (req.url === '/api/.well-known/jwks') return send(res, 200, { public_key: publicPem, algorithm: 'RS256' });
new file mode 100644
new file mode 100644
@@ -0,0 +1,36 @@
@@ -0,0 +1,36 @@
+'use strict';
+// The owner adds a person by @username from the panel (POST /robots/:id/operators). Network answers
+// identity.subject-projection@1, whose `subject` is a subject-ref { type, id }: until 2026-11-06 Bot read it as a
+// string and answered "No OpenVibe account is called @…" for every person Network found (a community member could not
+// add @Quinn). The stub now speaks the contract's shape, so this test fails on that reading.
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/app');
+
+(async () => {
+ const t = await boot();
+ const alex = t.network.newUser('alex');
+ const quinn = t.network.newUser('Quinn');
+ const cookie = (user) => ({ Cookie: `ov_token=${t.network.signUser(user)}` });
+ const post = (p, user, form) => fetch(t.base + p, {
+ method: 'POST', redirect: 'manual', body: new URLSearchParams(form).toString(),
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Origin: new URL(t.base).origin, ...cookie(user) },
+ });
+ const { robot } = await t.robot(alex, { name: 'LOOI' });
+ const members = async () => (await t.call("GET", `/api/v1/robots/${robot.id}/operators`, { user: alex })).json;
+
+ await check('an @username Network knows is added, whatever its case and with or without the @', async () => {
+ const r = await post(`/robots/${robot.id}/operators`, alex, { username: '@Quinn', role: 'operator' });
+ assert.ok(r.status === 303 || r.status === 302, `added (got ${r.status})`);
+ const m = await members();
+ const ops = (m.operators || m.members || []);
+ assert.ok(ops.some((o) => o.subject === quinn.subject && o.role === 'operator'), 'Quinn is an operator');
+ });
+
+ await check('a name Network does not know is still "No OpenVibe account"', async () => {
+ const r = await post(`/robots/${robot.id}/operators`, alex, { username: 'nobody_here', role: 'viewer' });
+ assert.strictEqual(r.status, 422);
+ assert.match(await r.text(), /No OpenVibe account is called @nobody_here/);
+ });
+
+ await done();
+})();