openvibers/openvibe.chat Commits

openvibe.chat/developers, and the room embed's sign-in ticket kept out of every URL (#151)

OP OpenVibers committed 20 days ago · 42fcccae70 · 9 files, +202 −28

* openvibe.chat/developers: one page for building on Chat; the room embed's sign-in ticket never appears in a URL (fragment, then a same-site bind)

* The outbound-request ratchet knows the sign-in window's same-site script

Changes

ddiff --git a/public/web/chat.css b/public/web/chat.css
iindex a8375a4..6ecd06a 100644
---- a/public/web/chat.css
++++ b/public/web/chat.css
@@@ -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; }
ddiff --git a/public/web/room-embed.js b/public/web/room-embed.js
iindex 5b53856..e43f55c 100644
---- a/public/web/room-embed.js
++++ b/public/web/room-embed.js
@@@ -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
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
it) 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();
ddiff --git a/server/web/developer-pages.js b/server/web/developer-pages.js
nnew file mode 100644
iindex 0000000..1b649b2
---- /dev/null
++++ b/server/web/developer-pages.js
@@@ -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>&lt;/body&gt;</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>&lt;iframe src="${esc(base)}/embed/feed/fed_…" style="width:100%;height:320px;border:0"&gt;&lt;/iframe&gt;</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 };
ddiff --git a/server/web/discovery.js b/server/web/discovery.js
iindex 70aac80..39428af 100644
---- a/server/web/discovery.js
++++ b/server/web/discovery.js
@@@ -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') },
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex 70550fb..0f583c1 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -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 });
ddiff --git a/server/web/room-embed-pages.js b/server/web/room-embed-pages.js
iindex e1b9f55..dc29beb 100644
---- a/server/web/room-embed-pages.js
++++ b/server/web/room-embed-pages.js
@@@ -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' });
ddiff --git a/test/developers.test.js b/test/developers.test.js
nnew file mode 100644
iindex 0000000..e084ae5
---- /dev/null
++++ b/test/developers.test.js
@@@ -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=&quot;inb_…&quot;', 'data-room=&quot;your-room&quot;', '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(); });
ddiff --git a/test/room-embed.test.js b/test/room-embed.test.js
iindex f831279..592179f 100644
---- a/test/room-embed.test.js
++++ b/test/room-embed.test.js
@@@ -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 everyon
const 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 });
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex f34f60d..6e30e7c 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -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 = [];