openvibers/openvibe.network Commits

Account ID: the person's own id on /my, and the endpoint in llms.txt (#574)

OP OpenVibers committed 7 days ago · 52705889af · 6 files, +190 −1

The account page shows the signed-in person's own account ID (usr_…) in a chip with the kit's Copy button, with one line saying what it is for. It comes from GET /api/auth/me for the signed-in person only, so nobody else's id can show up.

README gets a 'Your account ID' section: the endpoint, the field (user.subject_id), a curl example and how to read it with openvibe-sdk. llms.txt and llms-full.txt name the endpoint so an agent finds it.

test/account-id.test.js asserts the page shows the caller's own id and nobody else's, and that llms.txt names the endpoint.

Changes

ddiff --git a/README.md b/README.md
iindex c80d1fe3..cf398e22 100644
---- a/README.md
++++ b/README.md
@@@ -29,6 +29,43 @@ Identity and account service for the OpenVibe network. Manages user accounts, OA
- **User modules** — versioned per-person preferences and summaries in namespaces owned by services (openvibe-contracts `manifests/namespaces`); see [User modules](#user-modules).
- **The Dock's state** — the little floating surface every OpenVibe site draws (your agent, your chats, your activity) follows the person across every domain, because `localStorage` is per site and the state lives here instead. `GET /api/v1/me/dock` and `PUT /api/v1/me/dock` (`network.dock-state@1`, `network.dock-result@1`, contracts 0.251.0; `server/dock/`). See [docs/dock.md](docs/dock.md).
++## Your account ID
++
++Every person on OpenVibe has one canonical id, `usr_…`: the **account ID**. It is what apps,
++robots and services ask for when they ask for "your OpenVibe account ID", and it is the id every
++OpenVibe service keys a person's data by.
++
++A person sees their own on their account page (`https://openvibe.network/my`, Profile → Account
++ID): the id in a chip with a Copy button. It is nobody else's — the API answers it for the
++signed-in person only.
++
++### API
++
++- `GET /api/auth/me` — the signed-in person's own account. `user.subject_id` is the account ID
++ (`usr_…`); an account that has none gets one minted on its first call.
++
++```bash
++curl -s https://openvibe.network/api/auth/me \
++ -H "Authorization: Bearer $OV_TOKEN" | jq -r '.user.subject_id'
++```
++
++`OV_TOKEN` is that person's own access token (the `ov_token` cookie, an OAuth access token, or an
++`Authorization: Bearer` header). The answer carries the caller's own id and nobody else's.
++
++### With openvibe-sdk
++
++The SDK has no client for `GET /api/auth/me`, so a service that already holds the person's token
++reads the id from the token's claims:
++
++```js
++const { verifyUserToken } = require('openvibe-sdk/auth');
++const claims = await verifyUserToken(token, { jwks: 'https://openvibe.network/api/.well-known/jwks' });
++claims.subject_id; // 'usr_…' — the account ID
++```
++
++`openvibe-sdk/identity` goes the other way: `resolve({ subjectId })` turns an account ID into the
++person's username and picture (a service token, `identity.subject.resolve`).
++
---
## Owns
ddiff --git a/public/js/my/core.js b/public/js/my/core.js
iindex d943d671..d6523fc2 100644
---- a/public/js/my/core.js
++++ b/public/js/my/core.js
@@@ -219,6 +219,10 @@ function applyAnonModeUI(user) {
}
const currentName = document.getElementById('theme-current-name');
if (currentName) currentName.textContent = 'Anon sessions do not sync themes';
++
++ // An anonymous session has no account to name: no Account ID card.
++ const idCard = document.getElementById('account-id-card');
++ if (idCard) idCard.hidden = true;
}
// Small helpers (my.html has no global toast/esc of its own).
ddiff --git a/public/js/my/profile.js b/public/js/my/profile.js
iindex 870fd9e2..811afbf1 100644
---- a/public/js/my/profile.js
++++ b/public/js/my/profile.js
@@@ -42,6 +42,7 @@ async function loadUser() {
loadQrSignin(data.features);
renderHeader(data.user);
renderProfile(data.user);
++ renderAccountId(data.user);
loadThemes(); loadThemeSubmissions();
loadAccounts();
renderMerge();
@@@ -146,6 +147,16 @@ function renderProfile(user) {
}
}
++// ── Account ID: the person's own subject id (usr_…) ─────────────────────────────
++// The id apps and robots ask for. It comes from GET /api/auth/me for the signed-in
++// person only, so the page shows nobody else's.
++function renderAccountId(user) {
++ const value = document.getElementById('account-id-value');
++ if (!value) return;
++ const id = String((user && user.subject_id) || '').trim();
++ value.textContent = id || 'Not available yet.';
++}
++
// Profile form
// ── Avatar: one picture for every site (PUT/DELETE /api/profile/avatar) ─────
ddiff --git a/public/my.html b/public/my.html
iindex 806cadb9..00bd23d1 100644
---- a/public/my.html
++++ b/public/my.html
@@@ -120,6 +120,16 @@
<!-- Profile Section -->
<div class="section active" id="sec-profile">
++ <!-- Account ID (the person's own usr_… subject id): what apps and robots ask for. Kit components only:
++ the kit's snippet (a monospace value) and the kit's copy button (data-ovk-copy, /shared/kit-web.js). -->
++ <div class="card" id="account-id-card">
++ <h3><span class="icon"><i class="fa-solid fa-id-card"></i></span> Account ID</h3>
++ <p>For apps and robots that ask for your OpenVibe account ID. It is yours alone: nobody else can read it.</p>
++ <div class="ovk-snip">
++ <button type="button" class="ovk-icon-btn" data-ovk-copy="#account-id-value" hidden aria-label="Copy your account ID" title="Copy your account ID"><svg class="ovk-i" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"/></svg></button>
++ <pre tabindex="0" id="account-id-value"><code>Loading…</code></pre>
++ </div>
++ </div>
<div class="card" id="avatar-card">
<h3><span class="icon"><i class="fa-solid fa-user-circle"></i></span> Avatar</h3>
<p class="muted" style="margin-top:0">One picture for every OpenVibe site. It always lives on <b>openvibe.media</b>. Paste the link to one of your image pastes (from <a href="https://openvibe.community/my" target="_blank" rel="noopener">your pastes</a>), or any picture link: we fetch it once, clean it, and keep our own copy.</p>
@@@ -514,6 +524,8 @@
<script src="/shared/navbar.js"></script>
<script src="/shared/notification-ui.js"></script>
<script src="/shared/account-switcher.js"></script>
++<!-- The kit's behaviour in the page: the copy button on the Account ID (data-ovk-copy). -->
++<script src="/shared/kit-web.js" defer></script>
<script src="/js/my/core.js" defer></script>
<script src="/js/my/tools.js" defer></script>
ddiff --git a/server/home/render.js b/server/home/render.js
iindex 4fb3ef01..0d841598 100644
---- a/server/home/render.js
++++ b/server/home/render.js
@@@ -471,11 +471,22 @@ function sendHome(req, res, { releaseId = '' } = {}) {
// described an unrelated "Openvibe" mobile app): the network, what is on it, and what it is not.
const llmsSummary = () => `OpenVibe (openvibe.network, made by OpenVibers) is an open source, community-run network of websites that share one free account: ${OPEN.map((site) => site.name.toLowerCase()).join(', ')}. It is not the Openvibe mobile social-media aggregator app, which is unrelated.`;
++// The person's own account ID (the canonical usr_… subject id): what apps and robots ask for.
++// GET /api/auth/me answers it for the signed-in person; a person reads it on their account page (/my).
++const ACCOUNT_ID_LINK = {
++ title: 'Your account ID: GET /api/auth/me → user.subject_id',
++ url: 'https://openvibe.network/api/auth/me',
++ note: 'the caller\'s own usr_… id: the account id apps and robots ask for. A person sees their own on their account page (openvibe.network/my); nobody else can read it',
++};
++const ACCOUNT_ID_TEXT = 'GET /api/auth/me with a person\'s token answers { user: { subject_id: "usr_…" } }. That usr_… value is the person\'s OpenVibe account ID: what apps and robots ask for when they ask for someone\'s OpenVibe account ID. A person sees their own on their account page (https://openvibe.network/my); nobody else can read it.';
++
function llmsTxt() {
const { catalog } = toolsCatalog.peek();
return seo.llmsTxt({ name: 'OpenVibe Network', summary: llmsSummary(), markdown: true, base: 'https://openvibe.network',
sections: [
{ title: 'Sites', links: OPEN.map((site) => ({ title: siteName(site), url: `https://${site.host}/`, note: site.what })) },
++ // A person's account ID: the endpoint an agent needs when it works for someone.
++ { title: 'Your account ID', links: [ACCOUNT_ID_LINK] },
// The JSON an agent came for is a main section, not something to skip (AGT-5 review).
{ title: 'Machine-readable', links: [
{ title: 'Open stats (JSON): every site\'s numbers', url: 'https://openvibe.network/api/v1/stats' },
@@@ -506,7 +517,8 @@ const LLMS_FULL_MAX_BYTES = 512 * 1024;
function llmsFullTxt({ baseUrl = 'https://openvibe.network' } = {}) {
const pages = require('../seo/routes').PAGES.filter((page) => LLMS_PAGE_TEXT[page.path])
.map((page) => ({ title: LLMS_PAGE_TITLE[page.path], url: page.path, text: LLMS_PAGE_TEXT[page.path]() }));
-- return seo.llmsFull({ site: 'OpenVibe Network', summary: llmsSummary(), base: baseUrl, maxBytes: LLMS_FULL_MAX_BYTES, sections: [{ title: 'Pages', pages }] });
++ return seo.llmsFull({ site: 'OpenVibe Network', summary: llmsSummary(), base: baseUrl, maxBytes: LLMS_FULL_MAX_BYTES,
++ sections: [{ title: 'Pages', pages }, { title: 'Your account ID', url: ACCOUNT_ID_LINK.url, body: ACCOUNT_ID_TEXT }] });
}
module.exports = { SAME_AS, withSameAs, sendHome, render, llmsTxt, llmsFullTxt, LLMS_FULL_MAX_BYTES, INTENTS };
ddiff --git a/test/account-id.test.js b/test/account-id.test.js
nnew file mode 100644
iindex 00000000..81d73023
---- /dev/null
++++ b/test/account-id.test.js
@@@ -0,0 +1,113 @@
++'use strict';
++// The account ID: the person's own canonical subject id (usr_…).
++// - the account page (/my) shows it, in a chip, with the kit's Copy button
++// - GET /api/auth/me answers it for the signed-in person, and nobody else's
++// - llms.txt and llms-full.txt name the endpoint, so an agent finds it
++// node --import ./test/helpers/pg-preload.mjs test/account-id.test.js
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const http = require('http');
++const crypto = require('crypto');
++const vm = require('vm');
++const express = require('express');
++const { getDb } = require('../server/db/database');
++const { signToken } = require('../server/auth/routes');
++const authRoutes = require('../server/auth/routes');
++
++const ROOT = path.join(__dirname, '..');
++const html = fs.readFileSync(path.join(ROOT, 'public', 'my.html'), 'utf8');
++const profileJs = fs.readFileSync(path.join(ROOT, 'public', 'js', 'my', 'profile.js'), 'utf8');
++const coreJs = fs.readFileSync(path.join(ROOT, 'public', 'js', 'my', 'core.js'), 'utf8');
++const readme = fs.readFileSync(path.join(ROOT, 'README.md'), 'utf8');
++
++// ── The account page ─────────────────────────────────────────────────────────
++assert.match(html, /id="account-id-card"/, 'the account page has an Account ID card');
++assert.match(html, /Account ID<\/h3>/, 'the card is named Account ID');
++assert.match(html, /apps and robots that ask for your OpenVibe account ID/, 'it says what the id is for');
++assert.match(html, /data-ovk-copy="#account-id-value"/, "the kit's copy button copies the value");
++assert.match(html, /<script src="\/shared\/kit-web\.js" defer><\/script>/, 'the kit (and its copy button) is on the page');
++assert.match(html, /<pre tabindex="0" id="account-id-value">/, "the value sits in the kit's monospace snippet");
++assert.ok(!/usr_[A-Z0-9]{8,}/.test(html), "the page hardcodes nobody's id: the signed-in person's own fills it");
++assert.match(profileJs, /renderAccountId\(data\.user\)/, 'the page fills it from the signed-in person\'s own /api/auth/me');
++assert.match(coreJs, /account-id-card[\s\S]{0,80}hidden = true/, 'an anonymous session shows no account id');
++
++// ── The client: it shows the person its own id, and nobody else's ───────────
++const start = profileJs.indexOf('function renderAccountId(user) {');
++assert.ok(start > 0, 'renderAccountId exists');
++let depth = 0, end = start;
++for (let i = profileJs.indexOf('{', start); i < profileJs.length; i++) {
++ if (profileJs[i] === '{') depth++;
++ else if (profileJs[i] === '}' && --depth === 0) { end = i + 1; break; }
++}
++const source = profileJs.slice(start, end);
++function renderAccountId(user) {
++ const value = { textContent: '' };
++ const document = { getElementById: (id) => (id === 'account-id-value' ? value : null) };
++ vm.runInNewContext(`${source}; renderAccountId(user);`, { document, user });
++ return value.textContent;
++}
++assert.strictEqual(renderAccountId({ subject_id: 'usr_AAA111' }), 'usr_AAA111', 'the page shows the id it is given');
++assert.strictEqual(renderAccountId({}), 'Not available yet.', 'no id, no invention');
++assert.ok(!renderAccountId({ subject_id: 'usr_AAA111' }).includes('usr_BBB222'), 'never another id');
++
++(async () => {
++const db = getDb();
++await db.prepare("INSERT INTO users (id, username, email, password_hash, email_verified) VALUES (9101, 'acctalpha', '[email protected]', 'x', 1)").run();
++await db.prepare("INSERT INTO users (id, username, email, password_hash, email_verified) VALUES (9102, 'acctbeta', '[email protected]', 'x', 1)").run();
++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });
++const config = { jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };
++const alpha = await db.prepare('SELECT * FROM users WHERE id = 9101').get();
++const beta = await db.prepare('SELECT * FROM users WHERE id = 9102').get();
++const tokenA = signToken(alpha, keys.privateKey, config);
++const tokenB = signToken(beta, keys.privateKey, config);
++
++const app = express();
++app.use(express.json());
++Object.assign(app.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey });
++app.use('/api/auth', authRoutes);
++const server = http.createServer(app);
++await new Promise((r) => server.listen(0, '127.0.0.1', r));
++const base = `http://127.0.0.1:${server.address().port}`;
++const me = async (token) => {
++ const res = await fetch(base + '/api/auth/me', { headers: token ? { authorization: `Bearer ${token}` } : {} });
++ return { status: res.status, body: await res.text() };
++};
++
++try {
++ // ── The API: the caller's own account id, and nobody else's ──────────────
++ const a = await me(tokenA);
++ assert.strictEqual(a.status, 200, 'a signed-in person is answered');
++ const userA = JSON.parse(a.body).user;
++ assert.match(userA.subject_id, /^usr_/, 'the account id is a usr_ subject id');
++ const b = await me(tokenB);
++ const userB = JSON.parse(b.body).user;
++ assert.match(userB.subject_id, /^usr_/, 'the other person has one too');
++ assert.notStrictEqual(userA.subject_id, userB.subject_id, 'two people, two account ids');
++ assert.ok(!a.body.includes(userB.subject_id), "the first person's answer never carries the other's id");
++ assert.ok(!b.body.includes(userA.subject_id), "the other answer never carries the first's id");
++ const guest = await me(null);
++ assert.strictEqual(guest.status, 401, 'a guest gets no account id');
++
++ // The page shows exactly what the API answered for that person.
++ assert.strictEqual(renderAccountId(userA), userA.subject_id, 'the page shows the caller\'s own id');
++ assert.ok(!renderAccountId(userA).includes(userB.subject_id), 'and nobody else\'s');
++
++ // ── The docs: the endpoint is named, for people and for agents ───────────
++ const home = require('../server/home/render');
++ const llms = home.llmsTxt();
++ assert.ok(llms.includes('/api/auth/me'), 'llms.txt names the endpoint an agent needs');
++ assert.ok(llms.includes('subject_id'), 'llms.txt names the field');
++ assert.match(llms, /## Your account ID/, 'llms.txt has an account ID section');
++ const full = home.llmsFullTxt({ baseUrl: 'https://openvibe.network' });
++ assert.ok(full.includes('/api/auth/me') && full.includes('subject_id'), 'llms-full.txt names it too');
++
++ assert.ok(readme.includes('## Your account ID'), 'the API docs have an account ID entry');
++ assert.ok(readme.includes('GET /api/auth/me') && readme.includes('user.subject_id'), 'the docs name the endpoint and the field');
++ assert.ok(/curl -s https:\/\/openvibe\.network\/api\/auth\/me/.test(readme), 'the docs carry a curl example');
++ assert.ok(readme.includes('openvibe-sdk/auth'), 'the docs say how to read it with the SDK');
++} finally {
++ server.close();
++}
++console.log('account id: all checks passed');
++})().then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });