openvibers/openvibe.network Commits

Network: the chat apps' account links (A4) (#591)

OP OpenVibers committed 7 days ago · c27c68fcf1 · 10 files, +466 −2

* Network: the chat apps' account links (A4)

- linked_accounts gains the chat namespaces (chat:telegram, chat:discord, chat:slack:<workspace>)
  through a partial unique index, so one Telegram/Discord/Slack account is one person and cannot
  be taken by a second signed-in account.
- chat_app_pending_links: a /link token's hash, the app and the chat name, its expiry and who
  confirmed it. The token is used once, and a different signed-in account cannot steal an
  existing identity even with a fresh link.
- /internal/chat-app-links (svc:chat only): register, pending, resolve and unlink, guarded by
  identity.subject.resolve.
- /chat-apps/link: the signed-in confirm page. It shows the app and the chat name, refuses a
  cross-origin POST, and redirects a signed-out person to sign in first.
- Account deletion removes the person's pending links; their account links already go with the
  existing linked_accounts erase.
- Grant Chat's own client identity.subject.resolve on Network, which its service token is
  minted with (it already had actor.agent.manage and actor.task.create/read).

* Merge remote-tracking branch 'origin/main' into feature/a4-chat-apps

* Network: list and remove a person's chat-app accounts (A4)

- GET /api/v1/me/chat-apps: the signed-in person's Telegram, Discord and Slack links (app,
  workspace, chat name, when). No token or hash is returned.
- DELETE /api/v1/me/chat-apps/:app: removes their own link (every Slack workspace for slack),
  and any pending link token for it; 404 when there is none.
- docs/chat-app-links.md: the rows, the svc:chat API, the confirm page, the account API, the
  grant and what a deployment must set.

The test now runs the real internal gate: a signed service token, a refusal without the
capability, another service refused, svc:chat register/pending/confirm/resolve/unlink, and the
account API's own list and removal.

* Network: the chat-app migration in the schema rehearsal tests (A4)

agent-schema and user-owned-trust pin the exact list of migrations that apply on top of an
older database; migration 0041 chat_app_links is now that last step. Both tests pass, and so do
grants-admin and registry-topics after the contracts v0.298.0 install.

* Network: linking a chat account takes the code from that chat (A4 review)

A person who started /link could forward the confirm URL to somebody else; if that person was
signed in and confirmed, the starter's chat account would drive the victim's agent. Linking is
now two-way:

- Confirming on Network mints a 6-digit code (10 minutes) and shows it with the bot and the chat
  account to send it from; it explains that this chat account will talk to the agent and approve
  its steps. Nothing is linked yet.
- The code is bound to the chat identity that started /link and kept only as a SHA-256 hash.
  POST /internal/chat-app-links/finish (svc:chat) writes the linked_accounts row only when that
  same identity sends it; another chat account gets no_link, three wrong codes cancel it, and an
  expired code is refused. Confirming again rotates the code.
- Migration 0042 (renamed from 0041, which Network #587 also took), with the code hash, code
  expiry, attempts, bot handle, finished and cancelled marks. The rehearsal lists name 0042.

Tests: the starter finishing with the code; another chat account refused; the code expiring;
three wrong codes; the code stored only as a hash; the confirm page's words; the real service
token gate; account data. node test/run.js chat-app-links account-data pass.

* Network: merge main (adult confirmation 0041) with chat-app links 0042

#587 shipped migration 0041 (adult confirmation) first, so the chat-app links migration is
0042. The rehearsal lists now name both, in order. No other change.

* Network: a confirmed link cannot be rotated by another account (A4 review)

Once one signed-in person has confirmed a pending link, another account confirming the same
token is refused (409) instead of taking it over and minting its own code. The person who
confirmed can confirm again to rotate their own code. Test added.

Changes

ddiff --git a/docs/chat-app-links.md b/docs/chat-app-links.md
nnew file mode 100644
iindex 00000000..5b861ebb
---- /dev/null
++++ b/docs/chat-app-links.md
@@@ -0,0 +1,74 @@
++# Chat-app accounts (Telegram, Discord, Slack) — A4
++
++Network owns the account link between a chat identity and an OpenVibe subject. Chat's bots ask
++Network who a chat account is; the person confirms the link while signed in here. Linking is
++**two-way**: a forwarded confirm URL alone links nothing.
++
++## The flow
++
++1. The person sends `/link` to the bot from their own chat. Chat registers a pending link (a
++ 43-character token, stored as a SHA-256 hash) and sends them the confirm URL.
++2. While signed in on Network, the person confirms. Nothing is linked yet: the page says plainly
++ that this chat account will talk to their agent and approve its steps, and shows a **6-digit
++ code** (10 minutes, kept only as a hash bound to that chat identity).
++3. The person sends that code to the bot **from the same chat account** that started `/link`.
++ Chat calls `finish` with its own identity and the code; only then is the `linked_accounts` row
++ written. A different chat account cannot finish it, three wrong codes cancel it, and an
++ expired code cannot be used.
++
++## The rows
++
++- `linked_accounts` (existing): the link itself, in Chat's own namespaces —
++ `chat:telegram`, `chat:discord`, and `chat:slack:<workspace>` with `service_user_id`
++ `T…:U…`. A partial unique index (`service LIKE 'chat:%'`) makes one chat account one person.
++- `chat_app_pending_links` (migration **0042**): the token's **SHA-256 hash**, the app, the chat
++ name, the bot handle the page names, the expiry, who confirmed it (`confirmed_by`, on Network),
++ the **code hash**, when the code expires, the wrong-code attempts, and the finished/cancelled
++ marks. Neither the token nor the code is ever stored in the clear.
++
++One chat account is one person; a second signed-in account confirming the same chat account is
++refused (`409`), and replacing a different linked account of the same service is refused too.
++
++## Chat → Network (`/internal/chat-app-links`, `svc:chat` only)
++
++The guard is `identity.subject.resolve`; the client id must be `chat` so the subject is
++`svc:chat`, and only that service is let in.
++
++| Call | What it does |
++|---|---|
++| `POST /` `{ token, app, service_user_id, service_username, bot_handle, expires_at }` | registers a pending link; expiry must be within 10 minutes; idempotent per token hash |
++| `GET /?t=<token>` | `{ state: pending\|confirmed\|finished\|cancelled\|unknown, subject }` |
++| `POST /finish` `{ app, service_user_id, code }` | the same chat account sends the code: `{ ok: true, subject }`, or `{ ok: false, error: code\|expired\|no_link, attemptsLeft }`; `404` when this chat account started nothing |
++| `GET /resolve?app=&id=` | `{ subject_id }` or `null` — the person behind a chat account, resolved fresh each message (only a finished link appears) |
++| `POST /unlink` `{ app, service_user_id, subject }` | removes the link and any pending token for that identity |
++
++The token is 32 random bytes, base64url (43 characters); the code is six digits from
++`crypto.randomInt`. Neither is ever logged.
++
++## The person's pages and API
++
++- `GET /chat-apps/link?t=<token>` — requires sign-in (a signed-out person is redirected to
++ `/login?return=…`), says what linking does, and shows the confirm button. Answers `410` for an
++ expired, cancelled or finished token.
++- `POST /chat-apps/link` — same-origin only; confirms and returns the code page: the 6 digits,
++ the bot (`@<bot_handle>`), the chat account to send them from, and that the code is shown once.
++ Confirming again rotates the code.
++- `GET /api/v1/me/chat-apps` — the signed-in person's links: `app`, `workspace` (Slack only),
++ `service_user_id`, `service_username`, `linked_at`. No token or hash is returned.
++- `DELETE /api/v1/me/chat-apps/:app` — removes the person's own link (`telegram`, `discord` or
++ all Slack workspaces for `slack`); `404` when there is none.
++
++## Configuration
++
++- The `chat` OAuth client on Network holds `identity.subject.resolve` on `openvibe.network`
++ (`server/identity/principals.js`, the grant list). It already held `actor.agent.manage` and
++ `actor.task.create/read` for Actor.
++- Chat's `/etc/openvibe/chat.env` needs `OV_OAUTH_CLIENT_ID=chat` and the client secret, and
++ `CHAT_LINK_CONFIRM_URL` must point at `https://openvibe.network/chat-apps/link`.
++- nginx for `openvibe.network` already sends `/chat-apps` and `/api/` to Network; nothing new.
++
++## Account data
++
++`network.account.deleted` removes the person's pending chat-app links; their `linked_accounts`
++rows go with the existing erase. Chat removes its own link tokens, buttons and queued deliveries
++(see Chat's `docs/chat-apps.md`).
ddiff --git a/migrations/0042_chat_app_links.sql b/migrations/0042_chat_app_links.sql
nnew file mode 100644
iindex 00000000..76eeae2d
---- /dev/null
++++ b/migrations/0042_chat_app_links.sql
@@@ -0,0 +1,25 @@
++-- phase: expand
++-- Connect identities for the chat bots. Provider ids are namespaced by workspace for Slack.
++-- These are Connect account links, not OAuth sign-in providers. Slack's service key includes its workspace.
++--
++-- Linking is two-way (plan A4 security review). /link makes a pending row; the person confirms it
++-- while signed in here, which mints a 6-digit code kept only as a hash; the link is written only
++-- when the SAME chat account that started /link sends that code to the bot. A confirm URL sent to
++-- somebody else therefore links nothing.
++CREATE UNIQUE INDEX linked_chat_identity ON linked_accounts(service, service_user_id) WHERE service LIKE 'chat:%';
++CREATE TABLE chat_app_pending_links (
++ token_hash text PRIMARY KEY,
++ app text NOT NULL CHECK (app IN ('telegram', 'discord', 'slack')),
++ external_id text NOT NULL,
++ label text,
++ bot_handle text,
++ expires_at text NOT NULL,
++ confirmed_by bigint REFERENCES users(id) ON DELETE CASCADE,
++ confirmed_at text,
++ code_hash text,
++ code_expires_at text,
++ attempts integer NOT NULL DEFAULT 0,
++ finished_at text,
++ cancelled_at text
++);
++CREATE INDEX chat_app_pending_links_identity ON chat_app_pending_links (app, external_id);
ddiff --git a/server/chat-apps/links.js b/server/chat-apps/links.js
nnew file mode 100644
iindex 00000000..0b87f5ac
---- /dev/null
++++ b/server/chat-apps/links.js
@@@ -0,0 +1,212 @@
++'use strict';
++const crypto = require('crypto');
++const express = require('express');
++const hash = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
++const service = (app, external) => app === 'slack' ? `chat:slack:${String(external).split(':')[0]}` : `chat:${app}`;
++const TOKEN = /^[A-Za-z0-9_-]{43}$/;
++const CODE = /^[0-9]{6}$/;
++const CODE_TTL_MS = 10 * 60_000;
++const MAX_ATTEMPTS = 3;
++const validIdentity = (app, id) => app === 'slack' ? /^T[A-Z0-9]+:[UW][A-Z0-9]+$/.test(id)
++ : ['telegram', 'discord'].includes(app) && /^[0-9]{1,24}$/.test(id);
++const escape = (s) => String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
++const fail = (status, message) => Object.assign(new Error(message), { status });
++const same = (a, b) => {
++ const x = Buffer.from(String(a == null ? '' : a));
++ const y = Buffer.from(String(b == null ? '' : b));
++ return x.length === y.length && crypto.timingSafeEqual(x, y);
++};
++/** The code is bound to the chat identity that asked for the link, so it cannot finish another one. */
++const codeHash = (app, externalId, code) => hash(`chat-app-code:${app}:${externalId}:${code}`);
++
++function store(db, now = () => Date.now()) {
++ const iso = () => new Date(now()).toISOString();
++ return {
++ async register(b) {
++ if (!TOKEN.test(b.token || '') || !validIdentity(b.app, String(b.service_user_id || ''))) throw fail(400, 'Invalid link');
++ const exp = Date.parse(b.expires_at);
++ if (!Number.isFinite(exp) || exp <= now() || exp > now() + 10 * 60_000) throw fail(400, 'Invalid expiry');
++ await db.prepare('DELETE FROM chat_app_pending_links WHERE expires_at < ?').run(iso());
++ await db.prepare(`INSERT INTO chat_app_pending_links (token_hash, app, external_id, label, bot_handle, expires_at)
++ VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (token_hash) DO NOTHING`)
++ .run(hash(b.token), b.app, b.service_user_id, String(b.service_username || '').slice(0, 80),
++ String(b.bot_handle || '').slice(0, 64) || null, new Date(exp).toISOString());
++ },
++ async pending(token) {
++ if (!TOKEN.test(token || '')) return null;
++ return db.prepare(`SELECT p.*, u.subject_id FROM chat_app_pending_links p LEFT JOIN users u ON u.id = p.confirmed_by
++ WHERE token_hash = ? AND expires_at > ?`).get(hash(token), iso());
++ },
++ /**
++ * Half of the link: the signed-in person says yes and gets a 6-digit code (only its hash is
++ * kept). Nothing is linked until the same chat account sends that code to the bot (finish).
++ * Confirming again rotates the code; confirming alone never links.
++ */
++ async confirm(token, userId) {
++ if (!TOKEN.test(token || '')) throw fail(400, 'Invalid link');
++ return db.tx(async t => {
++ const p = await t.prepare('SELECT * FROM chat_app_pending_links WHERE token_hash = ? FOR UPDATE').get(hash(token));
++ if (!p || p.finished_at || p.cancelled_at || p.expires_at <= iso()) throw fail(410, 'This link expired or was already used');
++ // Once one signed-in person has confirmed, another account cannot rotate (or take) it.
++ if (p.confirmed_by && Number(p.confirmed_by) !== Number(userId)) throw fail(409, 'This link was already confirmed by another account');
++ const user = await t.prepare('SELECT id FROM users WHERE id = ? AND deleted_at IS NULL AND merged_into IS NULL AND is_banned = 0 FOR UPDATE').get(userId);
++ if (!user) throw fail(403, 'This account cannot be linked');
++ // Refuse before showing a code that could never be used: an identity is one person, and a
++ // service account belongs to one chat account.
++ const key = service(p.app, p.external_id);
++ const mine = await t.prepare('SELECT * FROM linked_accounts WHERE user_id = ? AND service = ?').get(userId, key);
++ if (mine && mine.service_user_id !== p.external_id) throw fail(409, 'Unlink the previous chat account first');
++ const other = await t.prepare('SELECT user_id FROM linked_accounts WHERE service = ? AND service_user_id = ?').get(key, p.external_id);
++ if (other && other.user_id !== userId) throw fail(409, 'This chat account is already linked');
++ const code = String(crypto.randomInt(0, 1_000_000)).padStart(6, '0');
++ const codeExpires = new Date(Math.min(Date.parse(p.expires_at), now() + CODE_TTL_MS)).toISOString();
++ await t.prepare(`UPDATE chat_app_pending_links SET confirmed_by = ?, confirmed_at = ?, code_hash = ?, code_expires_at = ?, attempts = 0, cancelled_at = NULL
++ WHERE token_hash = ?`).run(userId, iso(), codeHash(p.app, p.external_id, code), codeExpires, p.token_hash);
++ return { code, row: p };
++ });
++ },
++ /**
++ * The other half: the chat account that started /link sends the code to the bot. Three wrong
++ * codes cancel the pending link, an expired code cannot be used, and only now is the account
++ * link written.
++ */
++ async finish(b) {
++ if (!validIdentity(b.app, String(b.service_user_id || ''))) throw fail(400, 'Invalid identity');
++ if (!CODE.test(String(b.code || ''))) return { ok: false, error: 'code', attemptsLeft: MAX_ATTEMPTS };
++ return db.tx(async t => {
++ const p = await t.prepare(`SELECT * FROM chat_app_pending_links
++ WHERE app = ? AND external_id = ? AND confirmed_by IS NOT NULL AND finished_at IS NULL AND cancelled_at IS NULL AND expires_at > ?
++ ORDER BY confirmed_at DESC LIMIT 1 FOR UPDATE`).get(b.app, b.service_user_id, iso());
++ if (!p) return { ok: false, error: 'no_link' };
++ if (!p.code_expires_at || p.code_expires_at <= iso()) {
++ await t.prepare('UPDATE chat_app_pending_links SET cancelled_at = ? WHERE token_hash = ?').run(iso(), p.token_hash);
++ return { ok: false, error: 'expired' };
++ }
++ if (!same(p.code_hash, codeHash(p.app, p.external_id, b.code))) {
++ const attempts = Number(p.attempts || 0) + 1;
++ if (attempts >= MAX_ATTEMPTS) {
++ await t.prepare('UPDATE chat_app_pending_links SET attempts = ?, cancelled_at = ?, code_hash = NULL WHERE token_hash = ?').run(attempts, iso(), p.token_hash);
++ } else {
++ await t.prepare('UPDATE chat_app_pending_links SET attempts = ? WHERE token_hash = ?').run(attempts, p.token_hash);
++ }
++ return { ok: false, error: 'code', attemptsLeft: Math.max(0, MAX_ATTEMPTS - attempts) };
++ }
++ const user = await t.prepare('SELECT id, subject_id FROM users WHERE id = ? AND deleted_at IS NULL AND merged_into IS NULL AND is_banned = 0').get(p.confirmed_by);
++ if (!user) throw fail(409, 'This account cannot be linked');
++ const key = service(p.app, p.external_id);
++ const mine = await t.prepare('SELECT * FROM linked_accounts WHERE user_id = ? AND service = ?').get(user.id, key);
++ if (mine && mine.service_user_id !== p.external_id) throw fail(409, 'Unlink the previous chat account first');
++ const other = await t.prepare('SELECT user_id FROM linked_accounts WHERE service = ? AND service_user_id = ?').get(key, p.external_id);
++ if (other && other.user_id !== user.id) throw fail(409, 'This chat account is already linked');
++ const row = await t.prepare(`INSERT INTO linked_accounts (service, service_user_id, user_id, service_username, linked_at)
++ VALUES (?, ?, ?, ?, ov_now()) ON CONFLICT DO NOTHING RETURNING user_id`).get(key, p.external_id, user.id, p.label);
++ if (!row && !mine) throw fail(409, 'This chat account is already linked');
++ await t.prepare('UPDATE chat_app_pending_links SET finished_at = ?, code_hash = NULL WHERE token_hash = ?').run(iso(), p.token_hash);
++ return { ok: true, subject: user.subject_id, app: p.app };
++ });
++ },
++ async resolve(app, externalId) {
++ return db.prepare(`SELECT u.subject_id FROM linked_accounts l JOIN users u ON u.id = l.user_id
++ WHERE l.service = ? AND l.service_user_id = ? AND u.deleted_at IS NULL AND u.merged_into IS NULL AND u.is_banned = 0`).get(service(app, externalId), externalId);
++ },
++ async unlink(app, externalId) {
++ await db.tx(async t => {
++ await t.prepare('DELETE FROM linked_accounts WHERE service = ? AND service_user_id = ?').run(service(app, externalId), externalId);
++ await t.prepare('DELETE FROM chat_app_pending_links WHERE app = ? AND external_id = ?').run(app, externalId);
++ });
++ },
++ };
++}
++function internalRouter() {
++ const r = express.Router();
++ r.use((req, res, next) => req.principal && req.principal.sub === 'svc:chat'
++ ? next() : res.status(403).json({ error: 'Chat service only' }));
++ r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
++ const wrap = fn => async (req, res) => { try { await fn(req, res, store(req.app.locals.db)); } catch (e) { res.status(e.status || 503).json({ error: e.status ? e.message : 'Link service unavailable' }); } };
++ r.post('/', wrap(async (req, res, s) => { await s.register(req.body || {}); res.json({ ok: true }); }));
++ r.get('/', wrap(async (req, res, s) => {
++ const p = await s.pending(req.query.t);
++ const state = !p ? 'unknown' : p.finished_at ? 'finished' : p.cancelled_at ? 'cancelled' : p.confirmed_at ? 'confirmed' : 'pending';
++ res.json({ state, subject: p && p.subject_id ? p.subject_id : null });
++ }));
++ r.post('/finish', wrap(async (req, res, s) => {
++ const out = await s.finish(req.body || {});
++ // A wrong or expired code is a normal answer (the bot says how many tries are left), not a
++ // transport error; only "no link was started by this chat account" is a 404.
++ res.status(out.ok || out.error !== 'no_link' ? 200 : 404).json(out);
++ }));
++ r.get('/resolve', wrap(async (req, res, s) => res.json(await s.resolve(req.query.app, req.query.id) || { subject_id: null })));
++ r.post('/unlink', wrap(async (req, res, s) => {
++ if (!validIdentity(req.body.app, String(req.body.service_user_id || ''))) throw fail(400, 'Invalid identity');
++ await s.unlink(req.body.app, req.body.service_user_id); res.json({ ok: true });
++ }));
++ return r;
++}
++/**
++ * The signed-in person's own chat-app accounts (the website's account page): what is linked, and
++ * the way to remove one without going back to the chat. No token or hash is ever returned.
++ */
++function userRouter(requireAuth) {
++ const r = express.Router();
++ r.use(requireAuth);
++ r.get('/', async (req, res) => {
++ try {
++ const rows = await req.app.locals.db.prepare(`SELECT service, service_user_id, service_username, linked_at FROM linked_accounts
++ WHERE user_id = ? AND service LIKE 'chat:%' ORDER BY linked_at DESC`).all(req.user.id);
++ res.set('Cache-Control', 'no-store').json({ items: rows.map((l) => {
++ const [, app, workspace] = l.service.split(':');
++ return { app, workspace: workspace || null, service_user_id: l.service_user_id, service_username: l.service_username || null, linked_at: l.linked_at };
++ }) });
++ } catch { res.status(503).json({ error: 'Link service unavailable' }); }
++ });
++ r.delete('/:app', async (req, res) => {
++ const app = String(req.params.app || '');
++ try {
++ const rows = await req.app.locals.db.prepare("DELETE FROM linked_accounts WHERE user_id = ? AND service LIKE ? RETURNING service, service_user_id")
++ .all(req.user.id, app === 'slack' ? 'chat:slack:%' : `chat:${app}`);
++ if (!rows.length) return res.status(404).json({ error: 'Not linked' });
++ for (const row of rows) await req.app.locals.db.prepare('DELETE FROM chat_app_pending_links WHERE app = ? AND external_id = ?').run(app, row.service_user_id);
++ res.set('Cache-Control', 'no-store').json({ ok: true, removed: rows.length });
++ } catch { res.status(503).json({ error: 'Link service unavailable' }); }
++ });
++ return r;
++}
++
++const page = (title, body) => `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>${escape(title)}</title>
++<style>body{font:16px/1.5 system-ui,sans-serif;max-width:38rem;margin:3rem auto;padding:0 1rem;color:#111}code{font-size:2rem;letter-spacing:.35rem;display:inline-block;padding:.6rem 1rem;border:1px solid #111;border-radius:.4rem}button{font:inherit;padding:.6rem 1rem}</style>${body}</html>`;
++
++function pageRouter(requireAuth) {
++ const r = express.Router();
++ r.use((req, res, next) => { res.set({ 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' }); next(); });
++ r.use((req, res, next) => {
++ if (req.method === 'GET' && !req.cookies?.ov_token && !req.get('authorization')) return res.redirect('/login?return=' + encodeURIComponent(req.originalUrl));
++ return requireAuth(req, res, next);
++ });
++ r.get('/link', async (req, res) => {
++ try {
++ const p = await store(req.app.locals.db).pending(req.query.t);
++ if (!p || p.finished_at || p.cancelled_at) return res.status(410).send('This link expired or was already used. Send /link again.');
++ const label = p.label || p.external_id;
++ res.type('html').send(page('Link your chat account', `<main><h1>Link your ${escape(p.app)} account</h1>
++<p><strong>This ${escape(p.app)} account (@${escape(label)}) will talk to your OpenVibe agent and approve its steps.</strong></p>
++<p>Confirm only if you started /link from that chat account yourself. The next page shows a 6-digit code: send it to the bot in that same chat to finish. Until you do, nothing is linked.</p>
++<form method="post" action="/chat-apps/link"><input type="hidden" name="t" value="${escape(req.query.t)}"><button>Yes, this is my chat account</button></form></main>`));
++ } catch { res.status(503).send('Link service unavailable'); }
++ });
++ r.post('/link', async (req, res) => {
++ const origin = new URL(req.app.locals.config.baseUrl).origin;
++ if (req.get('origin') !== origin) return res.status(403).send('Confirm on OpenVibe.Network');
++ try {
++ const { code, row } = await store(req.app.locals.db).confirm(req.body.t, req.user.id);
++ const label = row.label || row.external_id;
++ const bot = row.bot_handle ? `@${row.bot_handle}` : 'the OpenVibe bot';
++ res.type('html').send(page('Send the code to your bot', `<main><h1>One more step</h1>
++<p>Send this code to <strong>${escape(bot)}</strong> from ${escape(row.app)} @${escape(label)} to finish:</p>
++<p><code>${escape(code)}</code></p>
++<p>It works once, for 10 minutes. This is what keeps a link forwarded to somebody else from working: only this chat account can finish it.</p>
++<p>This ${escape(row.app)} account will talk to your OpenVibe agent and approve its steps. If you did not start this, do nothing: the link stays unfinished.</p></main>`));
++ } catch (e) { res.status(e.status || 503).send(e.status ? e.message : 'Link service unavailable'); }
++ });
++ return r;
++}
++module.exports = { store, internalRouter, pageRouter, userRouter, validIdentity };
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex 8e0832ca..da06a837 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -345,6 +345,7 @@ async function erase(db, deletion, { now = Date.now() } = {}) {
for (const t of ['linked_accounts', 'user_sessions', 'oauth_tokens', 'oauth_codes', 'email_verification_tokens', 'password_reset_tokens', 'notifications',
'notification_preferences', 'notification_quiet_hours', 'notification_digest_runs', 'user_preferences', 'user_effects', 'push_subscriptions', 'user_history', 'username_history', 'user_profile_changes',
'ip_log', 'email_delivery_log', 'user_theme_history', 'user_theme_site_overrides']) await del(t, `user_id IN ${inU}`, userIds);
++ await del('chat_app_pending_links', `confirmed_by IN ${inU}`, userIds);
if (await tableExists(db, 'account_merge_intents')) await del('account_merge_intents', `into_user_id IN ${inU}`, userIds);
// Notifications this person sent others stay, without their name or picture.
if (await tableExists(db, 'notifications')) await db.prepare(`UPDATE notifications SET sender_id = NULL, sender_name = 'Deleted account', sender_avatar = NULL WHERE sender_id IN ${inU}`).run(...userIds);
ddiff --git a/server/identity/principals.js b/server/identity/principals.js
iindex 6f451ffc..255de88b 100644
---- a/server/identity/principals.js
++++ b/server/identity/principals.js
@@@ -82,6 +82,10 @@ const DEFAULT_GRANTS = [
['live', 'network.modules.write', SELF_AUDIENCE, ['live.profile', 'live.stats', 'live.loyalty']],
['chat', 'network.modules.read', SELF_AUDIENCE, CHAT_NAMESPACES],
['chat', 'network.modules.write', SELF_AUDIENCE, CHAT_NAMESPACES],
++ // Chat's chat-app bots (plan A4) resolve a Telegram/Discord/Slack account to its linked OpenVibe
++ // subject, and register/confirm/unlink the one-time links. Only svc:chat may reach those routes;
++ // the grant is what its service token is minted with.
++ ['chat', 'identity.subject.resolve', SELF_AUDIENCE, []],
['ai', 'network.modules.read', SELF_AUDIENCE, ['ai.preferences', 'ai.usage_summary']],
['ai', 'network.modules.write', SELF_AUDIENCE, ['ai.usage_summary']],
// The Exchange remake's uncensored tier: AI reads the account's 18+ confirmation before serving an uncensored
ddiff --git a/server/index.js b/server/index.js
iindex 2a564d0a..9a6e2db2 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -696,6 +696,9 @@ app.use('/api/auth', require('./auth/email-verify').routes(requireAuth));
// Discord account linking (OAuth2 flow)
app.use('/api/auth/discord', requireAuth, require('./auth/discord-link'));
++app.use('/chat-apps', require('./chat-apps/links').pageRouter(requireAuth));
++// The account page's chat-app list and removal (the person's own links; no token is returned).
++app.use('/api/v1/me/chat-apps', rateLimit({ windowMs: 60_000, max: 60 }), require('./chat-apps/links').userRouter(requireAuth));
// OAuth2 authorization endpoints (token issuance counted by grant type: server/observability.js).
// The app limit is per address AND client (server/auth/oauth-limit.js); nginx keeps its own per-address zone.
ddiff --git a/server/internal/routes.js b/server/internal/routes.js
iindex bf5749ac..1c82e26c 100644
---- a/server/internal/routes.js
++++ b/server/internal/routes.js
@@@ -47,6 +47,7 @@ function ownSourceSystem(req, res, next) {
}
router.use(requireServiceToken);
++router.use('/chat-app-links', principals.guard('identity.subject.resolve'), require('../chat-apps/links').internalRouter());
// Identity lookups accept a service token with identity.subject.resolve (or the key, as before).
router.get('/identity/resolve', principals.guard('identity.subject.resolve'));
router.post('/identity/resolve-batch', principals.guard('identity.subject.resolve'));
ddiff --git a/test/agent-schema.test.js b/test/agent-schema.test.js
iindex 56050f26..353bbe3b 100644
---- a/test/agent-schema.test.js
++++ b/test/agent-schema.test.js
@@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// The real directory: 0016 applies on top of 0015, once.
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand']]);
++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand']]);
assert.ok(await hasAgents());
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
ddiff --git a/test/chat-app-links.test.js b/test/chat-app-links.test.js
nnew file mode 100644
iindex 00000000..4625f660
---- /dev/null
++++ b/test/chat-app-links.test.js
@@@ -0,0 +1,144 @@
++'use strict';
++const assert = require('node:assert/strict');
++const crypto = require('node:crypto');
++const express = require('express');
++const { getDb } = require('../server/db/database');
++const { store, pageRouter, userRouter } = require('../server/chat-apps/links');
++(async () => {
++ const db = getDb();
++ const mk = async name => (await db.prepare('INSERT INTO users (username, email, password_hash) VALUES (?, ?, ?) RETURNING id').get(name, `${name}@example.test`, 'fixture')).id;
++ const alice = await mk('chat_alice'), bob = await mk('chat_bob');
++ const aliceSubject = (await db.prepare('SELECT subject_id FROM users WHERE id = ?').get(alice)).subject_id;
++ let now = Date.now();
++ const s = store(db, () => now);
++ const make = async (app, external, ttl = 60000) => {
++ const token = crypto.randomBytes(32).toString('base64url');
++ await s.register({ token, app, service_user_id: external, service_username: '<script>', bot_handle: 'openvibe_bot', expires_at: new Date(now + ttl).toISOString() });
++ return token;
++ };
++ const wrong = (code) => String((Number(code) + 1) % 1_000_000).padStart(6, '0');
++
++ // ── Two-way linking: confirming mints a code; only the same chat account can finish it ──
++ for (const [app, external] of [['telegram', '123'], ['discord', '456'], ['slack', 'T123:U123']]) {
++ const token = await make(app, external);
++ assert.equal((await s.pending(token)).app, app);
++ const half = await s.confirm(token, alice);
++ assert.match(half.code, /^[0-9]{6}$/, 'the page shows a 6-digit code');
++ const row = await db.prepare('SELECT * FROM chat_app_pending_links WHERE app = ? AND external_id = ?').get(app, external);
++ assert.equal(row.code_hash, crypto.createHash('sha256').update(`chat-app-code:${app}:${external}:${half.code}`).digest('hex'));
++ assert.notEqual(row.code_hash, half.code, 'the code is kept only as a hash');
++ await assert.rejects(s.confirm(token, bob), /already confirmed/, 'another signed-in account cannot rotate it');
++ assert.equal((await db.prepare("SELECT count(*) AS n FROM information_schema.columns WHERE table_name = 'chat_app_pending_links' AND column_name = 'code'").get()).n, 0, 'no plaintext code column');
++ // A different chat account sending the same code finishes nothing.
++ assert.deepEqual(await s.finish({ app, service_user_id: app === 'slack' ? 'T123:U999' : '999', code: half.code }), { ok: false, error: 'no_link' });
++ assert.equal(await s.resolve(app, external), undefined);
++ assert.equal((await s.pending(token)).finished_at, null, 'still unfinished');
++ // The chat account that started /link finishes it.
++ const done = await s.finish({ app, service_user_id: external, code: half.code });
++ assert.deepEqual(done, { ok: true, subject: aliceSubject, app });
++ assert.equal((await s.resolve(app, external)).subject_id, aliceSubject);
++ assert.ok((await s.pending(token)).finished_at, 'the pending link is finished');
++ // The same code twice finishes nothing more.
++ assert.deepEqual(await s.finish({ app, service_user_id: external, code: half.code }), { ok: false, error: 'no_link' });
++ await s.unlink(app, external);
++ assert.equal(await s.resolve(app, external), undefined);
++ }
++
++ // Three wrong codes cancel the pending link; the right code after that does nothing.
++ {
++ const token = await make('telegram', '777');
++ const { code } = await s.confirm(token, alice);
++ assert.equal((await s.finish({ app: 'telegram', service_user_id: '777', code: wrong(code) })).attemptsLeft, 2);
++ assert.equal((await s.finish({ app: 'telegram', service_user_id: '777', code: wrong(code) })).attemptsLeft, 1);
++ assert.equal((await s.finish({ app: 'telegram', service_user_id: '777', code: wrong(code) })).attemptsLeft, 0);
++ assert.equal((await s.finish({ app: 'telegram', service_user_id: '777', code })).ok, false, 'the link was cancelled');
++ assert.equal(await s.resolve('telegram', '777'), undefined);
++ }
++
++ // The code (and the pending link) expire together; confirming after that is refused.
++ {
++ const token = await make('discord', '888', 60000);
++ const { code } = await s.confirm(token, alice);
++ now += 61000;
++ assert.equal((await s.finish({ app: 'discord', service_user_id: '888', code })).ok, false);
++ await assert.rejects(s.confirm(token, alice), /expired/);
++ const expired = await make('telegram', '987'); now += 61000;
++ await assert.rejects(s.confirm(expired, alice), /expired/);
++ }
++
++ // A different signed-in account cannot take an identity, even with a fresh link and its code.
++ {
++ const token = await make('telegram', '321');
++ const { code } = await s.confirm(token, alice);
++ await s.finish({ app: 'telegram', service_user_id: '321', code });
++ const second = await make('telegram', '321');
++ await assert.rejects(s.confirm(second, bob), /already linked/);
++ await s.unlink('telegram', '321');
++ }
++
++ // The real internal gate: a signed service token with identity.subject.resolve, and only svc:chat.
++ const ISSUER = 'https://openvibe.network';
++ const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
++ const serviceAuth = require('openvibe-contracts').serviceAuth;
++ const token = (sub, cap) => serviceAuth.signServiceToken({ iss: ISSUER, sub, actor_type: 'service', aud: ['openvibe.network'], cap, iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 300, jti: crypto.randomUUID() }, keys.privateKey);
++ const app = express(); app.locals.db = db;
++ app.locals.config = { baseUrl: ISSUER, jwt: { issuer: ISSUER } };
++ app.locals.privateKey = keys.privateKey; app.locals.publicKey = keys.publicKey;
++ app.use(express.json()); app.use(express.urlencoded({ extended: false }));
++ app.use('/internal', require('../server/internal/routes'));
++ const auth = (req, res, next) => { if (!req.get('x-test-user')) return res.sendStatus(401); req.user = { id: alice }; next(); };
++ app.use('/chat-apps', pageRouter(auth));
++ app.use('/api/v1/me/chat-apps', userRouter(auth));
++ const server = app.listen(0, '127.0.0.1'); await new Promise(r => server.once('listening', r));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const call = async (path, { method = 'GET', bearer, body } = {}) => {
++ const r = await fetch(base + path, { method, headers: { ...(bearer ? { authorization: `Bearer ${bearer}` } : {}), ...(body ? { 'content-type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined });
++ const text = await r.text();
++ return { status: r.status, body: text ? JSON.parse(text) : null };
++ };
++ try {
++ // No token, a token without the capability, and another service with it are all refused.
++ assert.equal((await call('/internal/chat-app-links', { method: 'POST', body: {} })).status, 401);
++ assert.equal((await call('/internal/chat-app-links', { method: 'POST', bearer: token('svc:chat', ['actor.task.read']), body: {} })).status, 403);
++ assert.equal((await call('/internal/chat-app-links', { method: 'POST', bearer: token('svc:watch', ['identity.subject.resolve']), body: {} })).status, 403);
++ // svc:chat registers a link; the signed-in confirm page shows the code and the bot to send it to.
++ const chat = token('svc:chat', ['identity.subject.resolve']);
++ const tg = crypto.randomBytes(32).toString('base64url');
++ assert.equal((await call('/internal/chat-app-links', { method: 'POST', bearer: chat, body: { token: tg, app: 'telegram', service_user_id: '555', service_username: 'dana', bot_handle: 'openvibe_bot', expires_at: new Date(now + 300000).toISOString() } })).status, 200);
++ assert.equal((await call(`/internal/chat-app-links?t=${tg}`, { bearer: chat })).body.state, 'pending');
++ const before = await fetch(base + `/chat-apps/link?t=${tg}`, { headers: { 'x-test-user': 'yes', authorization: 'Bearer test' } });
++ const beforeText = await before.text();
++ assert.equal(before.status, 200);
++ assert.match(beforeText, /will talk to your OpenVibe agent and approve its steps/, 'the page says in plain words what linking does');
++ assert.match(beforeText, /6-digit code/);
++ const confirmed = await fetch(base + '/chat-apps/link', { method: 'POST', headers: { 'x-test-user': 'yes', authorization: 'Bearer test', origin: ISSUER, 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ t: tg }).toString() });
++ const page = await confirmed.text();
++ assert.equal(confirmed.status, 200);
++ const code = page.match(/<code>([0-9]{6})<\/code>/)[1];
++ assert.match(page, /@openvibe_bot/, 'the code page names the bot');
++ assert.match(page, /telegram @dana/, 'and the chat account to send it from');
++ assert.equal((await call(`/internal/chat-app-links?t=${tg}`, { bearer: chat })).body.state, 'confirmed');
++ // Another chat account sending the code finishes nothing; the same one does.
++ assert.equal((await call('/internal/chat-app-links/finish', { method: 'POST', bearer: chat, body: { app: 'telegram', service_user_id: '999', code } })).status, 404);
++ assert.equal((await call('/internal/chat-app-links/resolve?app=telegram&id=555', { bearer: chat })).body.subject_id, null);
++ const done = await call('/internal/chat-app-links/finish', { method: 'POST', bearer: chat, body: { app: 'telegram', service_user_id: '555', code } });
++ assert.deepEqual(done.body, { ok: true, subject: aliceSubject, app: 'telegram' });
++ assert.equal((await call(`/internal/chat-app-links?t=${tg}`, { bearer: chat })).body.state, 'finished');
++ assert.equal((await call('/internal/chat-app-links/resolve?app=telegram&id=555', { bearer: chat })).body.subject_id, aliceSubject);
++ // The account page's own list and removal (signed in, their own rows only).
++ const mine = async (method = 'GET', path = '/api/v1/me/chat-apps') => {
++ const r = await fetch(base + path, { method, headers: { 'x-test-user': 'yes' } });
++ const text = await r.text();
++ return { status: r.status, body: text ? JSON.parse(text) : null };
++ };
++ assert.equal((await fetch(base + '/api/v1/me/chat-apps')).status, 401);
++ assert.deepEqual((await mine()).body.items.map((i) => [i.app, i.service_user_id]), [['telegram', '555']]);
++ assert.deepEqual(await mine('DELETE', '/api/v1/me/chat-apps/telegram'), { status: 200, body: { ok: true, removed: 1 } });
++ assert.deepEqual((await mine()).body, { items: [] });
++ assert.equal((await mine('DELETE', '/api/v1/me/chat-apps/telegram')).status, 404);
++ // The confirm page: signed out redirects, a cross-origin POST is refused.
++ assert.equal((await fetch(base + '/chat-apps/link', { redirect: 'manual' })).status, 302);
++ assert.equal((await fetch(base + '/chat-apps/link', { method: 'POST', headers: { 'x-test-user': 'yes', origin: 'https://evil.test', 'content-type': 'application/json' }, body: '{}' })).status, 403);
++ } finally { await new Promise(r => server.close(r)); }
++ console.log('chat-app links: two-way codes, the wrong chat account refused, expiry, three wrong codes, real service-token gate, CSRF passed');
++})().catch(e => { console.error(e); process.exitCode = 1; });
ddiff --git a/test/user-owned-trust.test.js b/test/user-owned-trust.test.js
iindex e9fbd888..56f705bd 100644
---- a/test/user-owned-trust.test.js
++++ b/test/user-owned-trust.test.js
@@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// ── 0020 applies on top of 0019, once, and rewrites nothing.
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand']]);
++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand']]);
assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twice
assert.deepStrictEqual(await rows(), seeded, 'no existing row changes');