openvibers/openvibe.network Commits
Account ID: the person's own id on /my, and the endpoint in llms.txt (#574)
Changes
@@@ -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@@@ -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).@@@ -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) ─────@@@ -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>@@@ -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 };nnew file mode 100644@@@ -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); });
@@ -29,6 +29,43 @@ Identity and account service for the OpenVibe network. Manages user accounts, OA
@@ -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).
- **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).
- **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
## Owns
@@ -219,6 +219,10 @@ function applyAnonModeUI(user) {
@@ -219,6 +219,10 @@ function applyAnonModeUI(user) {
}
}
const currentName = document.getElementById('theme-current-name');
const currentName = document.getElementById('theme-current-name');
if (currentName) currentName.textContent = 'Anon sessions do not sync themes';
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).
// Small helpers (my.html has no global toast/esc of its own).
@@ -42,6 +42,7 @@ async function loadUser() {
@@ -42,6 +42,7 @@ async function loadUser() {
loadQrSignin(data.features);
loadQrSignin(data.features);
renderHeader(data.user);
renderHeader(data.user);
renderProfile(data.user);
renderProfile(data.user);
+ renderAccountId(data.user);
loadThemes(); loadThemeSubmissions();
loadThemes(); loadThemeSubmissions();
loadAccounts();
loadAccounts();
renderMerge();
renderMerge();
@@ -146,6 +147,16 @@ function renderProfile(user) {
@@ -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
// Profile form
// ── Avatar: one picture for every site (PUT/DELETE /api/profile/avatar) ─────
// ── Avatar: one picture for every site (PUT/DELETE /api/profile/avatar) ─────
@@ -120,6 +120,16 @@
@@ -120,6 +120,16 @@
<!-- Profile Section -->
<!-- Profile Section -->
<div class="section active" id="sec-profile">
<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">
<div class="card" id="avatar-card">
<h3><span class="icon"><i class="fa-solid fa-user-circle"></i></span> Avatar</h3>
<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>
<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 @@
@@ -514,6 +524,8 @@
<script src="/shared/navbar.js"></script>
<script src="/shared/navbar.js"></script>
<script src="/shared/notification-ui.js"></script>
<script src="/shared/notification-ui.js"></script>
<script src="/shared/account-switcher.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/core.js" defer></script>
<script src="/js/my/tools.js" defer></script>
<script src="/js/my/tools.js" defer></script>
@@ -471,11 +471,22 @@ function sendHome(req, res, { releaseId = '' } = {}) {
@@ -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.
// 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.`;
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() {
function llmsTxt() {
const { catalog } = toolsCatalog.peek();
const { catalog } = toolsCatalog.peek();
return seo.llmsTxt({ name: 'OpenVibe Network', summary: llmsSummary(), markdown: true, base: 'https://openvibe.network',
return seo.llmsTxt({ name: 'OpenVibe Network', summary: llmsSummary(), markdown: true, base: 'https://openvibe.network',
sections: [
sections: [
{ title: 'Sites', links: OPEN.map((site) => ({ title: siteName(site), url: `https://${site.host}/`, note: site.what })) },
{ 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).
// The JSON an agent came for is a main section, not something to skip (AGT-5 review).
{ title: 'Machine-readable', links: [
{ title: 'Machine-readable', links: [
{ title: 'Open stats (JSON): every site\'s numbers', url: 'https://openvibe.network/api/v1/stats' },
{ 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;
@@ -506,7 +517,8 @@ const LLMS_FULL_MAX_BYTES = 512 * 1024;
function llmsFullTxt({ baseUrl = 'https://openvibe.network' } = {}) {
function llmsFullTxt({ baseUrl = 'https://openvibe.network' } = {}) {
const pages = require('../seo/routes').PAGES.filter((page) => LLMS_PAGE_TEXT[page.path])
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]() }));
.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 };
module.exports = { SAME_AS, withSameAs, sendHome, render, llmsTxt, llmsFullTxt, LLMS_FULL_MAX_BYTES, INTENTS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,113 @@
@@ -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); });