openvibers/openvibe.network Commits
Network: the chat apps' account links (A4) (#591)
Changes
nnew file mode 100644@@@ -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`).nnew file mode 100644@@@ -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);nnew file mode 100644@@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 };@@@ -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);@@@ -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@@@ -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.@@@ -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'));@@@ -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.nnew file mode 100644@@@ -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; });@@@ -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 twiceassert.deepStrictEqual(await rows(), seeded, 'no existing row changes');
new file mode 100644
new file mode 100644
@@ -0,0 +1,74 @@
@@ -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`).
new file mode 100644
new file mode 100644
@@ -0,0 +1,25 @@
@@ -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);
new file mode 100644
new file mode 100644
@@ -0,0 +1,212 @@
@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 };
@@ -345,6 +345,7 @@ async function erase(db, deletion, { now = Date.now() } = {}) {
@@ -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',
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',
'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);
'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);
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.
// 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);
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);
@@ -82,6 +82,10 @@ const DEFAULT_GRANTS = [
@@ -82,6 +82,10 @@ const DEFAULT_GRANTS = [
['live', 'network.modules.write', SELF_AUDIENCE, ['live.profile', 'live.stats', 'live.loyalty']],
['live', 'network.modules.write', SELF_AUDIENCE, ['live.profile', 'live.stats', 'live.loyalty']],
['chat', 'network.modules.read', SELF_AUDIENCE, CHAT_NAMESPACES],
['chat', 'network.modules.read', SELF_AUDIENCE, CHAT_NAMESPACES],
['chat', 'network.modules.write', 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.read', SELF_AUDIENCE, ['ai.preferences', 'ai.usage_summary']],
['ai', 'network.modules.write', SELF_AUDIENCE, ['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
// The Exchange remake's uncensored tier: AI reads the account's 18+ confirmation before serving an uncensored
@@ -696,6 +696,9 @@ app.use('/api/auth', require('./auth/email-verify').routes(requireAuth));
@@ -696,6 +696,9 @@ app.use('/api/auth', require('./auth/email-verify').routes(requireAuth));
// Discord account linking (OAuth2 flow)
// Discord account linking (OAuth2 flow)
app.use('/api/auth/discord', requireAuth, require('./auth/discord-link'));
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).
// 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.
// The app limit is per address AND client (server/auth/oauth-limit.js); nginx keeps its own per-address zone.
@@ -47,6 +47,7 @@ function ownSourceSystem(req, res, next) {
@@ -47,6 +47,7 @@ function ownSourceSystem(req, res, next) {
}
}
router.use(requireServiceToken);
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).
// 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.get('/identity/resolve', principals.guard('identity.subject.resolve'));
router.post('/identity/resolve-batch', principals.guard('identity.subject.resolve'));
router.post('/identity/resolve-batch', principals.guard('identity.subject.resolve'));
@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// The real directory: 0016 applies on top of 0015, once.
// The real directory: 0016 applies on top of 0015, once.
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
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.ok(await hasAgents());
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
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.
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
new file mode 100644
new file mode 100644
@@ -0,0 +1,144 @@
@@ -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; });
@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// ── 0020 applies on top of 0019, once, and rewrites nothing.
// ── 0020 applies on top of 0019, once, and rewrites nothing.
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
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');
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
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');
assert.deepStrictEqual(await rows(), seeded, 'no existing row changes');