openvibers/openvibe.chat Commits
openvibe.chat/developers, and the room embed's sign-in ticket kept out of every URL (#151)
Changes
@@@ -310,3 +310,10 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }.fd-dot { background: var(--oc-muted); }.fd-dot.fd-on { background: var(--oc-ok); }.fd-key code { font-size: 1rem; user-select: all; }++++/* The developers page (server/web/developer-pages.js) */++.oc-lead { font-size: 1.1rem; color: var(--oc-muted); max-width: 760px; }++.oc-toc { display: flex; flex-wrap: wrap; gap: 6px 12px; padding: 10px 14px; margin: 14px 0 6px; border: 1px solid var(--oc-line); border-radius: 12px; background: var(--oc-panel); font-size: .92rem; }++.oc-table { border-collapse: collapse; width: 100%; max-width: 760px; font-size: .92rem; }++.oc-table th, .oc-table td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--oc-line); vertical-align: top; }++.oc-table th { color: var(--oc-muted); font-weight: 600; }@@@ -1,6 +1,6 @@/* A chat room in a frame on any website (server/web/room-embed-pages.js /embed/room/:slug). Anyone reads; the room'smessages arrive over the chat socket (join_room). To write, a person signs in through a window on openvibe.chat:-- this frame makes a random ticket, opens /embed/signin?ticket=…, and asks /embed/ticket until the window has bound it++ this frame makes a random ticket, opens /embed/signin#t=…, and asks /embed/ticket until the window has bound itto their session; the token it gets back is kept in this frame's own storage (partitioned by the site embeddingit) and sent with join_room. Everything a person wrote is set with textContent; links are made with DOM nodes. */(function () {@@@ -96,7 +96,8 @@}signinBtn.addEventListener('click', function () {var ticket = newTicket();-- var w = window.open('/embed/signin?ticket=' + encodeURIComponent(ticket), 'ovchat-signin', 'width=480,height=680');++ // The ticket rides in the fragment: the window keeps it in its own storage, and no server or log sees it.++ var w = window.open('/embed/signin#t=' + ticket, 'ovchat-signin', 'width=480,height=680');if (!w) { error('Allow pop-ups for this site to sign in, or open the room on OpenVibe.'); return; }error('');var started = Date.now();nnew file mode 100644@@@ -0,0 +1,102 @@++'use strict';++/**++ * openvibe.chat/developers: one page for everyone building on OpenVibe Chat (docs/platform.md, the owner 2026-10-24:++ * "fully modular and SDK and API"): the tag for a support inbox or a room, verified visitors, a panel of your own,++ * feeds from Claude Code or anything, acting as a person with the SDK, webhooks, and the limits. Public and indexed.++ */++const { esc } = require('./layout');++++const code = (s) => `<pre class="ib-snippet"><code>${esc(s)}</code></pre>`;++++function body(base) {++ return `<h1>Build with OpenVibe Chat</h1>++<p class="oc-lead">One chat system, from a private chat with Vibe to a chat bubble on your website. Everything here is open: a tag for any site, a REST API and socket, the <code>openvibe-sdk/chat</code> package, and signed webhooks. Free to use; open source.</p>++<nav class="oc-toc" aria-label="On this page"><a href="#widget">Chat on your site</a> · <a href="#verified">Verified visitors</a> · <a href="#panel">Your own panel</a> · <a href="#feeds">Feeds from Claude Code</a> · <a href="#sdk">The SDK</a> · <a href="#webhooks">Webhooks</a> · <a href="#limits">Limits</a></nav>++++<h2 id="widget">Chat on your site</h2>++<p>A <strong>support inbox</strong>: make one at <a href="/inbox">openvibe.chat/inbox</a>, then add one line before <code></body></code>. Vibe answers first from your instructions and pages; visitors can ask for a person, and your team answers from the inbox (or turn people off for a site assistant).</p>++${code(`<script src="${base}/widget.js" data-inbox="inb_…" async></script>`)}++<p>A <strong>public room</strong>, as a bubble or inside your page. Anyone reads; people sign in with OpenVibe to write, and the room's moderation applies.</p>++${code(`<script src="${base}/widget.js" data-room="your-room" async></script>++<script src="${base}/widget.js" data-room="your-room" data-target="#chat" data-height="520" async></script>`)}++<p>Options: <code>data-position="left"</code>, <code>data-color="#ff7a1a"</code>, <code>data-open="1"</code>. From your page: <code>OpenVibeChat.open()</code>, <code>.close()</code>, <code>.toggle()</code>, <code>.identify(user)</code>; it says what happens with the events <code>openvibechat:open</code>, <code>openvibechat:close</code> and <code>openvibechat:message</code> on <code>window</code>. The panel runs on openvibe.chat's own origin, in a frame your site's CSS cannot reach; an inbox's allowed sites decide who may frame it.</p>++++<h2 id="verified">Verified visitors</h2>++<p>When your site has its own accounts, turn on verified visitors in the inbox's settings and keep its secret on your server. For a signed-in visitor, sign their id; your team then sees their verified name and email, and their conversation follows them to every device.</p>++${code(`// Node++const hash = crypto.createHmac('sha256', SECRET).update(String(user.id)).digest('hex');++// in the page++<script src="${base}/widget.js" data-inbox="inb_…" data-user-id="42" data-user-name="Dana Lee"++ data-user-email="[email protected]" data-user-hash="…" async></script>`)}++++<h2 id="panel">Your own panel</h2>++<p>The visitor API answers any site with no cookies: the visitor's token is the key. Build your own chat UI on it, in the browser or on your server.</p>++${code(`import { createClient } from 'openvibe-sdk';++import { createInboxVisitor } from 'openvibe-sdk/chat';++++const visitor = createInboxVisitor(createClient(), { inbox: 'inb_…', token: localStorage.getItem('chat') });++const { token, conversation, messages } = await visitor.visit(); // keep the token: it is the visitor++localStorage.setItem('chat', token);++await visitor.send('Where is my order?', { page_url: location.href });++const { messages: next } = await visitor.messages({ after: lastId });++await visitor.handoff(); // "Talk to a person"++await visitor.rate(1, { comment: 'Quick!' });`)}++<p>Over HTTP: <code>GET ${esc(base)}/api/chat/widget/inbox/:id</code>, <code>POST …/visit</code>, <code>GET|POST …/messages</code>, <code>POST …/handoff</code>, <code>POST …/rating</code>.</p>++++<h2 id="feeds">Feeds from Claude Code (or anything)</h2>++<p>Show what you are building, live: in a room, on your OpenVibe Live channel while you stream, or on your own site. Make a feed at <a href="/feeds">openvibe.chat/feeds</a>; it gives you a key and this block for <code>.claude/settings.json</code>, Claude Code's own <code>http</code> hooks (no extension). Secrets and home folders are wiped before anyone sees a step.</p>++${code(`{ "hooks": { "UserPromptSubmit": [{ "hooks": [{ "type": "http",++ "url": "${base}/api/chat/feeds/fed_…/hooks/claude-code",++ "headers": { "Authorization": "Bearer $OPENVIBE_FEED_KEY" }, "allowedEnvVars": ["OPENVIBE_FEED_KEY"] }] }],++ "PostToolUse": […the same…], "Stop": […], "SessionStart": […], "SessionEnd": […] } }`)}++<p>From anything else, with the same key:</p>++${code(`import { createFeedPublisher } from 'openvibe-sdk/chat';++const feed = createFeedPublisher(createClient(), { feed: 'fed_…', key });++await feed.publish({ eventType: 'prompt', text: 'Add a dark mode' });++await feed.publish([{ eventId: 'e1', eventType: 'tool.call', summary: 'Ran the tests', tool: { name: 'Bash' } }]);`)}++<p>On any page: <code><iframe src="${esc(base)}/embed/feed/fed_…" style="width:100%;height:320px;border:0"></iframe></code></p>++++<h2 id="sdk">The SDK: act as a person or a bot</h2>++<p>With a person's sign-in or an OpenVibe API token, <code>createChatClient</code> covers global chat, rooms, direct messages, Vibe, inboxes (conversations, replies, saved replies, verified visitors, webhooks) and feeds.</p>++${code(`npm install openvibe-sdk++++import { createClient } from 'openvibe-sdk';++import { createChatClient } from 'openvibe-sdk/chat';++const chat = createChatClient(createClient({ token: process.env.OPENVIBE_TOKEN }));++++const { conversations } = await chat.inboxes.conversations('inb_…', { status: 'waiting' });++await chat.inboxes.reply('inb_…', conversations[0].id, 'Hi! I can help with that.');++await chat.rooms.send('builders', 'Shipping now');`)}++<p>Live delivery is the chat socket, <code>wss://openvibe.chat/ws/chat</code>: <code>join_room</code>, <code>inbox_watch</code> and <code>feed_watch</code>.</p>++++<h2 id="webhooks">Webhooks</h2>++<p>An inbox's owner adds up to three https addresses. A Slack or Discord webhook gets a line when a visitor waits for a person, writes while waiting, or rates a conversation. Any other address gets every event as JSON, signed like OpenVibe.Events deliveries: <code>chat.inbox.conversation.created</code>, <code>.waiting</code>, <code>.resolved</code>, <code>.rated</code> and <code>chat.inbox.message.created</code>.</p>++${code(`import { parseDelivery } from 'openvibe-sdk/events';++++app.post('/hooks/openvibe', express.raw({ type: 'application/json' }), (req, res) => {++ const d = parseDelivery(req.body, req.headers, process.env.WEBHOOK_SECRET, { requireV2: true });++ if (!d) return res.sendStatus(401);++ if (d.event.type === 'chat.inbox.conversation.waiting') page(d.event.data.conversation);++ res.sendStatus(204);++});`)}++++<h2 id="limits">Limits</h2>++<table class="oc-table"><thead><tr><th>What</th><th>Limit</th></tr></thead><tbody>++<tr><td>Visitor writes (one address)</td><td>20 a minute</td></tr>++<tr><td>Vibe's answers in an inbox</td><td>30 a conversation, 500 an inbox a day</td></tr>++<tr><td>Inboxes, people, saved replies, webhooks</td><td>10 a person; 50 people, 100 saved replies and 3 webhooks an inbox</td></tr>++<tr><td>Feed events</td><td>600 a minute a feed (a batch of 50 is one request); a feed keeps its newest 5,000</td></tr>++<tr><td>Feeds</td><td>10 a person</td></tr>++</tbody></table>++<p class="oc-muted">The full reference is Chat's README on GitHub (<a href="https://github.com/OpenVibers/OpenVibe.Chat#support-inboxes-and-the-website-widget-openvibechatinbox">support inboxes</a>, <a href="https://github.com/OpenVibers/OpenVibe.Chat#feeds-vibe-coding-live-openvibechatfeeds">feeds</a>); the whole platform's building blocks are on <a href="https://openvibe.services">openvibe.services</a>.</p>`;++}++++function mountDeveloperPages(router, { page, config }) {++ router.get('/developers', async (req, res) => await page(req, res, 200, {++ title: 'Build with OpenVibe Chat', path: '/developers', robots: 'index, follow',++ description: 'Add a support inbox or a chat room to any website with one tag, build your own panel on the open API, show Claude Code sessions live with feeds, and hear events through signed webhooks.',++ body: body(config.web.baseUrl),++ }));++}++++module.exports = { mountDeveloperPages };@@@ -31,6 +31,7 @@ async function publicPages() {{ path: '/rooms', changefreq: 'daily', priority: 0.8, lastmod: () => newestRoomActivity(pages) },{ path: '/servers', changefreq: 'daily', priority: 0.8 },{ path: '/updates', changefreq: 'daily', priority: 0.5 },++ { path: '/developers', changefreq: 'weekly', priority: 0.6 },];for (const r of pages) {out.push({ path: `/r/${r.slug}`, changefreq: 'daily', priority: 0.6, lastmod: () => dayOf(r.last_message_at || r.created_at) });@@@ -83,7 +84,7 @@ function createDiscoveryRoutes({ config }) {// the sitemap. Every previous Disallow is kept.res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(seo.robotsTxt({sitemaps: [abs('/sitemap.xml')],-- allow: ['/', '/updates', '/rooms', '/r/', '/servers', '/s/'],++ allow: ['/', '/updates', '/developers', '/rooms', '/r/', '/servers', '/s/'],disallow: ['/r/*/settings', '/s/*/settings', '/join/', '/messages', '/settings', '/auth/', '/api/'],}));});@@@ -100,6 +101,9 @@ function createDiscoveryRoutes({ config }) {{ title: 'Servers', url: abs('/servers'), note: 'public servers: groups with text and voice channels, roles and invites' },{ title: 'What shipped on OpenVibe.Chat', url: abs('/updates') },] },++ { title: 'Build with it', links: [++ { title: 'Developers', url: abs('/developers'), note: 'a support inbox or a room on any website with one tag, the visitor API, openvibe-sdk/chat, feeds from Claude Code, signed webhooks, the limits' },++ ] },{ title: 'Machine-readable', links: [{ title: 'Sitemap', url: abs('/sitemap.xml'), note: 'the public pages above' },{ title: 'robots.txt', url: abs('/robots.txt') },@@@ -900,7 +900,8 @@ ${attachList}`,// ── Servers (server/rooms/servers.js): text and voice channels, roles, invites ──require('./vibe-pages').mountVibePages(router, { page, viewerOf, needUser, sameSite, formLimit, notice });require('./inbox-pages').mountInboxPages(router, { page, viewerOf, needUser, sameSite, formLimit, notice, config });-- require('./room-embed-pages').mountRoomEmbedPages(router, { needUser, config });++ require('./room-embed-pages').mountRoomEmbedPages(router, { viewerOf, sameSite, config });++ require('./developer-pages').mountDeveloperPages(router, { page, config });require('./feed-pages').mountFeedPages(router, { page, needUser, sameSite, formLimit, notice, config });require('./server-pages').mountServerPages(router, { page, needUser, viewerOf, sameSite, formLimit, notice, roomMessage, callPanel, applyRoomChange, pictureComposer, config });@@@ -7,8 +7,10 @@** GET /embed/room/:slug the room in a frame (public rooms only; frame-ancestors any site): its latest messages,* live over the chat socket (public/web/room-embed.js); anyone reads, people sign in to write-- * GET /embed/signin?ticket= the sign-in window the frame opens: OpenVibe's sign-in, then the ticket is bound to the-- * person's session for two minutes++ * GET /embed/signin#t=… the sign-in window the frame opens: it keeps the ticket (in the address's fragment, so++ * no server or log ever sees it) in its own session storage through OpenVibe's sign-in,++ * then binds it with POST /embed/bind (the person's session, same site only)++ * POST /embed/bind { ticket } → the ticket is bound to the signed-in person for two minutes* POST /embed/ticket { ticket } → { token } once the window bound it (the frame asks every second or two)** Why a ticket: a frame on another site gets no openvibe.chat cookies (browsers block or partition them), and the@@@ -70,7 +72,7 @@ function embedHtml(room, members, msgs, { bubble }) {</html>`;}--function mountRoomEmbedPages(router, { needUser, config }) {++function mountRoomEmbedPages(router, { viewerOf, sameSite, config }) {const base = config.web.baseUrl;const ticketLimit = rateLimit({ windowMs: 60_000, max: 90, standardHeaders: true, legacyHeaders: false });@@@ -87,23 +89,44 @@ function mountRoomEmbedPages(router, { needUser, config }) {});router.get('/embed/signin', async (req, res) => {-- const ticket = String(req.query.ticket || '');-- if (!TICKET_RE.test(ticket)) return res.status(400).type('text').send('This sign-in link is not complete. Close this window and try again.');-- const actor = await needUser(req, res); if (!actor) return;-- sweep();-- if (tickets.size > 10000) return res.status(503).type('text').send('Too many sign-ins at once. Try again in a minute.');-- tickets.set(hash(ticket), { token: actor.token, at: Date.now() });++ const actor = await viewerOf(req);const nonce = crypto.randomBytes(16).toString('base64');-- res.set('Content-Security-Policy', `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'unsafe-inline'; frame-ancestors 'none'`)-- .set('Cache-Control', 'no-store').set('X-Robots-Tag', 'noindex').type('html').send(`<!DOCTYPE html>--<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Signed in · OpenVibe Chat</title></head>++ const signedIn = actor.kind === 'user';++ res.set('Content-Security-Policy', `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'`)++ .set('Cache-Control', 'no-store').set('X-Robots-Tag', 'noindex').set('Referrer-Policy', 'no-referrer').type('html').send(`<!DOCTYPE html>++<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Sign in · OpenVibe Chat</title></head><body style="font:16px/1.5 system-ui,sans-serif;display:grid;place-items:center;min-height:90vh;margin:0;text-align:center;padding:16px">--<div><p style="font-size:1.25rem;font-weight:700;margin:0 0 6px">You're signed in as ${esc(actor.user.display_name || actor.user.username)}.</p>--<p style="margin:0;color:#5b6577">Go back to the chat; this window closes by itself.</p></div>--<script nonce="${nonce}">setTimeout(function () { window.close(); }, 1200);</script>++<div><p id="say" style="font-size:1.25rem;font-weight:700;margin:0 0 6px">${signedIn ? `Signing you in as ${esc(actor.user.display_name || actor.user.username)}…` : 'Taking you to OpenVibe sign-in…'}</p>++<p style="margin:0;color:#5b6577">Go back to the chat afterwards; this window closes by itself.</p></div>++<script nonce="${nonce}">(function () {++ var say = document.getElementById('say'), KEY = 'ov-embed-ticket', t = null;++ var m = /(?:^|&)t=([A-Za-z0-9_-]{43})(?:&|$)/.exec(location.hash.slice(1));++ try { if (m) sessionStorage.setItem(KEY, m[1]); t = sessionStorage.getItem(KEY); } catch (e) { t = m && m[1]; }++ if (m) history.replaceState(null, '', location.pathname);++ if (!t) { say.textContent = 'This sign-in link is not complete. Close this window and try again from the chat.'; return; }++ if (!${signedIn ? 'true' : 'false'}) { location.replace('/auth/login?next=' + encodeURIComponent('/embed/signin')); return; }++ fetch('/embed/bind', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ticket: t }) })++ .then(function (r) {++ try { sessionStorage.removeItem(KEY); } catch (e) { /* gone */ }++ if (!r.ok) { say.textContent = 'That did not work. Close this window and try again from the chat.'; return; }++ say.textContent = 'You\u2019re signed in. Go back to the chat.';++ setTimeout(function () { window.close(); }, 1200);++ }, function () { say.textContent = 'You seem to be offline. Try again from the chat.'; });++})();</script></body></html>`);});++ router.post('/embed/bind', ticketLimit, sameSite, async (req, res) => {++ const ticket = String((req.body && req.body.ticket) || '');++ if (!TICKET_RE.test(ticket)) return res.status(400).json({ error: 'No such ticket', code: 'embed.ticket' });++ const actor = await viewerOf(req);++ if (actor.kind !== 'user') return res.status(401).json({ error: 'Sign in first', code: 'embed.sign_in' });++ sweep();++ if (tickets.size > 10000) return res.status(503).json({ error: 'Too many sign-ins at once. Try again in a minute.', code: 'embed.busy' });++ tickets.set(hash(ticket), { token: actor.token, at: Date.now() });++ res.set('Cache-Control', 'no-store').json({ ok: true });++ });++router.post('/embed/ticket', ticketLimit, (req, res) => {const ticket = String((req.body && req.body.ticket) || '');if (!TICKET_RE.test(ticket)) return res.status(400).json({ error: 'No such ticket', code: 'embed.ticket' });nnew file mode 100644@@@ -0,0 +1,33 @@++'use strict';++/**++ * openvibe.chat/developers (server/web/developer-pages.js): one public, indexed page for builders: the tags for an++ * inbox and a room, verified visitors, a panel of your own, feeds from Claude Code, the SDK, webhooks and the limits,++ * with every code sample escaped; the sitemap and llms.txt name it.++ */++const assert = require('assert');++const { boot, suite } = require('./helpers');++++const t = suite('developers page');++const SITE = 'https://openvibe.chat';++let h;++++t('boot', async () => { h = await boot({ env: { CHAT_WEB_URL: SITE } }); });++++t('the page: every way to build on Chat, its samples escaped, indexed', async () => {++ const r = await fetch(`${h.base}/developers`);++ assert.strictEqual(r.status, 200);++ const html = await r.text();++ assert.ok(html.includes('<h1>Build with OpenVibe Chat</h1>') && /<meta name="robots" content="index, follow"/.test(html));++ const wants = ['data-inbox="inb_…"', 'data-room="your-room"', 'data-user-hash', 'createInboxVisitor', '/hooks/claude-code', 'createFeedPublisher', 'createChatClient', 'parseDelivery', 'chat.inbox.conversation.waiting'];++ const missing = wants.filter((w) => !html.includes(w));++ assert.deepStrictEqual(missing, [], 'the page shows every sample');++ assert.ok(!/<script src="https:\/\/openvibe\.chat\/widget\.js"/.test(html), 'the tags are shown, not run');++ assert.ok(html.includes('href="/inbox"') && html.includes('href="/feeds"'));++});++++t('search engines and AI crawlers find it', async () => {++ assert.ok((await (await fetch(`${h.base}/sitemap.xml`)).text()).includes(`${SITE}/developers`));++ assert.ok((await (await fetch(`${h.base}/llms.txt`)).text()).includes(`${SITE}/developers`));++});++++t.run(async () => { if (h && h.close) await h.close(); });@@@ -42,19 +42,21 @@ t('only a public room embeds, on any site, with its latest messages', async () =assert.strictEqual((await page('/embed/room/no-such-room')).status, 404);});--t('signing in from the frame: a window binds the ticket, the frame collects the token once', async () => {++t('signing in from the frame: the window keeps the ticket out of every address, binds it, the frame collects the token once', async () => {const tk = ticket();assert.deepStrictEqual(await collect(tk), { status: 202, body: { pending: true } });-- const out = await page(`/embed/signin?ticket=${tk}`);-- assert.strictEqual(out.status, 303);-- assert.ok(out.headers.get('location').startsWith('/auth/login?next=%2Fembed%2Fsignin%3Fticket%3D'), 'sign in first, then back');-- const ok = await page(`/embed/signin?ticket=${tk}`, ann);-- assert.ok(ok.status === 200 && ok.text.includes('signed in as Ann') && /frame-ancestors 'none'/.test(ok.headers.get('content-security-policy')));-- assert.strictEqual(ok.headers.get('cache-control'), 'no-store');++ const anon = await page('/embed/signin');++ assert.ok(anon.status === 200 && anon.text.includes("/auth/login?next=") && anon.text.includes('location.hash'), 'the window sends a stranger to sign in, keeping the ticket itself');++ assert.ok(/frame-ancestors 'none'/.test(anon.headers.get('content-security-policy')) && anon.headers.get('cache-control') === 'no-store');++ assert.ok((await page('/embed/signin', ann)).text.includes('Signing you in as Ann'));++ const bind = (u, body, origin = SITE) => page('/embed/bind', u, { method: 'POST', headers: { 'content-type': 'application/json', origin }, body: JSON.stringify(body) });++ assert.strictEqual((await bind(null, { ticket: tk })).status, 401, 'only a signed-in person binds');++ assert.strictEqual((await bind(ann, { ticket: tk }, 'https://elsewhere.example')).status, 403, 'and only from openvibe.chat');++ assert.strictEqual((await bind(ann, { ticket: 'short' })).status, 400);++ assert.strictEqual((await bind(ann, { ticket: tk })).status, 200);assert.deepStrictEqual(await collect(tk), { status: 200, body: { token: ann.token } });assert.deepStrictEqual(await collect(tk), { status: 202, body: { pending: true } }, 'once');assert.strictEqual((await collect('short')).status, 400);-- assert.strictEqual((await page('/embed/signin?ticket=bad', ann)).status, 400);});t('with the token the socket joins as them: a newcomer joins, posts, and everyone reading sees it', async () => {@@@ -63,7 +65,7 @@ t('with the token the socket joins as them: a newcomer joins, posts, and everyonconst r = await reader.next((m) => m.type === 'room_joined');assert.strictEqual(r.can.post, false, 'anyone reads, nobody anonymous writes');const tk = ticket();-- await page(`/embed/signin?ticket=${tk}`, bob);++ await page('/embed/bind', bob, { method: 'POST', headers: { 'content-type': 'application/json', origin: SITE }, body: JSON.stringify({ ticket: tk }) });const token = (await collect(tk)).body.token;const writer = await h.ws({});writer.sendJson({ type: 'join_room', room: 'pixel-fans', token });@@@ -121,6 +121,7 @@ t('ratchet: every file that makes an outbound request itself is reviewed', () =>'server/chat/soundboard-service.js': '101soundboards (host allowlist; audio via safeLookup)','server/chat/tts-engine.js': 'Google / AWS TTS (fixed hosts)','server/inbox/webhooks.js': "an inbox owner's webhook addresses: https only, assertPublicUrl when saved and safeLookup on every delivery, no redirects followed",++ 'server/web/room-embed-pages.js': 'not the server: the sign-in window\'s own script posts to /embed/bind on this same site',};const root = path.join(__dirname, '..');const found = [];
@@ -310,3 +310,10 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }
@@ -310,3 +310,10 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }
.fd-dot { background: var(--oc-muted); }
.fd-dot { background: var(--oc-muted); }
.fd-dot.fd-on { background: var(--oc-ok); }
.fd-dot.fd-on { background: var(--oc-ok); }
.fd-key code { font-size: 1rem; user-select: all; }
.fd-key code { font-size: 1rem; user-select: all; }
+
+/* The developers page (server/web/developer-pages.js) */
+.oc-lead { font-size: 1.1rem; color: var(--oc-muted); max-width: 760px; }
+.oc-toc { display: flex; flex-wrap: wrap; gap: 6px 12px; padding: 10px 14px; margin: 14px 0 6px; border: 1px solid var(--oc-line); border-radius: 12px; background: var(--oc-panel); font-size: .92rem; }
+.oc-table { border-collapse: collapse; width: 100%; max-width: 760px; font-size: .92rem; }
+.oc-table th, .oc-table td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--oc-line); vertical-align: top; }
+.oc-table th { color: var(--oc-muted); font-weight: 600; }
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
/* A chat room in a frame on any website (server/web/room-embed-pages.js /embed/room/:slug). Anyone reads; the room's
/* A chat room in a frame on any website (server/web/room-embed-pages.js /embed/room/:slug). Anyone reads; the room's
messages arrive over the chat socket (join_room). To write, a person signs in through a window on openvibe.chat:
messages arrive over the chat socket (join_room). To write, a person signs in through a window on openvibe.chat:
- this frame makes a random ticket, opens /embed/signin?ticket=…, and asks /embed/ticket until the window has bound it
+ this frame makes a random ticket, opens /embed/signin#t=…, and asks /embed/ticket until the window has bound it
to their session; the token it gets back is kept in this frame's own storage (partitioned by the site embedding
to their session; the token it gets back is kept in this frame's own storage (partitioned by the site embedding
it) and sent with join_room. Everything a person wrote is set with textContent; links are made with DOM nodes. */
it) and sent with join_room. Everything a person wrote is set with textContent; links are made with DOM nodes. */
(function () {
(function () {
@@ -96,7 +96,8 @@
@@ -96,7 +96,8 @@
}
}
signinBtn.addEventListener('click', function () {
signinBtn.addEventListener('click', function () {
var ticket = newTicket();
var ticket = newTicket();
- var w = window.open('/embed/signin?ticket=' + encodeURIComponent(ticket), 'ovchat-signin', 'width=480,height=680');
+ // The ticket rides in the fragment: the window keeps it in its own storage, and no server or log sees it.
+ var w = window.open('/embed/signin#t=' + ticket, 'ovchat-signin', 'width=480,height=680');
if (!w) { error('Allow pop-ups for this site to sign in, or open the room on OpenVibe.'); return; }
if (!w) { error('Allow pop-ups for this site to sign in, or open the room on OpenVibe.'); return; }
error('');
error('');
var started = Date.now();
var started = Date.now();
new file mode 100644
new file mode 100644
@@ -0,0 +1,102 @@
@@ -0,0 +1,102 @@
+'use strict';
+/**
+ * openvibe.chat/developers: one page for everyone building on OpenVibe Chat (docs/platform.md, the owner 2026-10-24:
+ * "fully modular and SDK and API"): the tag for a support inbox or a room, verified visitors, a panel of your own,
+ * feeds from Claude Code or anything, acting as a person with the SDK, webhooks, and the limits. Public and indexed.
+ */
+const { esc } = require('./layout');
+
+const code = (s) => `<pre class="ib-snippet"><code>${esc(s)}</code></pre>`;
+
+function body(base) {
+ return `<h1>Build with OpenVibe Chat</h1>
+<p class="oc-lead">One chat system, from a private chat with Vibe to a chat bubble on your website. Everything here is open: a tag for any site, a REST API and socket, the <code>openvibe-sdk/chat</code> package, and signed webhooks. Free to use; open source.</p>
+<nav class="oc-toc" aria-label="On this page"><a href="#widget">Chat on your site</a> · <a href="#verified">Verified visitors</a> · <a href="#panel">Your own panel</a> · <a href="#feeds">Feeds from Claude Code</a> · <a href="#sdk">The SDK</a> · <a href="#webhooks">Webhooks</a> · <a href="#limits">Limits</a></nav>
+
+<h2 id="widget">Chat on your site</h2>
+<p>A <strong>support inbox</strong>: make one at <a href="/inbox">openvibe.chat/inbox</a>, then add one line before <code></body></code>. Vibe answers first from your instructions and pages; visitors can ask for a person, and your team answers from the inbox (or turn people off for a site assistant).</p>
+${code(`<script src="${base}/widget.js" data-inbox="inb_…" async></script>`)}
+<p>A <strong>public room</strong>, as a bubble or inside your page. Anyone reads; people sign in with OpenVibe to write, and the room's moderation applies.</p>
+${code(`<script src="${base}/widget.js" data-room="your-room" async></script>
+<script src="${base}/widget.js" data-room="your-room" data-target="#chat" data-height="520" async></script>`)}
+<p>Options: <code>data-position="left"</code>, <code>data-color="#ff7a1a"</code>, <code>data-open="1"</code>. From your page: <code>OpenVibeChat.open()</code>, <code>.close()</code>, <code>.toggle()</code>, <code>.identify(user)</code>; it says what happens with the events <code>openvibechat:open</code>, <code>openvibechat:close</code> and <code>openvibechat:message</code> on <code>window</code>. The panel runs on openvibe.chat's own origin, in a frame your site's CSS cannot reach; an inbox's allowed sites decide who may frame it.</p>
+
+<h2 id="verified">Verified visitors</h2>
+<p>When your site has its own accounts, turn on verified visitors in the inbox's settings and keep its secret on your server. For a signed-in visitor, sign their id; your team then sees their verified name and email, and their conversation follows them to every device.</p>
+${code(`// Node
+const hash = crypto.createHmac('sha256', SECRET).update(String(user.id)).digest('hex');
+// in the page
+<script src="${base}/widget.js" data-inbox="inb_…" data-user-id="42" data-user-name="Dana Lee"
+ data-user-email="[email protected]" data-user-hash="…" async></script>`)}
+
+<h2 id="panel">Your own panel</h2>
+<p>The visitor API answers any site with no cookies: the visitor's token is the key. Build your own chat UI on it, in the browser or on your server.</p>
+${code(`import { createClient } from 'openvibe-sdk';
+import { createInboxVisitor } from 'openvibe-sdk/chat';
+
+const visitor = createInboxVisitor(createClient(), { inbox: 'inb_…', token: localStorage.getItem('chat') });
+const { token, conversation, messages } = await visitor.visit(); // keep the token: it is the visitor
+localStorage.setItem('chat', token);
+await visitor.send('Where is my order?', { page_url: location.href });
+const { messages: next } = await visitor.messages({ after: lastId });
+await visitor.handoff(); // "Talk to a person"
+await visitor.rate(1, { comment: 'Quick!' });`)}
+<p>Over HTTP: <code>GET ${esc(base)}/api/chat/widget/inbox/:id</code>, <code>POST …/visit</code>, <code>GET|POST …/messages</code>, <code>POST …/handoff</code>, <code>POST …/rating</code>.</p>
+
+<h2 id="feeds">Feeds from Claude Code (or anything)</h2>
+<p>Show what you are building, live: in a room, on your OpenVibe Live channel while you stream, or on your own site. Make a feed at <a href="/feeds">openvibe.chat/feeds</a>; it gives you a key and this block for <code>.claude/settings.json</code>, Claude Code's own <code>http</code> hooks (no extension). Secrets and home folders are wiped before anyone sees a step.</p>
+${code(`{ "hooks": { "UserPromptSubmit": [{ "hooks": [{ "type": "http",
+ "url": "${base}/api/chat/feeds/fed_…/hooks/claude-code",
+ "headers": { "Authorization": "Bearer $OPENVIBE_FEED_KEY" }, "allowedEnvVars": ["OPENVIBE_FEED_KEY"] }] }],
+ "PostToolUse": […the same…], "Stop": […], "SessionStart": […], "SessionEnd": […] } }`)}
+<p>From anything else, with the same key:</p>
+${code(`import { createFeedPublisher } from 'openvibe-sdk/chat';
+const feed = createFeedPublisher(createClient(), { feed: 'fed_…', key });
+await feed.publish({ eventType: 'prompt', text: 'Add a dark mode' });
+await feed.publish([{ eventId: 'e1', eventType: 'tool.call', summary: 'Ran the tests', tool: { name: 'Bash' } }]);`)}
+<p>On any page: <code><iframe src="${esc(base)}/embed/feed/fed_…" style="width:100%;height:320px;border:0"></iframe></code></p>
+
+<h2 id="sdk">The SDK: act as a person or a bot</h2>
+<p>With a person's sign-in or an OpenVibe API token, <code>createChatClient</code> covers global chat, rooms, direct messages, Vibe, inboxes (conversations, replies, saved replies, verified visitors, webhooks) and feeds.</p>
+${code(`npm install openvibe-sdk
+
+import { createClient } from 'openvibe-sdk';
+import { createChatClient } from 'openvibe-sdk/chat';
+const chat = createChatClient(createClient({ token: process.env.OPENVIBE_TOKEN }));
+
+const { conversations } = await chat.inboxes.conversations('inb_…', { status: 'waiting' });
+await chat.inboxes.reply('inb_…', conversations[0].id, 'Hi! I can help with that.');
+await chat.rooms.send('builders', 'Shipping now');`)}
+<p>Live delivery is the chat socket, <code>wss://openvibe.chat/ws/chat</code>: <code>join_room</code>, <code>inbox_watch</code> and <code>feed_watch</code>.</p>
+
+<h2 id="webhooks">Webhooks</h2>
+<p>An inbox's owner adds up to three https addresses. A Slack or Discord webhook gets a line when a visitor waits for a person, writes while waiting, or rates a conversation. Any other address gets every event as JSON, signed like OpenVibe.Events deliveries: <code>chat.inbox.conversation.created</code>, <code>.waiting</code>, <code>.resolved</code>, <code>.rated</code> and <code>chat.inbox.message.created</code>.</p>
+${code(`import { parseDelivery } from 'openvibe-sdk/events';
+
+app.post('/hooks/openvibe', express.raw({ type: 'application/json' }), (req, res) => {
+ const d = parseDelivery(req.body, req.headers, process.env.WEBHOOK_SECRET, { requireV2: true });
+ if (!d) return res.sendStatus(401);
+ if (d.event.type === 'chat.inbox.conversation.waiting') page(d.event.data.conversation);
+ res.sendStatus(204);
+});`)}
+
+<h2 id="limits">Limits</h2>
+<table class="oc-table"><thead><tr><th>What</th><th>Limit</th></tr></thead><tbody>
+<tr><td>Visitor writes (one address)</td><td>20 a minute</td></tr>
+<tr><td>Vibe's answers in an inbox</td><td>30 a conversation, 500 an inbox a day</td></tr>
+<tr><td>Inboxes, people, saved replies, webhooks</td><td>10 a person; 50 people, 100 saved replies and 3 webhooks an inbox</td></tr>
+<tr><td>Feed events</td><td>600 a minute a feed (a batch of 50 is one request); a feed keeps its newest 5,000</td></tr>
+<tr><td>Feeds</td><td>10 a person</td></tr>
+</tbody></table>
+<p class="oc-muted">The full reference is Chat's README on GitHub (<a href="https://github.com/OpenVibers/OpenVibe.Chat#support-inboxes-and-the-website-widget-openvibechatinbox">support inboxes</a>, <a href="https://github.com/OpenVibers/OpenVibe.Chat#feeds-vibe-coding-live-openvibechatfeeds">feeds</a>); the whole platform's building blocks are on <a href="https://openvibe.services">openvibe.services</a>.</p>`;
+}
+
+function mountDeveloperPages(router, { page, config }) {
+ router.get('/developers', async (req, res) => await page(req, res, 200, {
+ title: 'Build with OpenVibe Chat', path: '/developers', robots: 'index, follow',
+ description: 'Add a support inbox or a chat room to any website with one tag, build your own panel on the open API, show Claude Code sessions live with feeds, and hear events through signed webhooks.',
+ body: body(config.web.baseUrl),
+ }));
+}
+
+module.exports = { mountDeveloperPages };
@@ -31,6 +31,7 @@ async function publicPages() {
@@ -31,6 +31,7 @@ async function publicPages() {
{ path: '/rooms', changefreq: 'daily', priority: 0.8, lastmod: () => newestRoomActivity(pages) },
{ path: '/rooms', changefreq: 'daily', priority: 0.8, lastmod: () => newestRoomActivity(pages) },
{ path: '/servers', changefreq: 'daily', priority: 0.8 },
{ path: '/servers', changefreq: 'daily', priority: 0.8 },
{ path: '/updates', changefreq: 'daily', priority: 0.5 },
{ path: '/updates', changefreq: 'daily', priority: 0.5 },
+ { path: '/developers', changefreq: 'weekly', priority: 0.6 },
];
];
for (const r of pages) {
for (const r of pages) {
out.push({ path: `/r/${r.slug}`, changefreq: 'daily', priority: 0.6, lastmod: () => dayOf(r.last_message_at || r.created_at) });
out.push({ path: `/r/${r.slug}`, changefreq: 'daily', priority: 0.6, lastmod: () => dayOf(r.last_message_at || r.created_at) });
@@ -83,7 +84,7 @@ function createDiscoveryRoutes({ config }) {
@@ -83,7 +84,7 @@ function createDiscoveryRoutes({ config }) {
// the sitemap. Every previous Disallow is kept.
// the sitemap. Every previous Disallow is kept.
res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(seo.robotsTxt({
res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(seo.robotsTxt({
sitemaps: [abs('/sitemap.xml')],
sitemaps: [abs('/sitemap.xml')],
- allow: ['/', '/updates', '/rooms', '/r/', '/servers', '/s/'],
+ allow: ['/', '/updates', '/developers', '/rooms', '/r/', '/servers', '/s/'],
disallow: ['/r/*/settings', '/s/*/settings', '/join/', '/messages', '/settings', '/auth/', '/api/'],
disallow: ['/r/*/settings', '/s/*/settings', '/join/', '/messages', '/settings', '/auth/', '/api/'],
}));
}));
});
});
@@ -100,6 +101,9 @@ function createDiscoveryRoutes({ config }) {
@@ -100,6 +101,9 @@ function createDiscoveryRoutes({ config }) {
{ title: 'Servers', url: abs('/servers'), note: 'public servers: groups with text and voice channels, roles and invites' },
{ title: 'Servers', url: abs('/servers'), note: 'public servers: groups with text and voice channels, roles and invites' },
{ title: 'What shipped on OpenVibe.Chat', url: abs('/updates') },
{ title: 'What shipped on OpenVibe.Chat', url: abs('/updates') },
] },
] },
+ { title: 'Build with it', links: [
+ { title: 'Developers', url: abs('/developers'), note: 'a support inbox or a room on any website with one tag, the visitor API, openvibe-sdk/chat, feeds from Claude Code, signed webhooks, the limits' },
+ ] },
{ title: 'Machine-readable', links: [
{ title: 'Machine-readable', links: [
{ title: 'Sitemap', url: abs('/sitemap.xml'), note: 'the public pages above' },
{ title: 'Sitemap', url: abs('/sitemap.xml'), note: 'the public pages above' },
{ title: 'robots.txt', url: abs('/robots.txt') },
{ title: 'robots.txt', url: abs('/robots.txt') },
@@ -900,7 +900,8 @@ ${attachList}`,
@@ -900,7 +900,8 @@ ${attachList}`,
// ── Servers (server/rooms/servers.js): text and voice channels, roles, invites ──
// ── Servers (server/rooms/servers.js): text and voice channels, roles, invites ──
require('./vibe-pages').mountVibePages(router, { page, viewerOf, needUser, sameSite, formLimit, notice });
require('./vibe-pages').mountVibePages(router, { page, viewerOf, needUser, sameSite, formLimit, notice });
require('./inbox-pages').mountInboxPages(router, { page, viewerOf, needUser, sameSite, formLimit, notice, config });
require('./inbox-pages').mountInboxPages(router, { page, viewerOf, needUser, sameSite, formLimit, notice, config });
- require('./room-embed-pages').mountRoomEmbedPages(router, { needUser, config });
+ require('./room-embed-pages').mountRoomEmbedPages(router, { viewerOf, sameSite, config });
+ require('./developer-pages').mountDeveloperPages(router, { page, config });
require('./feed-pages').mountFeedPages(router, { page, needUser, sameSite, formLimit, notice, config });
require('./feed-pages').mountFeedPages(router, { page, needUser, sameSite, formLimit, notice, config });
require('./server-pages').mountServerPages(router, { page, needUser, viewerOf, sameSite, formLimit, notice, roomMessage, callPanel, applyRoomChange, pictureComposer, config });
require('./server-pages').mountServerPages(router, { page, needUser, viewerOf, sameSite, formLimit, notice, roomMessage, callPanel, applyRoomChange, pictureComposer, config });
@@ -7,8 +7,10 @@
@@ -7,8 +7,10 @@
*
*
* GET /embed/room/:slug the room in a frame (public rooms only; frame-ancestors any site): its latest messages,
* GET /embed/room/:slug the room in a frame (public rooms only; frame-ancestors any site): its latest messages,
* live over the chat socket (public/web/room-embed.js); anyone reads, people sign in to write
* live over the chat socket (public/web/room-embed.js); anyone reads, people sign in to write
- * GET /embed/signin?ticket= the sign-in window the frame opens: OpenVibe's sign-in, then the ticket is bound to the
- * person's session for two minutes
+ * GET /embed/signin#t=… the sign-in window the frame opens: it keeps the ticket (in the address's fragment, so
+ * no server or log ever sees it) in its own session storage through OpenVibe's sign-in,
+ * then binds it with POST /embed/bind (the person's session, same site only)
+ * POST /embed/bind { ticket } → the ticket is bound to the signed-in person for two minutes
* POST /embed/ticket { ticket } → { token } once the window bound it (the frame asks every second or two)
* POST /embed/ticket { ticket } → { token } once the window bound it (the frame asks every second or two)
*
*
* Why a ticket: a frame on another site gets no openvibe.chat cookies (browsers block or partition them), and the
* Why a ticket: a frame on another site gets no openvibe.chat cookies (browsers block or partition them), and the
@@ -70,7 +72,7 @@ function embedHtml(room, members, msgs, { bubble }) {
@@ -70,7 +72,7 @@ function embedHtml(room, members, msgs, { bubble }) {
</html>`;
</html>`;
}
}
-function mountRoomEmbedPages(router, { needUser, config }) {
+function mountRoomEmbedPages(router, { viewerOf, sameSite, config }) {
const base = config.web.baseUrl;
const base = config.web.baseUrl;
const ticketLimit = rateLimit({ windowMs: 60_000, max: 90, standardHeaders: true, legacyHeaders: false });
const ticketLimit = rateLimit({ windowMs: 60_000, max: 90, standardHeaders: true, legacyHeaders: false });
@@ -87,23 +89,44 @@ function mountRoomEmbedPages(router, { needUser, config }) {
@@ -87,23 +89,44 @@ function mountRoomEmbedPages(router, { needUser, config }) {
});
});
router.get('/embed/signin', async (req, res) => {
router.get('/embed/signin', async (req, res) => {
- const ticket = String(req.query.ticket || '');
- if (!TICKET_RE.test(ticket)) return res.status(400).type('text').send('This sign-in link is not complete. Close this window and try again.');
- const actor = await needUser(req, res); if (!actor) return;
- sweep();
- if (tickets.size > 10000) return res.status(503).type('text').send('Too many sign-ins at once. Try again in a minute.');
- tickets.set(hash(ticket), { token: actor.token, at: Date.now() });
+ const actor = await viewerOf(req);
const nonce = crypto.randomBytes(16).toString('base64');
const nonce = crypto.randomBytes(16).toString('base64');
- res.set('Content-Security-Policy', `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'unsafe-inline'; frame-ancestors 'none'`)
- .set('Cache-Control', 'no-store').set('X-Robots-Tag', 'noindex').type('html').send(`<!DOCTYPE html>
-<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Signed in · OpenVibe Chat</title></head>
+ const signedIn = actor.kind === 'user';
+ res.set('Content-Security-Policy', `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'`)
+ .set('Cache-Control', 'no-store').set('X-Robots-Tag', 'noindex').set('Referrer-Policy', 'no-referrer').type('html').send(`<!DOCTYPE html>
+<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Sign in · OpenVibe Chat</title></head>
<body style="font:16px/1.5 system-ui,sans-serif;display:grid;place-items:center;min-height:90vh;margin:0;text-align:center;padding:16px">
<body style="font:16px/1.5 system-ui,sans-serif;display:grid;place-items:center;min-height:90vh;margin:0;text-align:center;padding:16px">
-<div><p style="font-size:1.25rem;font-weight:700;margin:0 0 6px">You're signed in as ${esc(actor.user.display_name || actor.user.username)}.</p>
-<p style="margin:0;color:#5b6577">Go back to the chat; this window closes by itself.</p></div>
-<script nonce="${nonce}">setTimeout(function () { window.close(); }, 1200);</script>
+<div><p id="say" style="font-size:1.25rem;font-weight:700;margin:0 0 6px">${signedIn ? `Signing you in as ${esc(actor.user.display_name || actor.user.username)}…` : 'Taking you to OpenVibe sign-in…'}</p>
+<p style="margin:0;color:#5b6577">Go back to the chat afterwards; this window closes by itself.</p></div>
+<script nonce="${nonce}">(function () {
+ var say = document.getElementById('say'), KEY = 'ov-embed-ticket', t = null;
+ var m = /(?:^|&)t=([A-Za-z0-9_-]{43})(?:&|$)/.exec(location.hash.slice(1));
+ try { if (m) sessionStorage.setItem(KEY, m[1]); t = sessionStorage.getItem(KEY); } catch (e) { t = m && m[1]; }
+ if (m) history.replaceState(null, '', location.pathname);
+ if (!t) { say.textContent = 'This sign-in link is not complete. Close this window and try again from the chat.'; return; }
+ if (!${signedIn ? 'true' : 'false'}) { location.replace('/auth/login?next=' + encodeURIComponent('/embed/signin')); return; }
+ fetch('/embed/bind', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ticket: t }) })
+ .then(function (r) {
+ try { sessionStorage.removeItem(KEY); } catch (e) { /* gone */ }
+ if (!r.ok) { say.textContent = 'That did not work. Close this window and try again from the chat.'; return; }
+ say.textContent = 'You\u2019re signed in. Go back to the chat.';
+ setTimeout(function () { window.close(); }, 1200);
+ }, function () { say.textContent = 'You seem to be offline. Try again from the chat.'; });
+})();</script>
</body></html>`);
</body></html>`);
});
});
+ router.post('/embed/bind', ticketLimit, sameSite, async (req, res) => {
+ const ticket = String((req.body && req.body.ticket) || '');
+ if (!TICKET_RE.test(ticket)) return res.status(400).json({ error: 'No such ticket', code: 'embed.ticket' });
+ const actor = await viewerOf(req);
+ if (actor.kind !== 'user') return res.status(401).json({ error: 'Sign in first', code: 'embed.sign_in' });
+ sweep();
+ if (tickets.size > 10000) return res.status(503).json({ error: 'Too many sign-ins at once. Try again in a minute.', code: 'embed.busy' });
+ tickets.set(hash(ticket), { token: actor.token, at: Date.now() });
+ res.set('Cache-Control', 'no-store').json({ ok: true });
+ });
+
router.post('/embed/ticket', ticketLimit, (req, res) => {
router.post('/embed/ticket', ticketLimit, (req, res) => {
const ticket = String((req.body && req.body.ticket) || '');
const ticket = String((req.body && req.body.ticket) || '');
if (!TICKET_RE.test(ticket)) return res.status(400).json({ error: 'No such ticket', code: 'embed.ticket' });
if (!TICKET_RE.test(ticket)) return res.status(400).json({ error: 'No such ticket', code: 'embed.ticket' });
new file mode 100644
new file mode 100644
@@ -0,0 +1,33 @@
@@ -0,0 +1,33 @@
+'use strict';
+/**
+ * openvibe.chat/developers (server/web/developer-pages.js): one public, indexed page for builders: the tags for an
+ * inbox and a room, verified visitors, a panel of your own, feeds from Claude Code, the SDK, webhooks and the limits,
+ * with every code sample escaped; the sitemap and llms.txt name it.
+ */
+const assert = require('assert');
+const { boot, suite } = require('./helpers');
+
+const t = suite('developers page');
+const SITE = 'https://openvibe.chat';
+let h;
+
+t('boot', async () => { h = await boot({ env: { CHAT_WEB_URL: SITE } }); });
+
+t('the page: every way to build on Chat, its samples escaped, indexed', async () => {
+ const r = await fetch(`${h.base}/developers`);
+ assert.strictEqual(r.status, 200);
+ const html = await r.text();
+ assert.ok(html.includes('<h1>Build with OpenVibe Chat</h1>') && /<meta name="robots" content="index, follow"/.test(html));
+ const wants = ['data-inbox="inb_…"', 'data-room="your-room"', 'data-user-hash', 'createInboxVisitor', '/hooks/claude-code', 'createFeedPublisher', 'createChatClient', 'parseDelivery', 'chat.inbox.conversation.waiting'];
+ const missing = wants.filter((w) => !html.includes(w));
+ assert.deepStrictEqual(missing, [], 'the page shows every sample');
+ assert.ok(!/<script src="https:\/\/openvibe\.chat\/widget\.js"/.test(html), 'the tags are shown, not run');
+ assert.ok(html.includes('href="/inbox"') && html.includes('href="/feeds"'));
+});
+
+t('search engines and AI crawlers find it', async () => {
+ assert.ok((await (await fetch(`${h.base}/sitemap.xml`)).text()).includes(`${SITE}/developers`));
+ assert.ok((await (await fetch(`${h.base}/llms.txt`)).text()).includes(`${SITE}/developers`));
+});
+
+t.run(async () => { if (h && h.close) await h.close(); });
@@ -42,19 +42,21 @@ t('only a public room embeds, on any site, with its latest messages', async () =
@@ -42,19 +42,21 @@ t('only a public room embeds, on any site, with its latest messages', async () =
assert.strictEqual((await page('/embed/room/no-such-room')).status, 404);
assert.strictEqual((await page('/embed/room/no-such-room')).status, 404);
});
});
-t('signing in from the frame: a window binds the ticket, the frame collects the token once', async () => {
+t('signing in from the frame: the window keeps the ticket out of every address, binds it, the frame collects the token once', async () => {
const tk = ticket();
const tk = ticket();
assert.deepStrictEqual(await collect(tk), { status: 202, body: { pending: true } });
assert.deepStrictEqual(await collect(tk), { status: 202, body: { pending: true } });
- const out = await page(`/embed/signin?ticket=${tk}`);
- assert.strictEqual(out.status, 303);
- assert.ok(out.headers.get('location').startsWith('/auth/login?next=%2Fembed%2Fsignin%3Fticket%3D'), 'sign in first, then back');
- const ok = await page(`/embed/signin?ticket=${tk}`, ann);
- assert.ok(ok.status === 200 && ok.text.includes('signed in as Ann') && /frame-ancestors 'none'/.test(ok.headers.get('content-security-policy')));
- assert.strictEqual(ok.headers.get('cache-control'), 'no-store');
+ const anon = await page('/embed/signin');
+ assert.ok(anon.status === 200 && anon.text.includes("/auth/login?next=") && anon.text.includes('location.hash'), 'the window sends a stranger to sign in, keeping the ticket itself');
+ assert.ok(/frame-ancestors 'none'/.test(anon.headers.get('content-security-policy')) && anon.headers.get('cache-control') === 'no-store');
+ assert.ok((await page('/embed/signin', ann)).text.includes('Signing you in as Ann'));
+ const bind = (u, body, origin = SITE) => page('/embed/bind', u, { method: 'POST', headers: { 'content-type': 'application/json', origin }, body: JSON.stringify(body) });
+ assert.strictEqual((await bind(null, { ticket: tk })).status, 401, 'only a signed-in person binds');
+ assert.strictEqual((await bind(ann, { ticket: tk }, 'https://elsewhere.example')).status, 403, 'and only from openvibe.chat');
+ assert.strictEqual((await bind(ann, { ticket: 'short' })).status, 400);
+ assert.strictEqual((await bind(ann, { ticket: tk })).status, 200);
assert.deepStrictEqual(await collect(tk), { status: 200, body: { token: ann.token } });
assert.deepStrictEqual(await collect(tk), { status: 200, body: { token: ann.token } });
assert.deepStrictEqual(await collect(tk), { status: 202, body: { pending: true } }, 'once');
assert.deepStrictEqual(await collect(tk), { status: 202, body: { pending: true } }, 'once');
assert.strictEqual((await collect('short')).status, 400);
assert.strictEqual((await collect('short')).status, 400);
- assert.strictEqual((await page('/embed/signin?ticket=bad', ann)).status, 400);
});
});
t('with the token the socket joins as them: a newcomer joins, posts, and everyone reading sees it', async () => {
t('with the token the socket joins as them: a newcomer joins, posts, and everyone reading sees it', async () => {
@@ -63,7 +65,7 @@ t('with the token the socket joins as them: a newcomer joins, posts, and everyon
@@ -63,7 +65,7 @@ t('with the token the socket joins as them: a newcomer joins, posts, and everyon
const r = await reader.next((m) => m.type === 'room_joined');
const r = await reader.next((m) => m.type === 'room_joined');
assert.strictEqual(r.can.post, false, 'anyone reads, nobody anonymous writes');
assert.strictEqual(r.can.post, false, 'anyone reads, nobody anonymous writes');
const tk = ticket();
const tk = ticket();
- await page(`/embed/signin?ticket=${tk}`, bob);
+ await page('/embed/bind', bob, { method: 'POST', headers: { 'content-type': 'application/json', origin: SITE }, body: JSON.stringify({ ticket: tk }) });
const token = (await collect(tk)).body.token;
const token = (await collect(tk)).body.token;
const writer = await h.ws({});
const writer = await h.ws({});
writer.sendJson({ type: 'join_room', room: 'pixel-fans', token });
writer.sendJson({ type: 'join_room', room: 'pixel-fans', token });
@@ -121,6 +121,7 @@ t('ratchet: every file that makes an outbound request itself is reviewed', () =>
@@ -121,6 +121,7 @@ t('ratchet: every file that makes an outbound request itself is reviewed', () =>
'server/chat/soundboard-service.js': '101soundboards (host allowlist; audio via safeLookup)',
'server/chat/soundboard-service.js': '101soundboards (host allowlist; audio via safeLookup)',
'server/chat/tts-engine.js': 'Google / AWS TTS (fixed hosts)',
'server/chat/tts-engine.js': 'Google / AWS TTS (fixed hosts)',
'server/inbox/webhooks.js': "an inbox owner's webhook addresses: https only, assertPublicUrl when saved and safeLookup on every delivery, no redirects followed",
'server/inbox/webhooks.js': "an inbox owner's webhook addresses: https only, assertPublicUrl when saved and safeLookup on every delivery, no redirects followed",
+ 'server/web/room-embed-pages.js': 'not the server: the sign-in window\'s own script posts to /embed/bind on this same site',
};
};
const root = path.join(__dirname, '..');
const root = path.join(__dirname, '..');
const found = [];
const found = [];