openvibers/openvibe.chat Commits

Feeds: vibe coding moves from Live into Chat and gets better: any room, any site, Live slots; Claude Code's own http hooks; secrets scrubbed on the way in; the delay kept on the server (#144)

OP OpenVibers committed 20 days ago · 6827b8fc71 · 18 files, +1320 −12

Changes

ddiff --git a/README.md b/README.md
iindex 771bb48..a29ff09 100644
---- a/README.md
++++ b/README.md
@@@ -462,6 +462,43 @@ for people and for companies, with "direct live support with an agent", "fully m
- Export and account deletion cover inboxes: the owner's inboxes go with their conversations; an agent's replies in
other inboxes stay, signed "A former agent".
++### Feeds: vibe coding, live (openvibe.chat/feeds)
++
++The owner, 2026-10-24: "improve the vibe coding feed feature on live and migrate that whole entire feature so it's built
++into the openvibe.chat system". A **feed** (`server/feeds/`, `migrations/0017_feeds.sql`) is live activity a person
++publishes into Chat; vibe coding, moved from OpenVibe.Live (its `server/vibe-coding/`, same canonical events), is the
++first kind. Viewers see a card (`public/web/feed-view.js`): turns of a prompt, the steps taken (tools used, files
++changed) and the answer, the newest open, a live dot while steps arrive.
++
++- **Where it shows:** in a room its owner owns (above the messages), on the owner's Live channel slot while it is live
++ (as Live's `vibe-coding` frame on the stream's chat, which Live's channel widget reads), and on any site:
++ `<iframe src="https://openvibe.chat/embed/feed/fed_…">` (`?theme=light|dark`; it tells the parent its height,
++ `{ ov: 'chat', type: 'feed-height' }`).
++- **Publishing** with the feed's key (`ovf_…`, shown once, stored as its SHA-256; a new one on request):
++ - **Claude Code**: the feed page gives a `.claude/settings.json` block of `http` hooks (SessionStart,
++ UserPromptSubmit, PostToolUse, Stop, SessionEnd) that post each hook's JSON as it is to
++ `POST /api/chat/feeds/:id/hooks/claude-code` with `Authorization: Bearer $OPENVIBE_FEED_KEY`. A prompt, a tool
++ used (an edit is a file event), the turn's answer (`last_assistant_message`); paths become relative to the
++ session's folder; nothing is read from the transcript; the answer is always `{}`, so it never blocks Claude Code.
++ - **Anything else**: `POST /api/chat/feeds/:id/events` with one event, `{ events: [...] }` (at most 50) or Live's
++ `vibe-coding.event` envelopes; `POST /:id/hello { sessionKey, publisher }`. `eventType` is `session.status`,
++ `prompt`, `response`, `thinking`, `tool.call`, `file.change` or `file.save`; a short `{ eventType, text }` works.
++ 600 events a minute a feed.
++- **Safety:** every event is scrubbed on the way in: API keys and tokens (OpenAI/Anthropic, GitHub, Slack, AWS,
++ Google, JWTs, OpenVibe's own), `Bearer …`, `PASSWORD=`-style values, credentials in URLs, private keys, and home
++ folders (`/home/<name>` → `~`). What a viewer sees is decided when they see it by the feed's settings (Live's
++ projection): prompts, answers, tools, what the tools were given (off by default), files (names only by default),
++ thinking (off by default), detail, paused. The **delay** is the server's: neither the socket nor the history shows
++ a step before it (Live sent the delay to the browser).
++- **API:** anyone: `GET /api/chat/feeds/:id` (what a viewer sees now), `GET /api/chat/feeds?channel=<username>` or
++ `?slot=<id,…>` (the feeds on Live channel slots, what Live's chat asks); the owner: `GET /api/chat/feeds/mine`,
++ `POST /api/chat/feeds { title, room?, slot?, settings? }` → `{ feed, key }`, `PATCH /:id`, `POST /:id/key`,
++ `DELETE /:id`. The public and publishing routes answer any site without cookies. Socket: `{ type: 'feed_watch',
++ feed }`, then `feed_event { feed, event }`. A feed keeps its newest 5,000 events; at most 10 feeds a person.
++- **From Live:** `scripts/import-live-feeds.js --file <export.jsonl> [--apply]` copies each slot's feed (settings,
++ sessions, its newest 5,000 events, scrubbed) and verifies the copy; Live then drops `vibe_coding_*`.
++- Export and account deletion cover feeds (never the key's hash).
++
### VIP member badges
A member's messages in a creator's room (stream or offline channel chat) carry that creator's VIP
ddiff --git a/docs/platform.md b/docs/platform.md
iindex 99c99dd..c335ec5 100644
---- a/docs/platform.md
++++ b/docs/platform.md
@@@ -49,10 +49,12 @@ center (MagicBrowse privacy), getstream.io (Stream vs Sendbird), rumbletalk.com,
## Order of work
--1. **Support inbox + widget (first)**: inboxes, agents, visitor conversations, Vibe as first responder with handoff,
++1. **Support inbox + widget (first; Chat#143)**: inboxes, agents, visitor conversations, Vibe as first responder with handoff,
the agent inbox page, `widget.js` + `/embed/inbox/:id`, allowed sites (frame-ancestors), the API.
--2. **Feeds**: Live's vibe coding feed moves into Chat as the first feed type (publisher socket, settings, viewer cards in
-- any room and in Live's channel chat); Live's tables are copied, verified and dropped (convert-verify-delete).
++2. **Feeds** (built): Live's vibe coding feed moves into Chat as the first feed type, and gets better on the way: any
++ room, any site (an iframe card), Claude Code's own `http` hooks with no extension to install, publishing over plain
++ HTTP, secrets and home folders scrubbed on the way in, the delay kept on the server. Live's channel chat shows
++ Chat's card; Live's tables are copied (`scripts/import-live-feeds.js`), verified and dropped (convert-verify-delete).
3. **Community room and site assistant modes** of the widget; pictures, video and files in the embed (signed-in
visitors first, then anonymous once Pics/Video take anonymous uploads); call escalation from a support conversation.
4. **Developer platform**: capabilities in OpenVibe.Contracts (`chat.inbox.*`, `chat.conversation.*`, `chat.feed.*`),
ddiff --git a/migrations/0017_feeds.sql b/migrations/0017_feeds.sql
nnew file mode 100644
iindex 0000000..4679d84
---- /dev/null
++++ b/migrations/0017_feeds.sql
@@@ -0,0 +1,56 @@
++-- phase: expand
++-- Feeds (docs/platform.md step 2, the owner 2026-10-24: "migrate that whole entire feature so it's built into the
++-- openvibe.chat system"): live activity a person publishes into Chat with a feed's key; vibe coding, moved from
++-- OpenVibe.Live, is the first kind. Viewers see a card in the feed's room, on its Live channel slot while live, or on any
++-- site (openvibe.chat/embed/feed/:id). What they see is projected from the stored events by the feed's settings when
++-- they see it (server/feeds/feeds.js).
++CREATE TABLE IF NOT EXISTS feeds (
++ id text COLLATE "C" PRIMARY KEY, -- fed_…
++ owner_id bigint NOT NULL, -- Live user id, like rooms.owner_id
++ owner_subject text COLLATE "C",
++ kind text COLLATE "C" NOT NULL DEFAULT 'coding' CHECK (kind IN ('coding')),
++ title text NOT NULL,
++ room_id bigint, -- a Chat room it shows in (rooms.id)
++ managed_stream_id bigint, -- a Live channel slot it shows on while live
++ settings jsonb NOT NULL DEFAULT '{}',
++ key_hash text COLLATE "C", -- sha256 of the publish key (ovf_…), shown once
++ key_hint text COLLATE "C", -- its last four characters
++ legacy_source text COLLATE "C", -- 'live:managed_stream:<id>' when copied from Live
++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
++ last_event_at text COLLATE "C",
++ archived_at text COLLATE "C"
++);
++CREATE INDEX IF NOT EXISTS feeds_owner ON feeds (owner_id) WHERE archived_at IS NULL;
++CREATE UNIQUE INDEX IF NOT EXISTS feeds_key ON feeds (key_hash) WHERE key_hash IS NOT NULL;
++CREATE INDEX IF NOT EXISTS feeds_room ON feeds (room_id) WHERE room_id IS NOT NULL AND archived_at IS NULL;
++CREATE INDEX IF NOT EXISTS feeds_slot ON feeds (managed_stream_id) WHERE managed_stream_id IS NOT NULL AND archived_at IS NULL;
++CREATE UNIQUE INDEX IF NOT EXISTS feeds_legacy ON feeds (legacy_source) WHERE legacy_source IS NOT NULL;
++
++-- One publisher's run (an editor window, an agent session): who published (integration, client, workspace) and when.
++CREATE TABLE IF NOT EXISTS feed_sessions (
++ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
++ feed_id text COLLATE "C" NOT NULL,
++ session_key text COLLATE "C" NOT NULL,
++ publisher jsonb NOT NULL DEFAULT '{}',
++ status text COLLATE "C" NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'ended')),
++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
++ last_event_at text COLLATE "C",
++ ended_at text COLLATE "C",
++ UNIQUE (feed_id, session_key)
++);
++
++-- The events as published (scrubbed of secrets and home folders on the way in). received_ms orders the feed's delay.
++CREATE TABLE IF NOT EXISTS feed_events (
++ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
++ feed_id text COLLATE "C" NOT NULL,
++ session_key text COLLATE "C",
++ event_id text COLLATE "C" NOT NULL,
++ seq bigint NOT NULL DEFAULT 0,
++ type text COLLATE "C" NOT NULL,
++ summary text NOT NULL DEFAULT '',
++ payload jsonb NOT NULL,
++ received_ms bigint NOT NULL,
++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
++ UNIQUE (feed_id, event_id)
++);
++CREATE INDEX IF NOT EXISTS feed_events_feed ON feed_events (feed_id, id DESC);
ddiff --git a/public/web/chat.css b/public/web/chat.css
iindex 2ff2cb0..625ee29 100644
---- a/public/web/chat.css
++++ b/public/web/chat.css
@@@ -302,3 +302,10 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }
.ib-dot { display: inline-block; width: 12px; height: 12px; border-radius: 50%; flex: none; }
.ib-snippet { padding: 12px 14px; border-radius: 12px; background: var(--oc-panel); border: 1px solid var(--oc-line); overflow-x: auto; }
.ib-snippet code { font: .85rem/1.5 ui-monospace, Menlo, Consolas, monospace; white-space: pre; }
++
++/* Feeds (server/web/feed-pages.js; the card itself is public/web/feed-view.css) */
++.oc-room-fv { margin: 0 0 12px; }
++.fd-preview { margin: 12px 0 18px; }
++.fd-dot { background: var(--oc-muted); }
++.fd-dot.fd-on { background: var(--oc-ok); }
++.fd-key code { font-size: 1rem; user-select: all; }
ddiff --git a/public/web/feed-view.css b/public/web/feed-view.css
nnew file mode 100644
iindex 0000000..58787b7
---- /dev/null
++++ b/public/web/feed-view.css
@@@ -0,0 +1,34 @@
++/* A feed's card (public/web/feed-view.js): in a room on openvibe.chat (Chat's --oc-* colours) and in the embed
++ /embed/feed/:id, which sets the same names for light and dark (server/web/feed-pages.js). */
++.fv { --fv-line: var(--oc-line, #223049); --fv-panel: var(--oc-panel, #121a2c); --fv-text: var(--oc-text, #e8edf6); --fv-muted: var(--oc-muted, #98a6bd); --fv-accent: var(--oc-accent, #3b82f6); --fv-ok: var(--oc-ok, #22c55e); }
++.fv.fv-on { border: 1px solid var(--fv-line); border-radius: 14px; background: var(--fv-panel); color: var(--fv-text); overflow: hidden; font-size: .9rem; }
++.fv.fv-gone { display: none; }
++.fv-head { all: unset; box-sizing: border-box; cursor: pointer; display: flex; align-items: center; gap: 8px; width: 100%; padding: 9px 12px; }
++.fv-head:focus-visible { outline: 2px solid var(--fv-accent); outline-offset: -2px; }
++.fv-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--fv-muted); flex: none; }
++.fv-live .fv-dot { background: var(--fv-ok); box-shadow: 0 0 0 0 color-mix(in srgb, var(--fv-ok) 60%, transparent); animation: fvPulse 2s infinite; }
++@keyframes fvPulse { 70% { box-shadow: 0 0 0 7px transparent; } }
++.fv-title { font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
++.fv-who { color: var(--fv-muted); font-size: .8rem; white-space: nowrap; }
++.fv-who:not(:empty)::before { content: '· '; }
++.fv-when { margin-left: auto; color: var(--fv-muted); font-size: .78rem; }
++.fv-chev { color: var(--fv-muted); transition: transform .15s; }
++.fv-folded .fv-chev { transform: rotate(-90deg); }
++.fv-folded .fv-body { display: none; }
++.fv-body { border-top: 1px solid var(--fv-line); max-height: 260px; overflow-y: auto; padding: 8px 12px 10px; overscroll-behavior: contain; }
++.fv-turns, .fv-steps { list-style: none; margin: 0; padding: 0; }
++.fv-turn { padding: 4px 0; }
++.fv-turn + .fv-turn { border-top: 1px dashed color-mix(in srgb, var(--fv-line) 70%, transparent); }
++.fv-turn:not(.fv-cur) { opacity: .72; }
++.fv-prompt, .fv-answer, .fv-step, .fv-count, .fv-more, .fv-working { display: flex; gap: 7px; align-items: baseline; margin: 3px 0; min-width: 0; }
++.fv-prompt { font-weight: 600; }
++.fv-ic { flex: none; width: 1.1em; text-align: center; color: var(--fv-muted); }
++.fv-prompt .fv-ic { color: var(--fv-accent); }
++.fv-answer .fv-ic { color: var(--fv-ok); }
++.fv-txt { overflow-wrap: anywhere; }
++.fv-step { color: var(--fv-muted); font-size: .84rem; flex-wrap: wrap; }
++.fv-arg { font: .78rem ui-monospace, Menlo, Consolas, monospace; background: color-mix(in srgb, var(--fv-muted) 14%, transparent); padding: 1px 6px; border-radius: 6px; max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-left: 1.8em; flex-basis: 100%; }
++.fv-count, .fv-more { color: var(--fv-muted); font-size: .78rem; padding-left: 1.8em; }
++.fv-working { color: var(--fv-muted); font-size: .82rem; padding-left: 1.8em; }
++.fv-empty { margin: 4px 0; color: var(--fv-muted); font-size: .85rem; }
++@media (prefers-reduced-motion: reduce) { .fv-live .fv-dot { animation: none; } .fv-chev { transition: none; } }
ddiff --git a/public/web/feed-view.js b/public/web/feed-view.js
nnew file mode 100644
iindex 0000000..d726db9
---- /dev/null
++++ b/public/web/feed-view.js
@@@ -0,0 +1,124 @@
++/* A feed's card (server/feeds/feeds.js): what someone is building, live. Mounted on every <div class="fv"
++ data-feed="fed_…"> of the page: a room on openvibe.chat, the embed /embed/feed/:id that any site (OpenVibe.Live's
++ channel chat among them) frames. It reads /api/chat/feeds/:id and follows the chat socket (feed_watch). Events are
++ grouped into turns: a prompt, the steps taken (tools used, files changed), the answer; the newest turn is open, older
++ ones fold to their prompt. Everything is set with textContent. In a frame it tells the parent its height. */
++(function () {
++ 'use strict';
++ var ICON = { prompt: '›', response: '✓', thinking: '…', 'tool.call': '⚙', 'file.change': '✎', 'file.save': '✎', 'session.status': '•' };
++ var LIVE_MS = 3 * 60 * 1000;
++ function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
++ function store(fn) { try { return fn(window.localStorage); } catch (e) { return null; } }
++ function ago(iso) {
++ var t = Date.parse(iso || ''); if (!t) return '';
++ var s = Math.max(0, Math.round((Date.now() - t) / 1000));
++ return s < 60 ? 'now' : s < 3600 ? Math.round(s / 60) + 'm' : s < 86400 ? Math.round(s / 3600) + 'h' : Math.round(s / 86400) + 'd';
++ }
++
++ function turnsOf(events) {
++ var turns = [], cur = null;
++ events.forEach(function (e) {
++ if (e.eventType === 'prompt' || !cur) { cur = { prompt: e.eventType === 'prompt' ? e : null, steps: [], answer: null, at: e.occurredAt }; turns.push(cur); if (e.eventType === 'prompt') return; }
++ if (e.eventType === 'response') { cur.answer = e; cur.at = e.occurredAt; return; }
++ cur.steps.push(e); cur.at = e.occurredAt;
++ });
++ return turns;
++ }
++
++ function mount(box) {
++ var id = box.getAttribute('data-feed');
++ if (!/^fed_[0-9A-HJKMNP-TV-Z]{26}$/.test(id || '') || box.dataset.mounted) return;
++ box.dataset.mounted = '1';
++ var events = [], seen = {}, feed = null, publisher = null, ws = null, delay = 1000;
++ var KEY = 'ov-feed-folded-' + id;
++ var folded = store(function (s) { return s.getItem(KEY); }) === '1';
++
++ var head = el('button', 'fv-head'); head.type = 'button';
++ var dot = el('span', 'fv-dot'); var title = el('b', 'fv-title', 'Vibe coding'); var who = el('span', 'fv-who'); var when = el('time', 'fv-when');
++ var chev = el('span', 'fv-chev', '▾'); chev.setAttribute('aria-hidden', 'true');
++ head.appendChild(dot); head.appendChild(title); head.appendChild(who); head.appendChild(when); head.appendChild(chev);
++ var body = el('div', 'fv-body'); var list = el('ol', 'fv-turns'); body.appendChild(list);
++ var empty = el('p', 'fv-empty', 'Nothing yet. Steps show here as they happen.');
++ body.appendChild(empty);
++ box.textContent = ''; box.appendChild(head); box.appendChild(body);
++ box.classList.add('fv-on');
++
++ function setFolded(v) {
++ folded = v; box.classList.toggle('fv-folded', v); head.setAttribute('aria-expanded', v ? 'false' : 'true');
++ store(function (s) { s.setItem(KEY, v ? '1' : '0'); }); tellHeight();
++ }
++ head.addEventListener('click', function () { setFolded(!folded); });
++
++ function tellHeight() {
++ if (window.parent === window) return;
++ try { window.parent.postMessage({ ov: 'chat', type: 'feed-height', feed: id, height: Math.ceil(box.getBoundingClientRect().height) }, '*'); } catch (e) { /* not framed */ }
++ }
++ function stepLine(e) {
++ var li = el('li', 'fv-step fv-' + e.eventType.replace('.', '-'));
++ li.appendChild(el('span', 'fv-ic', ICON[e.eventType] || '•'));
++ var t = el('span', 'fv-txt', e.summary || (e.tool && e.tool.name) || '');
++ li.appendChild(t);
++ var arg = e.tool && e.tool.argumentsPreview;
++ if (arg) li.appendChild(el('code', 'fv-arg', arg));
++ return li;
++ }
++ function render() {
++ var live = feed && feed.last_event_at && Date.now() - Date.parse(feed.last_event_at) < LIVE_MS;
++ box.classList.toggle('fv-live', !!live);
++ dot.title = live ? 'Live' : 'Quiet';
++ title.textContent = (feed && feed.title) || 'Vibe coding';
++ who.textContent = publisher && publisher.integrationLabel && publisher.integrationLabel !== 'Custom publisher' ? publisher.integrationLabel : '';
++ when.textContent = feed ? ago(feed.last_event_at) : '';
++ list.textContent = '';
++ var turns = turnsOf(events);
++ empty.hidden = turns.length > 0;
++ turns.forEach(function (turn, i) {
++ var last = i === turns.length - 1;
++ var li = el('li', 'fv-turn' + (last ? ' fv-cur' : ''));
++ if (turn.prompt) { var p = el('p', 'fv-prompt'); p.appendChild(el('span', 'fv-ic', ICON.prompt)); p.appendChild(el('span', 'fv-txt', turn.prompt.summary)); li.appendChild(p); }
++ if (last || !turn.prompt) {
++ var steps = el('ol', 'fv-steps');
++ var shown = turn.steps.slice(-6);
++ if (turn.steps.length > shown.length) steps.appendChild(el('li', 'fv-more', (turn.steps.length - shown.length) + ' earlier steps'));
++ shown.forEach(function (e) { steps.appendChild(stepLine(e)); });
++ if (shown.length) li.appendChild(steps);
++ } else if (turn.steps.length) {
++ li.appendChild(el('p', 'fv-count', turn.steps.length + (turn.steps.length === 1 ? ' step' : ' steps')));
++ }
++ if (turn.answer && (last || !turn.prompt)) { var a = el('p', 'fv-answer'); a.appendChild(el('span', 'fv-ic', ICON.response)); a.appendChild(el('span', 'fv-txt', turn.answer.summary)); li.appendChild(a); }
++ else if (last && !turn.answer && live && turn.prompt) li.appendChild(el('p', 'fv-working', 'Working…'));
++ list.appendChild(li);
++ });
++ body.scrollTop = body.scrollHeight;
++ tellHeight();
++ }
++ function add(e) {
++ if (!e || !e.eventId || seen[e.eventId]) return;
++ seen[e.eventId] = 1; events.push(e);
++ if (events.length > 120) { events.splice(0, events.length - 120); }
++ if (feed) feed.last_event_at = new Date().toISOString();
++ render();
++ }
++ function connect() {
++ if (!window.WebSocket) return;
++ ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws/chat');
++ ws.onopen = function () { delay = 1000; ws.send(JSON.stringify({ type: 'feed_watch', feed: id })); };
++ ws.onmessage = function (ev) { var m; try { m = JSON.parse(ev.data); } catch (e) { return; } if (m.type === 'feed_event' && m.feed === id) { if (m.publisher) publisher = m.publisher; add(m.event); } };
++ ws.onclose = function () { setTimeout(function () { load(); connect(); }, delay); delay = Math.min(delay * 2, 30000); };
++ }
++ function load() {
++ return fetch('/api/chat/feeds/' + id + '?limit=40', { credentials: 'omit' }).then(function (r) { return r.ok ? r.json() : null; }).then(function (d) {
++ if (!d) { box.classList.add('fv-gone'); return; }
++ feed = d.feed; publisher = d.publisher;
++ (d.events || []).forEach(function (e) { if (!seen[e.eventId]) { seen[e.eventId] = 1; events.push(e); } });
++ render();
++ }).catch(function () {});
++ }
++ setFolded(folded);
++ load().then(connect);
++ setInterval(function () { if (feed) { when.textContent = ago(feed.last_event_at); box.classList.toggle('fv-live', Date.now() - Date.parse(feed.last_event_at || 0) < LIVE_MS); } }, 30000);
++ }
++ function all() { Array.prototype.forEach.call(document.querySelectorAll('.fv[data-feed]'), mount); }
++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', all); else all();
++ window.OpenVibeFeed = { mount: mount };
++})();
ddiff --git a/scripts/import-live-feeds.js b/scripts/import-live-feeds.js
nnew file mode 100644
iindex 0000000..3f8fe82
---- /dev/null
++++ b/scripts/import-live-feeds.js
@@@ -0,0 +1,121 @@
++#!/usr/bin/env node
++/**
++ * OpenVibe.Live's vibe coding feed → Chat's feeds (server/feeds/feeds.js, docs/platform.md step 2). One-off: copy,
++ * verify, then Live drops its tables (convert-verify-delete).
++ *
++ * node scripts/import-live-feeds.js --file <export.jsonl> # dry run: what would be copied
++ * node scripts/import-live-feeds.js --file <export.jsonl> --apply # copy, then verify
++ *
++ * The file is JSON lines exported from Live's database, one object per row, `kind` saying which:
++ * { kind: 'slot', managed_stream_id, user_id, title, settings } managed_streams.broadcast_settings.vibe_coding
++ * { kind: 'session', managed_stream_id, session_key, publisher, status, created_at, last_event_at, ended_at }
++ * { kind: 'event', managed_stream_id, session_key, event_id, seq, type, summary, payload, created_at }
++ *
++ * Each slot becomes one feed of its owner (legacy_source 'live:managed_stream:<id>', so a second run adds nothing),
++ * shown on that slot, with Live's settings, and no key: the owner makes one on the feed's page. A feed keeps its
++ * newest 5,000 events (feeds.KEEP_EVENTS), so only those are copied; every copied event is scrubbed as new ones are.
++ * Verified: each feed holds exactly the newest min(5,000, its events) event ids of the file.
++ *
++ * Exit codes: 0 done (or dry run), 2 refused (bad options or file), 1 failed or did not verify.
++ */
++'use strict';
++
++const fs = require('fs');
++
++const USAGE = 'usage: node scripts/import-live-feeds.js --file <export.jsonl> [--apply]';
++
++function parseArgs(argv) {
++ const out = { apply: false, file: null, help: false };
++ for (let i = 0; i < argv.length; i++) {
++ const a = argv[i];
++ if (a === '--apply') out.apply = true;
++ else if (a === '--dry-run') out.apply = false;
++ else if (a === '--file') { out.file = argv[++i]; if (!out.file || out.file.startsWith('--')) throw new Error('--file needs a value'); }
++ else if (a === '-h' || a === '--help') out.help = true;
++ else throw new Error(`unknown argument ${a}`);
++ }
++ if (!out.help && !out.file) throw new Error('--file is required');
++ return out;
++}
++
++const json = (v, d) => { if (v && typeof v === 'object') return v; try { return JSON.parse(v || 'null') || d; } catch { return d; } };
++const ms = (t) => { const n = Date.parse(String(t || '').includes('T') ? t : `${String(t || '').replace(' ', 'T')}Z`); return Number.isFinite(n) ? n : 0; };
++
++/** The file read into slots, each with its sessions and newest events (oldest first). */
++function readExport(text, keep) {
++ const slots = new Map();
++ const slot = (id) => { if (!slots.has(id)) slots.set(id, { id, user_id: null, title: null, settings: {}, sessions: [], events: [] }); return slots.get(id); };
++ let lines = 0;
++ for (const line of text.split('\n')) {
++ if (!line.trim()) continue;
++ lines++;
++ const r = JSON.parse(line);
++ const id = Number(r.managed_stream_id);
++ if (!Number.isSafeInteger(id) || id <= 0) throw new Error(`line ${lines}: no managed_stream_id`);
++ if (r.kind === 'slot') Object.assign(slot(id), { user_id: Number(r.user_id), title: r.title || null, settings: json(r.settings, {}) });
++ else if (r.kind === 'session') slot(id).sessions.push(r);
++ else if (r.kind === 'event') slot(id).events.push(r);
++ else throw new Error(`line ${lines}: unknown kind ${r.kind}`);
++ }
++ for (const s of slots.values()) {
++ s.total = s.events.length;
++ s.events = s.events.sort((a, b) => ms(a.created_at) - ms(b.created_at) || Number(a.seq) - Number(b.seq)).slice(-keep);
++ }
++ return { slots: [...slots.values()], lines };
++}
++
++async function main(argv, log = (m) => console.log(m)) {
++ let args;
++ try { args = parseArgs(argv); } catch (e) { log(`${e.message}\n${USAGE}`); return 2; }
++ if (args.help) { log(USAGE); return 0; }
++ const feeds = require('../server/feeds/feeds');
++ let data;
++ try { data = readExport(fs.readFileSync(args.file, 'utf8'), feeds.KEEP_EVENTS); } catch (e) { log(`cannot read ${args.file}: ${e.message}`); return 2; }
++ const orphans = data.slots.filter((s) => !s.user_id);
++ if (orphans.length) { log(`slots without a slot row (owner unknown): ${orphans.map((s) => s.id).join(', ')}`); return 2; }
++ for (const s of data.slots) log(`slot ${s.id} (owner ${s.user_id}): ${s.sessions.length} session(s), ${s.total} event(s), copying the newest ${s.events.length}`);
++ if (!args.apply) { log('dry run: nothing written (add --apply to copy)'); return 0; }
++
++ const db = require('../server/db/database');
++ await db.initDb();
++ let bad = 0;
++ for (const s of data.slots) {
++ const source = `live:managed_stream:${s.id}`;
++ const subject = await db.subjectFor(s.user_id).catch(() => null);
++ const settings = feeds.normalizeSettings(s.settings);
++ const title = String(s.settings.widget_title || s.title || 'Vibe coding').slice(0, 80);
++ const id = `fed_${require('crypto').randomBytes(26).reduce((a, b) => a + '0123456789ABCDEFGHJKMNPQRSTVWXYZ'[b % 32], '')}`;
++ await db.run(`INSERT INTO feeds (id, owner_id, owner_subject, title, managed_stream_id, settings, legacy_source) VALUES (?, ?, ?, ?, ?, ?, ?)
++ ON CONFLICT (legacy_source) WHERE legacy_source IS NOT NULL DO NOTHING`, [id, s.user_id, subject, title.length >= 2 ? title : 'Vibe coding', s.id, JSON.stringify(settings), source]);
++ const feed = await db.get('SELECT id FROM feeds WHERE legacy_source = ?', [source]);
++ for (const x of s.sessions) {
++ await db.run(`INSERT INTO feed_sessions (feed_id, session_key, publisher, status, created_at, last_event_at, ended_at) VALUES (?, ?, ?, ?, ?, ?, ?)
++ ON CONFLICT (feed_id, session_key) DO NOTHING`, [feed.id, String(x.session_key || 'default'), JSON.stringify(json(x.publisher, {})),
++ x.status === 'ended' ? 'ended' : 'active', x.created_at || null, x.last_event_at || null, x.ended_at || null]);
++ }
++ for (const e of s.events) {
++ const payload = feeds.scrub(json(e.payload, {}));
++ await db.run(`INSERT INTO feed_events (feed_id, session_key, event_id, seq, type, summary, payload, received_ms, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
++ ON CONFLICT (feed_id, event_id) DO NOTHING`, [feed.id, e.session_key || null, String(e.event_id), Number(e.seq) || 0, String(e.type),
++ feeds.scrubText(e.summary || '').slice(0, 2000), JSON.stringify(payload), ms(e.created_at), e.created_at || null]);
++ }
++ const last = s.events.length ? s.events[s.events.length - 1].created_at : null;
++ if (last) await db.run('UPDATE feeds SET last_event_at = ? WHERE id = ? AND (last_event_at IS NULL OR last_event_at < ?)', [last, feed.id, last]);
++ // Verify: exactly the newest event ids of the file, no more, no fewer.
++ const have = new Set((await db.all('SELECT event_id FROM feed_events WHERE feed_id = ?', [feed.id])).map((r) => r.event_id));
++ const want = new Set(s.events.map((e) => String(e.event_id)));
++ const missing = [...want].filter((x) => !have.has(x)).length;
++ const extra = [...have].filter((x) => !want.has(x)).length;
++ log(`feed ${feed.id} ← slot ${s.id}: ${have.size} event(s) held, ${missing} missing, ${extra} not from the file`);
++ if (missing || extra) bad++;
++ }
++ if (bad) { log(`${bad} feed(s) did not verify`); return 1; }
++ log('verified: every feed holds exactly the events copied');
++ return 0;
++}
++
++if (require.main === module) {
++ main(process.argv.slice(2)).then((code) => process.exit(code), (err) => { console.error(`import-live-feeds failed: ${err.message}`); process.exit(1); });
++}
++
++module.exports = { main, parseArgs, readExport };
ddiff --git a/server/app.js b/server/app.js
iindex 89252b5..ef8eb66 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -186,10 +186,12 @@ function createApp({ chatServer, relay, events = null, callServer = null }) {
},
credentials: true,
});
-- // The widget's visitor API answers any site, without cookies (the visitor's token is the key), so a site can build
-- // its own panel on it; an inbox that names its sites refuses the others itself (server/inbox/routes.js).
-- const openCors = cors({ origin: true, credentials: false, allowedHeaders: ['Content-Type', 'X-Visitor-Token'] });
-- app.use((req, res, next) => (req.path.startsWith('/api/chat/widget/') ? openCors : allowlistedCors)(req, res, next));
++ // The widget's visitor API and feeds answer any site, without cookies (the visitor's token, the feed's key or an API
++ // token is the key), so a site can build its own panel or publish from a browser; an inbox that names its sites
++ // refuses the others itself (server/inbox/routes.js).
++ const openCors = cors({ origin: true, credentials: false, allowedHeaders: ['Content-Type', 'X-Visitor-Token', 'Authorization'] });
++ const openPath = (p) => p.startsWith('/api/chat/widget/') || p === '/api/chat/feeds' || p.startsWith('/api/chat/feeds/');
++ app.use((req, res, next) => (openPath(req.path) ? openCors : allowlistedCors)(req, res, next));
app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: true, limit: '256kb' }));
app.use(cookieParser());
@@@ -249,6 +251,8 @@ function createApp({ chatServer, relay, events = null, callServer = null }) {
// Support inboxes (server/inbox/): the widget's visitor API, and the inbox's people.
app.use('/api/chat/widget/inbox/:id', require('./inbox/routes').widget);
app.use('/api/chat/inboxes', require('./inbox/routes').agents);
++ // Feeds (server/feeds/): live activity (vibe coding) published with a feed's key, shown in rooms, on Live, anywhere.
++ app.use('/api/chat/feeds', require('./feeds/routes'));
app.use('/api/chat/servers', require('./rooms/server-routes'));
// Channel moderators & moderation settings (/api/chat/channels/:id/…): before /api/chat too.
app.use('/api/chat/channels', require('./chat/channel-mod-routes'));
ddiff --git a/server/chat/account-data.js b/server/chat/account-data.js
iindex 03bb4aa..80883b5 100644
---- a/server/chat/account-data.js
++++ b/server/chat/account-data.js
@@@ -63,6 +63,9 @@ const EXPORTS = [
['inboxes_owned.json', `SELECT * FROM inboxes WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids) ORDER BY created_at DESC LIMIT ${LIMIT}`],
['inbox_memberships.json', `SELECT * FROM inbox_members WHERE user_id = ANY(@ids) LIMIT ${LIMIT}`],
['inbox_replies.json', `SELECT * FROM inbox_messages WHERE author = 'agent' AND author_user_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
++ // Feeds (server/feeds/feeds.js): the feeds, without their key's hash, and what was published to them.
++ ['feeds.json', `SELECT id, kind, title, room_id, managed_stream_id, settings, key_hint, legacy_source, created_at, last_event_at FROM feeds WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids) ORDER BY created_at DESC LIMIT ${LIMIT}`],
++ ['feed_events.json', `SELECT e.feed_id, e.session_key, e.event_id, e.type, e.summary, e.payload, e.created_at FROM feed_events e JOIN feeds f ON f.id = e.feed_id WHERE f.owner_subject = ANY(@s) OR f.owner_id = ANY(@ids) ORDER BY e.id DESC LIMIT ${LIMIT}`],
];
async function exportPart(d, subject) {
@@@ -110,6 +113,10 @@ const ERASE_AFTER_ROOMS = [
[null, 'DELETE FROM inbox_ai_usage WHERE inbox_id IN (SELECT id FROM inboxes WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids))'],
['inboxes', 'DELETE FROM inboxes WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids)'],
[null, "UPDATE inbox_messages SET author_user_id = NULL, author_name = 'A former agent' WHERE author = 'agent' AND author_user_id = ANY(@ids)"],
++ // Feeds (server/feeds/feeds.js): a person's feeds go with everything published to them.
++ ['feed_events', 'DELETE FROM feed_events WHERE feed_id IN (SELECT id FROM feeds WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids))'],
++ [null, 'DELETE FROM feed_sessions WHERE feed_id IN (SELECT id FROM feeds WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids))'],
++ ['feeds', 'DELETE FROM feeds WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids)'],
// An AI member belongs to its room, not to whoever added it: it stays, without their name on it.
[null, 'UPDATE room_ai_members SET created_by_subject = NULL WHERE created_by_subject = ANY(@s)'],
// Their own AI key is gone with them (OpenVibe.AI erases it): members that thought with it go back to OpenVibe's model.
ddiff --git a/server/chat/chat-server.js b/server/chat/chat-server.js
iindex 0982b15..e1392c0 100644
---- a/server/chat/chat-server.js
++++ b/server/chat/chat-server.js
@@@ -603,6 +603,14 @@ class ChatServer {
this.sendTo(ws, { type: 'inbox_watching', inbox: inbox.id, conversation: client.inboxConv || null });
break;
}
++ // Feeds (server/feeds/feeds.js): anyone follows a feed's card; what reaches them is already projected.
++ case 'feed_watch': {
++ const feed = await require('../feeds/feeds').get(msg.feed);
++ if (!feed) { this.sendTo(ws, { type: 'feed_error', code: 'feeds.not_found', message: 'No such feed' }); break; }
++ client.feedId = feed.id;
++ this.sendTo(ws, { type: 'feed_watching', feed: feed.id });
++ break;
++ }
case 'room_message': {
const rooms = require('../rooms/rooms');
const room = client.roomId ? await rooms.bySlug(client.roomSlug) : null;
@@@ -2559,6 +2567,20 @@ class ChatServer {
}
}
++ /**
++ * A feed's event, as viewers see it: to everyone following the feed's card, and to the Live channel slot it is on
++ * while that slot is live, as Live's `vibe-coding` frame (the widget in Live's channel chat reads those).
++ */
++ async deliverFeed(feed, event) {
++ const msg = JSON.stringify({ type: 'feed_event', feed: feed.id, event });
++ for (const [ws, client] of this.clients) {
++ if (client.feedId === feed.id && ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE) ws.send(msg);
++ }
++ if (!feed.managed_stream_id) return;
++ const live = await ctx.liveStreamForSlot(feed.managed_stream_id).catch(() => null);
++ if (live) await this.broadcastToStream(Number(live.id), { type: 'vibe-coding', managed_stream_id: feed.managed_stream_id, slot_slug: live.slug || null, delay_ms: 0, event });
++ }
++
/** Everyone following a chat room (server/rooms/) on this server. */
broadcastToRoom(roomId, data) {
const msg = JSON.stringify(data);
ddiff --git a/server/feeds/feeds.js b/server/feeds/feeds.js
nnew file mode 100644
iindex 0000000..23de7ce
---- /dev/null
++++ b/server/feeds/feeds.js
@@@ -0,0 +1,412 @@
++'use strict';
++/**
++ * Feeds (docs/platform.md step 2; migrations/0017_feeds.sql): live activity a person publishes into Chat. Vibe coding,
++ * moved here from OpenVibe.Live (its server/vibe-coding/, same canonical events), is the first kind: an editor, agent or
++ * CLI sends sanitized events (a prompt, an answer, a tool used, a file saved) with the feed's key, and viewers see a
++ * compact card in the feed's room, on its Live channel slot while live, or on any site (openvibe.chat/embed/feed/:id).
++ *
++ * feeds create(user, input) → { feed, key } · update(feed, user, input) · rotateKey(feed) → key · remove(feed) ·
++ * get(id) · byKey(key) · mine(user) · forSlots(ids) · forRoom(roomId) · publicShape(feed)
++ * publishing hello(feed, msg) · publish(feed, input) → [{ eventId, delivered }] · fromClaudeCode(hook) → events
++ * viewing view(feed, { limit }) → { feed, publisher, events } · project(event, settings)
++ *
++ * What a viewer sees is decided when they see it: project() applies the feed's settings to the stored event (the
++ * projection is Live's), so turning prompts off hides them in the history too. Events are scrubbed of secrets (keys,
++ * tokens, passwords, private keys) and home folders on the way in, and reach viewers only after the feed's delay, kept
++ * on the server (Live sent the delay to the browser). A feed keeps its newest 5,000 events.
++ */
++const crypto = require('crypto');
++const path = require('path');
++const db = require('../db/database');
++const wordFilter = require('../chat/word-filter');
++
++class FeedError extends Error { constructor(status, code, message) { super(message); this.status = status; this.code = code; } }
++const fail = (status, code, message) => { throw new FeedError(status, code, message); };
++
++const B32 = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
++const newId = () => `fed_${Array.from(crypto.randomBytes(26), (b) => B32[b % 32]).join('')}`;
++const newKey = () => `ovf_${crypto.randomBytes(24).toString('base64url')}`;
++const hashKey = (k) => crypto.createHash('sha256').update(String(k)).digest('hex');
++const FEED_RE = /^fed_[0-9A-HJKMNP-TV-Z]{26}$/;
++const KEY_RE = /^ovf_[A-Za-z0-9_-]{32}$/;
++const MAX_FEEDS = 10;
++const KEEP_EVENTS = 5000;
++const MAX_BATCH = 50;
++const TYPES = ['session.status', 'prompt', 'response', 'thinking', 'tool.call', 'file.change', 'file.save'];
++
++const DEFAULTS = Object.freeze({
++ enabled: 1,
++ viewer_depth: 'standard',
++ show_prompts: 1,
++ show_responses: 1,
++ show_thinking: 0,
++ show_tool_calls: 1,
++ show_tool_arguments: 0,
++ show_file_events: 1,
++ show_file_snippets: 0,
++ redact_file_paths: 1,
++ paused: 0,
++ delay_ms: 0,
++ max_events: 18,
++ max_prompt_chars: 220,
++ max_response_chars: 360,
++ max_thinking_chars: 140,
++ max_tool_chars: 140,
++ max_snippet_chars: 140,
++});
++
++const clean = (v, max) => String(v == null ? '' : v).replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '').trim().slice(0, max);
++const str = (v, max = 200) => { const s = clean(v, max); return s || null; };
++const bool = (v, d) => (v === undefined || v === null ? d : (v === true || v === 1 || v === '1' || v === 'on' || v === 'true') ? 1 : 0);
++const int = (v, min, max, d) => { const n = Number(v); return Number.isFinite(n) ? Math.min(max, Math.max(min, Math.round(n))) : d; };
++const depthOf = (v) => (['headline', 'deep'].includes(String(v || '').toLowerCase()) ? String(v).toLowerCase() : 'standard');
++const obj = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : null);
++const iso = (t) => (t ? (String(t).includes('T') ? String(t) : `${String(t).replace(' ', 'T')}Z`) : null); // ov_now() is UTC text
++const json = (v, d) => { if (v && typeof v === 'object') return v; try { return JSON.parse(v || 'null') || d; } catch { return d; } };
++
++function normalizeSettings(input = {}, current = DEFAULTS) {
++ const s = { ...DEFAULTS, ...current };
++ const out = {};
++ for (const k of ['enabled', 'show_prompts', 'show_responses', 'show_thinking', 'show_tool_calls', 'show_tool_arguments', 'show_file_events', 'show_file_snippets', 'redact_file_paths', 'paused']) out[k] = bool(input[k], s[k]);
++ out.viewer_depth = depthOf(input.viewer_depth !== undefined ? input.viewer_depth : s.viewer_depth);
++ const n = (k, min, max) => { out[k] = int(input[k] !== undefined ? input[k] : s[k], min, max, DEFAULTS[k]); };
++ n('delay_ms', 0, 120000); n('max_events', 1, 100); n('max_prompt_chars', 60, 1200); n('max_response_chars', 80, 2400);
++ n('max_thinking_chars', 40, 1200); n('max_tool_chars', 40, 1200); n('max_snippet_chars', 40, 1200);
++ return out;
++}
++
++// ── Scrubbing: what a publisher should never have sent ─────────────────────────────────────────────────────────────
++const HIDDEN = '[hidden]';
++const SECRETS = [
++ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g,
++ /\b(?:sk|pk|rk)-(?:ant-|proj-|live-|test-)?[A-Za-z0-9_-]{16,}/g,
++ /\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})/g,
++ /\b(?:hbt|ovf|ovs|whsec)_[A-Za-z0-9_-]{8,}/g,
++ /\bAKIA[0-9A-Z]{16}\b/g,
++ /\bxox[abprs]-[A-Za-z0-9-]{10,}/g,
++ /\bAIza[0-9A-Za-z_-]{30,}/g,
++ /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g,
++];
++function scrubText(s) {
++ let t = String(s);
++ for (const re of SECRETS) t = t.replace(re, HIDDEN);
++ return t
++ .replace(/\b(Bearer|Basic|Token)\s+[A-Za-z0-9._~+/=-]{12,}/gi, `$1 ${HIDDEN}`)
++ .replace(/([a-z][a-z0-9+.-]*:\/\/)[^\s/:@]+:[^\s/@]+@/gi, `$1${HIDDEN}@`)
++ .replace(/\b((?:[A-Z0-9_]*_)?(?:PASSWORD|PASSWD|SECRET|TOKEN|API_?KEY|ACCESS_?KEY|PRIVATE_?KEY)[A-Z0-9_]*)(\s*[=:]\s*)("[^"]*"|'[^']*'|\S+)/gi, `$1$2${HIDDEN}`)
++ .replace(/(?:\/home|\/Users)\/[^/\s"'`]+/g, '~')
++ .replace(/\b[A-Za-z]:\\Users\\[^\\\s"'`]+/g, '~');
++}
++function scrub(v, depth = 0) {
++ if (depth > 6) return null;
++ if (typeof v === 'string') return scrubText(v).slice(0, 8000);
++ if (Array.isArray(v)) return v.slice(0, 50).map((x) => scrub(x, depth + 1));
++ if (v && typeof v === 'object') { const o = {}; for (const [k, x] of Object.entries(v).slice(0, 60)) o[k] = scrub(x, depth + 1); return o; }
++ return typeof v === 'number' || typeof v === 'boolean' || v === null ? v : null;
++}
++
++// ── Feeds ──────────────────────────────────────────────────────────────────────────────────────────────────────────
++
++function shape(r) {
++ if (!r) return null;
++ return {
++ id: r.id, owner_id: Number(r.owner_id), kind: r.kind, title: r.title,
++ room_id: r.room_id == null ? null : Number(r.room_id), managed_stream_id: r.managed_stream_id == null ? null : Number(r.managed_stream_id),
++ settings: normalizeSettings({}, json(r.settings, {})), key_hint: r.key_hint || null, legacy_source: r.legacy_source || null,
++ created_at: iso(r.created_at), last_event_at: iso(r.last_event_at),
++ };
++}
++/** What anyone may know about a feed (the embed, the room card, Live's channel). */
++function publicShape(feed) {
++ return { id: feed.id, kind: feed.kind, title: feed.title, enabled: !!feed.settings.enabled && !feed.settings.paused, depth: feed.settings.viewer_depth, last_event_at: feed.last_event_at };
++}
++
++async function get(id) {
++ if (!FEED_RE.test(String(id || ''))) return null;
++ return shape(await db.get('SELECT * FROM feeds WHERE id = ? AND archived_at IS NULL', [id]));
++}
++async function byKey(key) {
++ if (!KEY_RE.test(String(key || ''))) return null;
++ return shape(await db.get('SELECT * FROM feeds WHERE key_hash = ? AND archived_at IS NULL', [hashKey(key)]));
++}
++async function mine(user) {
++ return (await db.all('SELECT * FROM feeds WHERE owner_id = ? AND archived_at IS NULL ORDER BY created_at DESC, id', [user.id])).map(shape);
++}
++async function forSlots(ids) {
++ const list = [...new Set((ids || []).map(Number).filter((n) => Number.isSafeInteger(n) && n > 0))].slice(0, 50);
++ if (!list.length) return [];
++ return (await db.all(`SELECT * FROM feeds WHERE managed_stream_id IN (${list.map(() => '?').join(',')}) AND archived_at IS NULL ORDER BY last_event_at DESC NULLS LAST`, list)).map(shape);
++}
++/** The feed a room shows (the most recently active one its owner put there that viewers may see), or null. */
++async function forRoom(roomId) {
++ const list = (await db.all('SELECT * FROM feeds WHERE room_id = ? AND archived_at IS NULL ORDER BY last_event_at DESC NULLS LAST LIMIT 5', [roomId])).map(shape);
++ return list.find((f) => f.settings.enabled && !f.settings.paused) || null;
++}
++
++// Where a feed shows: a room the person owns, a channel slot of theirs. null clears it.
++async function placeOf(user, input, current = {}) {
++ const out = {};
++ if (input.room !== undefined || input.room_id !== undefined) {
++ const v = input.room !== undefined ? input.room : input.room_id;
++ if (v === null || v === '' || v === 0 || v === '0') out.room_id = null;
++ else {
++ const rooms = require('../rooms/rooms');
++ const room = /^\d+$/.test(String(v)) ? await db.get('SELECT * FROM rooms WHERE id = ?', [Number(v)]) : await rooms.bySlug(String(v));
++ if (!room || room.assistant) fail(422, 'feeds.room', 'No such room');
++ if (!(await rooms.access(room, user)).manage) fail(403, 'feeds.room', 'A feed shows only in a room you own');
++ out.room_id = Number(room.id);
++ }
++ }
++ if (input.slot !== undefined || input.managed_stream_id !== undefined) {
++ const v = input.slot !== undefined ? input.slot : input.managed_stream_id;
++ if (v === null || v === '' || v === 0 || v === '0') out.managed_stream_id = null;
++ else {
++ const ms = await db.get('SELECT id, user_id FROM ctx_managed_streams WHERE id = ?', [Number(v)]);
++ if (!ms || Number(ms.user_id) !== Number(user.id)) fail(403, 'feeds.slot', 'A feed shows only on a channel slot of yours');
++ out.managed_stream_id = Number(ms.id);
++ }
++ }
++ return { room_id: current.room_id ?? null, managed_stream_id: current.managed_stream_id ?? null, ...out };
++}
++
++function titleOf(v) {
++ const t = clean(v, 81).replace(/\s+/g, ' ');
++ if (t.length < 2 || t.length > 80) fail(422, 'feeds.title', 'A feed\'s title is 2 to 80 characters');
++ if (wordFilter.check(t).safe === false) fail(422, 'feeds.title', 'That title is not allowed');
++ return t;
++}
++
++async function create(user, input = {}) {
++ const n = await db.get('SELECT count(*)::int AS n FROM feeds WHERE owner_id = ? AND archived_at IS NULL', [user.id]);
++ if (n.n >= MAX_FEEDS) fail(409, 'feeds.too_many', `At most ${MAX_FEEDS} feeds`);
++ const title = titleOf(input.title || 'Vibe coding');
++ const place = await placeOf(user, input);
++ const id = newId();
++ const key = newKey();
++ await db.run(`INSERT INTO feeds (id, owner_id, owner_subject, title, room_id, managed_stream_id, settings, key_hash, key_hint)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [id, user.id, user.subject_id || null, title, place.room_id, place.managed_stream_id,
++ JSON.stringify(normalizeSettings(obj(input.settings) || {})), hashKey(key), key.slice(-4)]);
++ return { feed: await get(id), key };
++}
++async function update(feed, user, input = {}) {
++ const title = input.title !== undefined ? titleOf(input.title) : feed.title;
++ const place = await placeOf(user, input, feed);
++ const settings = normalizeSettings(obj(input.settings) || {}, feed.settings);
++ await db.run('UPDATE feeds SET title = ?, room_id = ?, managed_stream_id = ?, settings = ? WHERE id = ?',
++ [title, place.room_id, place.managed_stream_id, JSON.stringify(settings), feed.id]);
++ return await get(feed.id);
++}
++async function rotateKey(feed) {
++ const key = newKey();
++ await db.run('UPDATE feeds SET key_hash = ?, key_hint = ? WHERE id = ?', [hashKey(key), key.slice(-4), feed.id]);
++ return key;
++}
++async function remove(feed) {
++ await db.run('DELETE FROM feed_events WHERE feed_id = ?', [feed.id]);
++ await db.run('DELETE FROM feed_sessions WHERE feed_id = ?', [feed.id]);
++ await db.run('DELETE FROM feeds WHERE id = ?', [feed.id]);
++}
++
++// ── Publishing ─────────────────────────────────────────────────────────────────────────────────────────────────────
++
++function publisherOf(p) {
++ const s = obj(p) || {};
++ const caps = obj(s.capabilities) || {};
++ const clientType = String(s.clientType || '').toLowerCase();
++ return {
++ integrationId: str(s.integrationId, 60) || 'custom',
++ integrationLabel: str(s.integrationLabel, 60) || str(s.integrationId, 60) || 'Custom publisher',
++ vendor: str(s.vendor, 60),
++ clientType: ['vscode-extension', 'cli', 'browser', 'service'].includes(clientType) ? clientType : 'other',
++ clientName: str(s.clientName, 80) || str(s.integrationLabel, 80) || 'Custom publisher',
++ clientVersion: str(s.clientVersion || s.extensionVersion, 40),
++ workspaceName: str(s.workspaceName, 80),
++ machineName: null, // never shown; Live kept it, Chat does not
++ viewerDepth: depthOf(s.viewerDepth),
++ capabilities: { thinking: !!caps.thinking, toolCalls: !!caps.toolCalls, workspaceFileEvents: !!caps.workspaceFileEvents },
++ };
++}
++const eventPublisher = (p) => { const x = publisherOf(p); return { integrationId: x.integrationId, integrationLabel: x.integrationLabel, vendor: x.vendor, clientType: x.clientType, clientName: x.clientName }; };
++
++/** One canonical event from what a publisher sent (Live's envelope, the bare event, or a short { type, text }). */
++function normalizeEvent(input, fallbackSession) {
++ let e = obj(input);
++ if (!e) fail(422, 'feeds.event', 'An event is an object');
++ if (e.type === 'vibe-coding.event') e = obj(e.event) || fail(422, 'feeds.event', 'The envelope has no event');
++ const type = String(e.eventType || e.type || '');
++ if (!TYPES.includes(type)) fail(422, 'feeds.event_type', `eventType is one of ${TYPES.join(', ')}`);
++ const text = (k) => str((obj(e[k]) && e[k].text) || (e.text && (type === k) ? e.text : null), 8000);
++ const out = {
++ version: 1,
++ eventId: str(e.eventId || e.id, 100) || crypto.randomUUID(),
++ sessionKey: str(e.sessionKey, 120) || fallbackSession || 'default',
++ sequence: int(e.sequence, 0, Number.MAX_SAFE_INTEGER, 0),
++ eventType: type,
++ visibility: 'public',
++ occurredAt: str(e.occurredAt, 40) || new Date().toISOString(),
++ summary: str(e.summary, 2000) || '',
++ source: obj(e.source) ? { channel: str(e.source.channel, 60), rawType: str(e.source.rawType, 60) } : null,
++ publisher: obj(e.publisher) ? eventPublisher(e.publisher) : null,
++ metadata: obj(e.metadata) ? { requestId: str(e.metadata.requestId, 100), responseId: str(e.metadata.responseId, 100), workspaceName: str(e.metadata.workspaceName, 80), model: str(e.metadata.model, 80) } : null,
++ };
++ if (type === 'prompt') out.prompt = { text: text('prompt') || out.summary };
++ if (type === 'response') out.response = { text: text('response') || out.summary };
++ if (type === 'thinking') out.thinking = { text: text('thinking') || out.summary };
++ if (type === 'tool.call') {
++ const t = obj(e.tool) || {};
++ out.tool = { name: str(t.name || e.name, 80) || 'tool', phase: str(t.phase, 20) || 'completed', argumentsPreview: str(t.argumentsPreview || e.args, 2000), resultPreview: str(t.resultPreview, 2000) };
++ }
++ if (type === 'file.change' || type === 'file.save') {
++ const f = obj(e.file) || {};
++ const rel = str(f.relativePath || e.path, 400);
++ out.file = { name: str(f.name, 200) || (rel ? path.posix.basename(rel.replace(/\\/g, '/')) : 'file'), relativePath: rel, operation: str(f.operation, 20) || (type === 'file.save' ? 'save' : 'edit'), changeCount: int(f.changeCount, 0, 1e6, 0), snippet: str(f.snippet, 2000) };
++ }
++ if (!out.summary) out.summary = (out.prompt || out.response || out.thinking || {}).text || '';
++ return scrub(out);
++}
++
++function deliverer(deps) {
++ return deps.deliver || ((feed, event) => { try { require('../chat/chat-server').deliverFeed(feed, event); } catch { /* no socket server (tests) */ } });
++}
++
++/** A publisher starts (or resumes) a session: who it is. */
++async function hello(feed, msg = {}) {
++ const key = str(msg.sessionKey, 120) || 'default';
++ const publisher = publisherOf(msg.publisher);
++ await db.run(`INSERT INTO feed_sessions (feed_id, session_key, publisher, status, last_event_at) VALUES (?, ?, ?, 'active', ov_now())
++ ON CONFLICT (feed_id, session_key) DO UPDATE SET publisher = excluded.publisher, status = 'active', ended_at = NULL, last_event_at = ov_now()`,
++ [feed.id, key, JSON.stringify(publisher)]);
++ return { sessionKey: key, publisher };
++}
++
++/**
++ * Store and deliver events: one, { events: [...] } (at most 50), or Live's envelopes. A repeated eventId replaces the
++ * stored one and is not delivered again. → [{ eventId, delivered }]
++ */
++async function publish(feed, input, deps = {}) {
++ const list = Array.isArray(input) ? input : Array.isArray(input && input.events) ? input.events : [input];
++ if (!list.length) fail(422, 'feeds.empty', 'No events');
++ if (list.length > MAX_BATCH) fail(413, 'feeds.batch', `At most ${MAX_BATCH} events at once`);
++ const events = list.map((e) => normalizeEvent(e, input && input.sessionKey));
++ const deliver = deliverer(deps);
++ const now = Date.now();
++ const out = [];
++ for (const event of events) {
++ const r = await db.get(`INSERT INTO feed_events (feed_id, session_key, event_id, seq, type, summary, payload, received_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
++ ON CONFLICT (feed_id, event_id) DO UPDATE SET seq = excluded.seq, type = excluded.type, summary = excluded.summary, payload = excluded.payload
++ RETURNING (xmax = 0) AS fresh`, [feed.id, event.sessionKey, event.eventId, event.sequence, event.eventType, event.summary.slice(0, 2000), JSON.stringify(event), now]);
++ await db.run(`INSERT INTO feed_sessions (feed_id, session_key, publisher, status, last_event_at) VALUES (?, ?, ?, 'active', ov_now())
++ ON CONFLICT (feed_id, session_key) DO UPDATE SET last_event_at = ov_now()${event.eventType === 'session.status' && /\bend(ed)?\b/i.test(event.summary) ? ", status = 'ended', ended_at = ov_now()" : ''}`,
++ [feed.id, event.sessionKey, JSON.stringify(event.publisher || {})]);
++ const fresh = !!(r && r.fresh);
++ const viewed = fresh ? project(event, feed.settings) : null;
++ // The delay is the server's: a viewer cannot read ahead of it (the history waits for it too, in view()).
++ if (viewed && feed.settings.delay_ms) setTimeout(() => deliver(feed, viewed), feed.settings.delay_ms).unref();
++ else if (viewed) deliver(feed, viewed);
++ out.push({ eventId: event.eventId, delivered: !!viewed });
++ }
++ await db.run('UPDATE feeds SET last_event_at = ov_now() WHERE id = ?', [feed.id]);
++ if (Math.random() < 0.02) await prune(feed.id);
++ return out;
++}
++async function prune(feedId) {
++ await db.run(`DELETE FROM feed_events WHERE feed_id = ? AND id < (SELECT id FROM feed_events WHERE feed_id = ? ORDER BY id DESC OFFSET ? LIMIT 1)`, [feedId, feedId, KEEP_EVENTS - 1]);
++}
++
++// Claude Code's hooks (an "http" hook POSTs each event's JSON): what a viewer may see of a prompt, a tool used, a
++// turn's answer. Paths become relative to the session's folder; nothing is read from the transcript.
++const CC = { integrationId: 'claude-code', integrationLabel: 'Claude Code', vendor: 'Anthropic', clientType: 'cli', clientName: 'Claude Code' };
++function relTo(cwd, p) {
++ const s = String(p || '');
++ if (cwd && s.startsWith(String(cwd).replace(/\/+$/, '') + '/')) return s.slice(String(cwd).replace(/\/+$/, '').length + 1);
++ return s;
++}
++function fromClaudeCode(h) {
++ const hook = obj(h) || {};
++ const sessionKey = `cc-${clean(hook.session_id, 80) || 'session'}`;
++ const base = { sessionKey, publisher: CC, metadata: { model: str(obj(hook.model) ? hook.model.id || hook.model.display_name : hook.model, 80), workspaceName: hook.cwd ? path.basename(String(hook.cwd)) : null } };
++ const id = (suffix) => `${sessionKey}-${clean(hook.tool_use_id || hook.prompt_id || '', 80) || crypto.randomUUID()}${suffix}`;
++ switch (hook.hook_event_name) {
++ case 'SessionStart': return [{ ...base, eventId: id('-start'), eventType: 'session.status', summary: hook.source === 'resume' ? 'Resumed a Claude Code session' : 'Started a Claude Code session' }];
++ case 'SessionEnd': return [{ ...base, eventId: id('-end'), eventType: 'session.status', summary: 'Session ended' }];
++ case 'UserPromptSubmit': return hook.prompt ? [{ ...base, eventId: id('-prompt'), eventType: 'prompt', prompt: { text: String(hook.prompt) } }] : [];
++ case 'Stop': return hook.last_assistant_message ? [{ ...base, eventId: id(`-answer-${Date.now()}`), eventType: 'response', response: { text: String(hook.last_assistant_message) } }] : [];
++ case 'PostToolUse': {
++ const name = clean(hook.tool_name, 80) || 'tool';
++ const input = obj(hook.tool_input) || {};
++ const file = input.file_path || input.notebook_path || null;
++ const rel = file ? relTo(hook.cwd, file) : null;
++ const arg = input.command || rel || input.pattern || input.url || input.query || input.description || null;
++ // A file written or edited is a file event (the feed's file settings apply), not a tool used as well.
++ if (rel && ['Edit', 'MultiEdit', 'Write', 'NotebookEdit'].includes(name)) {
++ return [{ ...base, eventId: id('-file'), eventType: name === 'Write' ? 'file.save' : 'file.change', file: { relativePath: rel, operation: name === 'Write' ? 'write' : 'edit', changeCount: Array.isArray(input.edits) ? input.edits.length : 1 } }];
++ }
++ const what = { Bash: 'Ran a command', Read: `Read ${rel || 'a file'}`, Grep: 'Searched the code', Glob: 'Looked for files', WebFetch: 'Read a web page', WebSearch: 'Searched the web', Task: 'Started a helper', Agent: 'Started a helper' }[name];
++ return [{ ...base, eventId: id('-tool'), eventType: 'tool.call', summary: what || `Used ${name}`, tool: { name, phase: 'completed', argumentsPreview: arg ? String(arg) : null, resultPreview: null } }];
++ }
++ default: return [];
++ }
++}
++
++// ── Viewing ────────────────────────────────────────────────────────────────────────────────────────────────────────
++
++function cut(value, max, depth) {
++ const text = String(value || '').replace(/\s+/g, ' ').trim();
++ if (!text) return '';
++ const limit = depth === 'headline' ? Math.max(40, Math.floor(max * 0.6)) : depth === 'deep' ? Math.max(max, Math.floor(max * 1.1)) : max;
++ return text.length <= limit ? text : `${text.slice(0, Math.max(20, limit - 1)).trim()}…`;
++}
++const TOOL_PREVIEW_ALWAYS = ['run_in_terminal', 'create_and_run_task', 'run_notebook_cell'];
++
++/** What a viewer sees of a stored event under these settings, or null (Live's projectViewerEvent). */
++function project(event, settings) {
++ if (!event || !settings || !settings.enabled || settings.paused) return null;
++ const d = settings.viewer_depth;
++ const p = {
++ eventId: event.eventId, eventType: event.eventType, occurredAt: event.occurredAt, sequence: event.sequence, depth: d,
++ summary: '', source: event.source || null, prompt: null, response: null, thinking: null, tool: null, file: null,
++ publisher: event.publisher ? eventPublisher(event.publisher) : null,
++ metadata: { requestId: event.metadata?.requestId || null, responseId: event.metadata?.responseId || null, workspaceName: event.metadata?.workspaceName || null, model: event.metadata?.model || null },
++ };
++ switch (event.eventType) {
++ case 'session.status': p.summary = cut(event.summary, settings.max_prompt_chars, d); return p;
++ case 'prompt': if (!settings.show_prompts) return null; p.summary = cut(event.prompt?.text || event.summary, settings.max_prompt_chars, d); p.prompt = { text: p.summary }; return p;
++ case 'response': if (!settings.show_responses) return null; p.summary = cut(event.response?.text || event.summary, settings.max_response_chars, d); p.response = { text: p.summary }; return p;
++ case 'thinking': if (!settings.show_thinking) return null; p.summary = cut(event.thinking?.text || event.summary, settings.max_thinking_chars, d); p.thinking = { text: p.summary }; return p;
++ case 'tool.call': {
++ if (!settings.show_tool_calls) return null;
++ const preview = settings.show_tool_arguments || TOOL_PREVIEW_ALWAYS.includes(String(event.tool?.name || '').toLowerCase());
++ p.summary = cut(event.summary || `Used ${event.tool?.name || 'tool'}`, settings.max_tool_chars, d);
++ p.tool = { name: event.tool?.name || 'tool', phase: event.tool?.phase || 'completed', argumentsPreview: preview ? cut(event.tool?.argumentsPreview, settings.max_tool_chars, d) || null : null, resultPreview: preview ? cut(event.tool?.resultPreview, settings.max_tool_chars, d) || null : null };
++ if (!preview && settings.redact_file_paths) p.summary = p.summary.replace(/[\w.~/-]*\/([\w.-]+)/g, '$1');
++ return p;
++ }
++ case 'file.change': case 'file.save': {
++ if (!settings.show_file_events) return null;
++ const raw = String(event.file?.relativePath || event.file?.name || '').trim();
++ const shown = settings.redact_file_paths ? path.posix.basename(raw.replace(/\\/g, '/')) : raw;
++ p.summary = `${event.eventType === 'file.save' ? 'Saved' : 'Editing'} ${shown || event.file?.name || 'file'}`;
++ p.file = { name: event.file?.name || shown || 'file', relativePath: shown || null, operation: event.file?.operation || (event.eventType === 'file.save' ? 'save' : 'edit'), changeCount: Number(event.file?.changeCount || 0), snippet: settings.show_file_snippets ? cut(event.file?.snippet, settings.max_snippet_chars, d) || null : null };
++ return p;
++ }
++ default: return null;
++ }
++}
++
++/** The feed as a viewer sees it now: its newest events, older than its delay, projected. */
++async function view(feed, { limit } = {}) {
++ const n = int(limit, 1, 100, feed.settings.max_events);
++ const before = Date.now() - feed.settings.delay_ms;
++ const rows = await db.all('SELECT payload FROM feed_events WHERE feed_id = ? AND received_ms <= ? ORDER BY id DESC LIMIT ?', [feed.id, before, n * 3]);
++ const events = rows.map((r) => project(json(r.payload, null), feed.settings)).filter(Boolean).slice(0, n).reverse();
++ const s = await db.get("SELECT publisher FROM feed_sessions WHERE feed_id = ? ORDER BY COALESCE(last_event_at, created_at) DESC, id DESC LIMIT 1", [feed.id]);
++ return { feed: publicShape(feed), publisher: s ? publisherOf(json(s.publisher, {})) : null, events };
++}
++
++function _reset() { /* no module state */ }
++
++module.exports = {
++ FeedError, FEED_RE, KEY_RE, TYPES, DEFAULTS, MAX_FEEDS, KEEP_EVENTS, MAX_BATCH, hashKey,
++ normalizeSettings, scrubText, scrub, normalizeEvent, project, fromClaudeCode,
++ create, update, rotateKey, remove, get, byKey, mine, forSlots, forRoom, shape, publicShape, hello, publish, view, prune, _reset,
++};
ddiff --git a/server/feeds/routes.js b/server/feeds/routes.js
nnew file mode 100644
iindex 0000000..cd5a00c
---- /dev/null
++++ b/server/feeds/routes.js
@@@ -0,0 +1,113 @@
++'use strict';
++/**
++ * Feeds over HTTP (server/feeds/feeds.js, docs/platform.md). One router at /api/chat/feeds; the public and publishing
++ * routes answer any site without cookies (server/app.js), the owner's need a sign-in (or an API token as its person).
++ *
++ * Anyone:
++ * GET /?slot=1,2 | ?channel=username { feeds }: the feeds shown on those Live channel slots (Live's chat asks)
++ * GET /:id { feed, publisher, events }: what a viewer sees now (after the delay)
++ * Publishers (Authorization: Bearer ovf_…, the feed's key):
++ * POST /:id/hello { sessionKey?, publisher? } → { sessionKey, publisher }
++ * POST /:id/events an event, { events: [...] } (at most 50) or Live's envelopes → { events }
++ * POST /:id/hooks/claude-code Claude Code's hook JSON as is (an "http" hook) → {} (never blocks it)
++ * The owner:
++ * GET /mine { feeds }
++ * POST / { title?, room?, slot?, settings? } → 201 { feed, key } (the key, once)
++ * PATCH /:id { title?, room?, slot?, settings? } → { feed }
++ * POST /:id/key → { key } (a new key; the old one stops working)
++ * DELETE /:id
++ */
++const express = require('express');
++const rateLimit = require('express-rate-limit');
++const cache = require('openvibe-shared/cache-policy');
++const { requireAuth } = require('../auth/auth');
++const feeds = require('./feeds');
++
++function send(res, err) {
++ if (err instanceof feeds.FeedError) return res.status(err.status).json({ error: err.message, code: err.code });
++ console.error('[Feeds]', err && err.message);
++ return res.status(500).json({ error: 'Something went wrong' });
++}
++const noStore = (res) => res.set('Cache-Control', cache.htmlHeaders({ private: true }));
++const router = express.Router();
++const readLimit = rateLimit({ windowMs: 60_000, max: 120, standardHeaders: true, legacyHeaders: false });
++// Per feed key: an agent working hard sends a few events a second.
++const publishLimit = rateLimit({
++ windowMs: 60_000, max: 600, standardHeaders: true, legacyHeaders: false,
++ keyGenerator: (req) => `feed:${req.params.id}`,
++ message: { error: 'Slow down a little', code: 'feeds.slow_down' },
++});
++
++// The feed's key first (a wrong key spends the address's allowance, not the feed's), then the feed's own allowance.
++const keyLimit = rateLimit({ windowMs: 60_000, max: 1200, standardHeaders: true, legacyHeaders: false });
++async function withKey(req, res, next) {
++ const m = /^Bearer\s+(\S+)$/i.exec(req.get('authorization') || '');
++ const feed = m ? await feeds.byKey(m[1]) : null;
++ if (!feed || feed.id !== req.params.id) return res.status(401).json({ error: 'The feed\'s key is missing or wrong', code: 'feeds.key' });
++ req.feed = feed;
++ next();
++}
++const publisher = [keyLimit, withKey, publishLimit];
++async function ownFeed(req, res) {
++ const feed = await feeds.get(req.params.id);
++ if (!feed || feed.owner_id !== Number(req.user.id)) { res.status(404).json({ error: 'No such feed', code: 'feeds.not_found' }); return null; }
++ return feed;
++}
++
++// ── Anyone ─────────────────────────────────────────────────────────────────────────────────────────────────────────
++router.get('/', readLimit, async (req, res, next) => {
++ if (!req.query.slot && !req.query.channel) return next();
++ try {
++ let slots = String(req.query.slot || '').split(',').map(Number).filter(Boolean);
++ if (req.query.channel) {
++ const ctx = require('../live-context');
++ const owner = await ctx.getUserByUsername(String(req.query.channel));
++ slots = owner ? (await ctx.getManagedStreamsByUserId(owner.id)).map((m) => Number(m.id)) : [];
++ }
++ const list = (await feeds.forSlots(slots)).filter((f) => f.settings.enabled && !f.settings.paused);
++ res.set('Cache-Control', 'public, max-age=15').json({ feeds: list.map((f) => ({ ...feeds.publicShape(f), managed_stream_id: f.managed_stream_id })) });
++ } catch (err) { send(res, err); }
++});
++
++// ── The owner ──────────────────────────────────────────────────────────────────────────────────────────────────────
++router.get('/mine', requireAuth, async (req, res) => {
++ try { noStore(res).json({ feeds: await feeds.mine(req.user) }); } catch (err) { send(res, err); }
++});
++router.post('/', requireAuth, async (req, res) => {
++ try { const r = await feeds.create(req.user, req.body || {}); noStore(res).status(201).json(r); } catch (err) { send(res, err); }
++});
++router.patch('/:id', requireAuth, async (req, res) => {
++ const feed = await ownFeed(req, res); if (!feed) return;
++ try { noStore(res).json({ feed: await feeds.update(feed, req.user, req.body || {}) }); } catch (err) { send(res, err); }
++});
++router.post('/:id/key', requireAuth, async (req, res) => {
++ const feed = await ownFeed(req, res); if (!feed) return;
++ try { noStore(res).json({ key: await feeds.rotateKey(feed) }); } catch (err) { send(res, err); }
++});
++router.delete('/:id', requireAuth, async (req, res) => {
++ const feed = await ownFeed(req, res); if (!feed) return;
++ try { await feeds.remove(feed); res.status(204).end(); } catch (err) { send(res, err); }
++});
++
++router.get('/:id', readLimit, async (req, res) => {
++ const feed = await feeds.get(req.params.id);
++ if (!feed) return res.status(404).json({ error: 'No such feed', code: 'feeds.not_found' });
++ try { noStore(res).json(await feeds.view(feed, { limit: req.query.limit })); } catch (err) { send(res, err); }
++});
++
++// ── Publishers ─────────────────────────────────────────────────────────────────────────────────────────────────────
++router.post('/:id/hello', publisher, async (req, res) => {
++ try { res.json(await feeds.hello(req.feed, req.body || {})); } catch (err) { send(res, err); }
++});
++router.post('/:id/events', publisher, async (req, res) => {
++ try { res.status(202).json({ events: await feeds.publish(req.feed, req.body) }); } catch (err) { send(res, err); }
++});
++router.post('/:id/hooks/claude-code', publisher, async (req, res) => {
++ try {
++ const events = feeds.fromClaudeCode(req.body);
++ if (events.length) await feeds.publish(req.feed, events);
++ } catch (err) { if (!(err instanceof feeds.FeedError)) console.warn('[Feeds] claude-code:', err.message); }
++ res.json({}); // an empty answer: Claude Code carries on as if the hook was not there
++});
++
++module.exports = router;
ddiff --git a/server/live-context.js b/server/live-context.js
iindex 9fb6122..e7bd359 100644
---- a/server/live-context.js
++++ b/server/live-context.js
@@@ -318,6 +318,12 @@ async function getLiveStreamsByUserId(userId) {
async function getStreamsByUserId(userId, limit = 50) {
return await db.all(`${STREAM_SELECT} WHERE s.user_id = ? ORDER BY s.created_at DESC LIMIT ?`, [userId, limit]);
}
++/** The live session on a channel slot (managed stream) right now, with the slot's slug, or null. */
++async function liveStreamForSlot(managedStreamId) {
++ if (!managedStreamId) return null;
++ return await db.get(`SELECT s.id, ms.slug FROM ctx_streams s LEFT JOIN ctx_managed_streams ms ON ms.id = s.managed_stream_id
++ WHERE s.managed_stream_id = ? AND s.is_live = 1 ORDER BY s.id DESC LIMIT 1`, [managedStreamId]) || null;
++}
async function getManagedStreamsByUserId(userId) {
return await db.all(`
SELECT ms.*,
@@@ -944,7 +950,7 @@ module.exports = {
authenticate, authFailureReason, upsertUser, invalidateUser, subjectFor,
getUserById, getUserByUsername, getUserBySubject, getUserByDisplayName, ensureUsers, ensureUserByUsername,
// streams + channels
-- getStreamById, ensureStream, refreshStream, latestStreamIdForUser, getLiveStreamsByUserId, getStreamsByUserId, getManagedStreamsByUserId,
++ getStreamById, ensureStream, refreshStream, latestStreamIdForUser, getLiveStreamsByUserId, getStreamsByUserId, getManagedStreamsByUserId, liveStreamForSlot,
getChannelById, getChannelByUserId, ensureChannelForUser, createChannel,
emoteSources, setChannelEmoteSources,
// policy
ddiff --git a/server/web/feed-pages.js b/server/web/feed-pages.js
nnew file mode 100644
iindex 0000000..cf79da5
---- /dev/null
++++ b/server/web/feed-pages.js
@@@ -0,0 +1,175 @@
++'use strict';
++/**
++ * Feeds on openvibe.chat (server/feeds/feeds.js, docs/platform.md):
++ *
++ * GET /embed/feed/:id a feed's card for any site's iframe (public/web/feed-view.js; frame-ancestors any site),
++ * OpenVibe.Live's channel chat among them; ?theme=light|dark, else the visitor's own
++ * GET /feeds a person's feeds, and a form to make one
++ * GET /feeds/:id where it shows, what viewers see, the delay; how to publish (Claude Code's hooks, the
++ * API), the key (made again on request), the embed tag, a live preview
++ * POST /feeds · /feeds/:id/settings · /feeds/:id/key · /feeds/:id/delete forms; making a feed or a key answers with
++ * the page that shows the key, once (it is never stored)
++ */
++const feeds = require('../feeds/feeds');
++const ctx = require('../live-context');
++const db = require('../db/database');
++const { esc, asset } = require('./layout');
++
++const THEMES = {
++ light: '--oc-bg:#ffffff;--oc-panel:#ffffff;--oc-line:#e3e7ef;--oc-text:#101828;--oc-muted:#5b6577;--oc-accent:#2563eb;--oc-ok:#16a34a;color-scheme:light',
++ dark: '--oc-bg:#0b1020;--oc-panel:#121a2c;--oc-line:#223049;--oc-text:#e8edf6;--oc-muted:#98a6bd;--oc-accent:#60a5fa;--oc-ok:#22c55e;color-scheme:dark',
++};
++
++function embedHtml(feed, theme) {
++ const pick = THEMES[theme] ? `:root{${THEMES[theme]}}` : `:root{${THEMES.light}}@media (prefers-color-scheme: dark){:root{${THEMES.dark}}}`;
++ return `<!DOCTYPE html>
++<html lang="en">
++<head>
++<meta charset="utf-8">
++<meta name="viewport" content="width=device-width, initial-scale=1">
++<title>${esc(feed.title)}</title>
++<meta name="robots" content="noindex, nofollow">
++<link rel="stylesheet" href="${asset('feed-view.css')}">
++<style>${pick}html,body{margin:0;background:transparent}body{color:var(--oc-text);font:15px/1.45 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}</style>
++<script src="${asset('feed-view.js')}" defer></script>
++</head>
++<body><div class="fv" data-feed="${esc(feed.id)}"></div></body>
++</html>`;
++}
++
++const hookFor = (base, id) => ({ type: 'http', url: `${base}/api/chat/feeds/${id}/hooks/claude-code`, headers: { Authorization: 'Bearer $OPENVIBE_FEED_KEY' }, allowedEnvVars: ['OPENVIBE_FEED_KEY'], timeout: 5 });
++/** The block for .claude/settings.json: the events a viewer may see, sent as they happen; never blocks Claude Code. */
++function claudeCodeSnippet(base, id) {
++ const h = [hookFor(base, id)];
++ return JSON.stringify({ hooks: { SessionStart: [{ hooks: h }], UserPromptSubmit: [{ hooks: h }], PostToolUse: [{ matcher: '*', hooks: h }], Stop: [{ hooks: h }], SessionEnd: [{ hooks: h }] } }, null, 2);
++}
++const curlSnippet = (base, id) => `curl -X POST ${base}/api/chat/feeds/${id}/events \\
++ -H "Authorization: Bearer $OPENVIBE_FEED_KEY" -H "Content-Type: application/json" \\
++ -d '{"eventType": "prompt", "text": "Add a dark mode to the settings page"}'`;
++const iframeSnippet = (base, id) => `<iframe src="${base}/embed/feed/${id}" title="Vibe coding, live" style="width:100%;height:320px;border:0" loading="lazy"></iframe>`;
++
++function mountFeedPages(router, { page, needUser, sameSite, formLimit, notice, config }) {
++ const base = config.web.baseUrl;
++
++ router.get('/embed/feed/:id', async (req, res) => {
++ const feed = await feeds.get(req.params.id);
++ if (!feed) return res.status(404).set('Cache-Control', 'no-store').type('html').send('<!DOCTYPE html><meta charset="utf-8"><title>Feed not found</title><p style="font-family:system-ui;padding:16px">This feed is not available.</p>');
++ res.set('Content-Security-Policy', [
++ "default-src 'self'", "script-src 'self'", "style-src 'self' 'unsafe-inline'", "img-src 'self' data:",
++ `connect-src 'self' ${base.replace(/^http/, 'ws')}`, 'frame-ancestors *', "base-uri 'self'", "form-action 'none'", "object-src 'none'",
++ ].join('; ')).set('Cache-Control', 'no-store').set('X-Robots-Tag', 'noindex').type('html').send(embedHtml(feed, String(req.query.theme || '')));
++ });
++
++ async function places(user) {
++ const roomList = await db.all('SELECT id, slug, name FROM rooms WHERE owner_id = ? AND archived_at IS NULL AND assistant = 0 AND server_id IS NULL ORDER BY lower(name) LIMIT 100', [user.id]);
++ const slots = await ctx.getManagedStreamsByUserId(user.id).catch(() => []);
++ return { roomList, slots };
++ }
++ function placeFields({ roomList, slots }, feed = {}) {
++ const opt = (v, label, on) => `<option value="${esc(v)}"${on ? ' selected' : ''}>${esc(label)}</option>`;
++ return `<label>Show it in a room <select name="room">${opt('', 'No room', !feed.room_id)}${roomList.map((r) => opt(r.id, r.name, Number(feed.room_id) === Number(r.id))).join('')}</select></label>
++<label>Show it on your Live channel while live <select name="slot">${opt('', 'Not on Live', !feed.managed_stream_id)}${slots.map((s) => opt(s.id, s.title || s.slug || `Slot ${s.id}`, Number(feed.managed_stream_id) === Number(s.id))).join('')}</select></label>`;
++ }
++ function keyPanel(feed, key) {
++ return `${notice('ok', 'Here is the feed\'s key. Copy it now: it is shown only this once.')}
++<pre class="ib-snippet fd-key"><code>${esc(key)}</code></pre>
++<p class="oc-muted">Put it where your tools read it, for example <code>export OPENVIBE_FEED_KEY=${esc(key.slice(0, 8))}…</code> in your shell profile. Anyone with the key can post to this feed; make a new one any time.</p>
++<p><a class="oc-button" href="/feeds/${esc(feed.id)}">Set up publishing</a></p>`;
++ }
++
++ router.get('/feeds', async (req, res) => {
++ const actor = await needUser(req, res); if (!actor) return;
++ const mine = await feeds.mine(actor.user);
++ const list = mine.length
++ ? `<ul class="ib-list">${mine.map((f) => `<li><a href="/feeds/${esc(f.id)}"><span class="ib-dot fd-dot${f.settings.enabled && !f.settings.paused ? ' fd-on' : ''}"></span><b>${esc(f.title)}</b>
++<span class="oc-muted">${f.last_event_at ? `last step ${esc(f.last_event_at.slice(0, 16).replace('T', ' '))}` : 'nothing published yet'}${f.room_id ? ' · in a room' : ''}${f.managed_stream_id ? ' · on Live' : ''}</span></a></li>`).join('')}</ul>`
++ : '<p class="oc-muted">No feeds yet.</p>';
++ await page(req, res, 200, {
++ actor, title: 'Feeds', path: '/feeds', active: 'rooms', robots: 'noindex, nofollow',
++ body: `<h1>Feeds</h1>
++<p class="oc-muted">Show what you are building, live. Claude Code, Copilot or any tool sends each step (a prompt, a command, a file edited, the answer) and your viewers follow along in a card: in your room, on your Live channel while you stream, or embedded on your own site. You decide what they see; secrets and home folders are wiped before anything is shown.</p>
++${req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : ''}
++${list}
++<h2>Make a feed</h2>
++<form class="oc-form" method="post" action="/feeds">
++<label>Title <input name="title" required minlength="2" maxlength="80" value="Vibe coding"></label>
++${placeFields(await places(actor.user))}
++<p><button type="submit">Make the feed</button></p></form>`,
++ });
++ });
++
++ router.post('/feeds', formLimit, sameSite, async (req, res) => {
++ const actor = await needUser(req, res); if (!actor) return;
++ const b = req.body || {};
++ try {
++ const { feed, key } = await feeds.create(actor.user, { title: b.title, room: b.room || null, slot: b.slot || null });
++ await page(req, res, 201, { actor, title: `${feed.title}: key`, path: `/feeds/${feed.id}`, active: 'rooms', robots: 'noindex, nofollow', body: `<p><a href="/feeds">&larr; Feeds</a></p><h1>${esc(feed.title)}</h1>${keyPanel(feed, key)}` });
++ } catch (err) { res.redirect(303, `/feeds?error=${encodeURIComponent(err.message || 'That did not work')}`); }
++ });
++
++ async function ownFeed(req, res) {
++ const actor = await needUser(req, res); if (!actor) return null;
++ const feed = await feeds.get(req.params.id);
++ if (!feed || feed.owner_id !== Number(actor.user.id)) { await page(req, res, 404, { actor, title: 'Feed not found', path: '/feeds', robots: 'noindex, nofollow', body: '<h1>Feed not found</h1><p><a href="/feeds">Your feeds</a></p>' }); return null; }
++ return { actor, feed };
++ }
++
++ router.get('/feeds/:id', async (req, res) => {
++ const a = await ownFeed(req, res); if (!a) return;
++ const { actor, feed } = a;
++ const s = feed.settings;
++ const check = (name, label) => `<label class="oc-check"><input type="checkbox" name="${name}" value="1"${s[name] ? ' checked' : ''}> ${label}</label>`;
++ await page(req, res, 200, {
++ actor, title: `${feed.title}: feed`, path: `/feeds/${feed.id}`, active: 'rooms', robots: 'noindex, nofollow', feedView: true,
++ body: `<p><a href="/feeds">&larr; Feeds</a></p><h1>${esc(feed.title)}</h1>
++${req.query.saved ? notice('ok', 'Saved.') : ''}${req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : ''}
++<div class="fv fd-preview" data-feed="${esc(feed.id)}"></div>
++<h2>Publish from Claude Code</h2>
++<p>Add this to <code>.claude/settings.json</code> in a project (or <code>~/.claude/settings.json</code> for every project), and set <code>OPENVIBE_FEED_KEY</code> in the shell you start Claude Code from. Each prompt, tool used and answer becomes a step; nothing is read from your files or transcript.</p>
++<pre class="ib-snippet"><code>${esc(claudeCodeSnippet(base, feed.id))}</code></pre>
++<h2>Publish from anything else</h2>
++<p>Send events to the API with the key; <code>eventType</code> is one of ${feeds.TYPES.map((t) => `<code>${t}</code>`).join(', ')} (up to 50 at once as <code>{ "events": [...] }</code>). The OpenVibe Copilot companion speaks the same events.</p>
++<pre class="ib-snippet"><code>${esc(curlSnippet(base, feed.id))}</code></pre>
++<p class="oc-muted">Key ending in <code>${esc(feed.key_hint || '????')}</code>.</p>
++<form method="post" action="/feeds/${esc(feed.id)}/key" class="oc-inline"><button type="submit">Make a new key</button></form> <span class="oc-muted">(the old one stops working)</span>
++<h2>Show it on your site</h2>
++<pre class="ib-snippet"><code>${esc(iframeSnippet(base, feed.id))}</code></pre>
++<h2>Where it shows and what viewers see</h2>
++<form class="oc-form" method="post" action="/feeds/${esc(feed.id)}/settings">
++<label>Title <input name="title" required minlength="2" maxlength="80" value="${esc(feed.title)}"></label>
++${placeFields(await places(actor.user), feed)}
++${check('enabled', 'Viewers see this feed')}
++${check('paused', 'Paused (steps are kept, nobody sees them until you unpause)')}
++${check('show_prompts', 'Prompts')}${check('show_responses', 'Answers')}${check('show_tool_calls', 'Tools used')}${check('show_tool_arguments', 'What the tools were given (commands, searches)')}
++${check('show_file_events', 'Files changed')}${check('redact_file_paths', 'File names only, not their folders')}${check('show_thinking', 'Thinking, when the tool sends it')}
++<label>Detail <select name="viewer_depth">${[['headline', 'Headlines'], ['standard', 'Standard'], ['deep', 'More text']].map(([v, l]) => `<option value="${v}"${s.viewer_depth === v ? ' selected' : ''}>${l}</option>`).join('')}</select></label>
++<label>Delay before viewers see a step <select name="delay_ms">${[[0, 'None'], [10000, '10 seconds'], [30000, '30 seconds'], [60000, '1 minute'], [120000, '2 minutes']].map(([v, l]) => `<option value="${v}"${s.delay_ms === v ? ' selected' : ''}>${l}</option>`).join('')}</select></label>
++<p><button type="submit">Save</button></p></form>
++<form method="post" action="/feeds/${esc(feed.id)}/delete" class="oc-inline"><button type="submit" class="oc-link oc-danger">Delete this feed and its history</button></form>`,
++ });
++ });
++
++ router.post('/feeds/:id/settings', formLimit, sameSite, async (req, res) => {
++ const a = await ownFeed(req, res); if (!a) return;
++ const b = req.body || {};
++ const flags = ['enabled', 'paused', 'show_prompts', 'show_responses', 'show_tool_calls', 'show_tool_arguments', 'show_file_events', 'redact_file_paths', 'show_thinking'];
++ const settings = { viewer_depth: b.viewer_depth, delay_ms: b.delay_ms };
++ for (const f of flags) settings[f] = b[f] === '1' ? 1 : 0;
++ try {
++ await feeds.update(a.feed, a.actor.user, { title: b.title, room: b.room || null, slot: b.slot || null, settings });
++ res.redirect(303, `/feeds/${a.feed.id}?saved=1`);
++ } catch (err) { res.redirect(303, `/feeds/${a.feed.id}?error=${encodeURIComponent(err.message || 'That did not save')}`); }
++ });
++ router.post('/feeds/:id/key', formLimit, sameSite, async (req, res) => {
++ const a = await ownFeed(req, res); if (!a) return;
++ const key = await feeds.rotateKey(a.feed);
++ await page(req, res, 200, { actor: a.actor, title: `${a.feed.title}: key`, path: `/feeds/${a.feed.id}`, active: 'rooms', robots: 'noindex, nofollow', body: `<p><a href="/feeds">&larr; Feeds</a></p><h1>${esc(a.feed.title)}</h1>${keyPanel(a.feed, key)}` });
++ });
++ router.post('/feeds/:id/delete', formLimit, sameSite, async (req, res) => {
++ const a = await ownFeed(req, res); if (!a) return;
++ await feeds.remove(a.feed);
++ res.redirect(303, '/feeds');
++ });
++}
++
++module.exports = { mountFeedPages, embedHtml, claudeCodeSnippet };
ddiff --git a/server/web/layout.js b/server/web/layout.js
iindex f7a65ae..70898cc 100644
---- a/server/web/layout.js
++++ b/server/web/layout.js
@@@ -97,7 +97,8 @@ function contentSecurityPolicy(nonce) {
* prefs (the person's chat preferences: timestamps, compact, font_scale, show_badges), counts (unread
* rooms/messages for the navigation), script / callScript / ringScript (public/web/chat.js, call.js, ring.js: a call
* coming in, on every page of a signed-in person while calls are on), app (Vibe's full-window app, server/web/vibe-pages.js:
-- * vibe.css + vibe-md.js + vibe.js, and no footer), appScript (the app's own script instead of vibe.js: inbox.js)
++ * vibe.css + vibe-md.js + vibe.js, and no footer), appScript (the app's own script instead of vibe.js: inbox.js), feedView
++ * (a feed's card on the page: feed-view.css + feed-view.js, server/feeds/)
*/
function renderPage(o) {
const { config } = o;
@@@ -129,6 +130,7 @@ ${INSTALL_LINKS}
<link rel="stylesheet" href="${ovServe.url('items.css')}">
${o.showcase ? `<link rel="stylesheet" href="${ovServe.url('showcase.css')}">` : ''}
${o.app ? `<link rel="stylesheet" href="${asset('vibe.css')}">` : ''}
++${o.feedView ? `<link rel="stylesheet" href="${asset('feed-view.css')}">\n<script src="${asset('feed-view.js')}" defer></script>` : ''}
<script src="${ovServe.url('theme-loader.js')}" defer></script>
<script src="${ovServe.url('navbar.js')}" defer></script>
<script src="${ovServe.url('footer.js')}" defer></script>
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex 0620032..a9bc1fc 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -619,7 +619,8 @@ ${mine.length ? `<h2>Your rooms</h2><ul class="oc-convs">${mine.map(roomCard).jo
<ul class="oc-goto"><li><a href="/">Global chat</a>: everyone on OpenVibe, readable without an account</li>
<li><a href="/servers">Servers</a>: channels, voice, roles and invite links, Discord-style</li>
<li><a href="/messages">Messages</a>: direct messages with anyone on OpenVibe</li></ul></div>`}
--${create}`,
++${create}
++<p class="oc-muted">Building something? A <a href="/feeds">feed</a> shows your coding session live in a room, on your Live channel or on your site.</p>`,
});
});
@@@ -687,9 +688,11 @@ ${people}
const isCall = room.kind === 'call';
const callReady = isCall && !!require('../config').calls.enabled;
const ais = aiMembers.enabled() ? (await aiMembers.list(room)).filter((a) => Number(a.enabled)) : [];
++ // A feed the owner shows here (server/feeds/): its card above the messages.
++ const feed = await require('../feeds/feeds').forRoom(room.id);
const aiHere = ais.length ? `<p class="oc-ai-here">${ais.map((a) => `<span class="oc-badge oc-ai">AI</span> <strong>${esc(a.name)}</strong> <span class="oc-muted oc-ai-model">(${esc(aiMembers.modelOf(a))})</span>`).join(' · ')} <span class="oc-muted">· write ${ais.map((a) => `<code>@${esc(a.handle)}</code>`).join(' or ')} to ask${ais.length > 1 ? ' one of them' : ''} something</span></p>` : '';
await page(req, res, 200, {
-- actor, title: room.name, path: `/r/${room.slug}`, active: 'rooms', robots: room.visibility === 'public' ? 'index, follow' : 'noindex, nofollow', script: true, callScript: callReady,
++ actor, title: room.name, path: `/r/${room.slug}`, active: 'rooms', robots: room.visibility === 'public' ? 'index, follow' : 'noindex, nofollow', script: true, callScript: callReady, feedView: !!feed,
description: room.topic || `${room.name}, a ${isCall ? 'call' : room.kind === 'system' ? 'announcements' : 'chat'} room on OpenVibe.`,
page: { view: 'room', room: room.slug, kind: room.kind, pics: pictures.enabled() ? pictures.publicUrl() : null, ai: ais.map((a) => a.handle), latest: msgs.length ? msgs[msgs.length - 1].id : 0, me: me ? me.id : null, moderate: can.moderate, post: can.post, role: can.role,
...(callReady ? { call: { channel: `room-${room.slug}`, join: can.join, talk: can.talk } } : {}) },
@@@ -698,6 +701,7 @@ ${people}
<p class="oc-muted">${room.topic ? `${esc(room.topic)} · ` : ''}${(await rooms.publicRoom(room)).members} members${room.slow_seconds ? ` · slow mode ${room.slow_seconds}s` : ''}${yourRole}${manage} <span class="oc-live" id="oc-live" hidden>● live</span></p>
${aiHere}${error}
${isCall ? callPanel(room, can, me) : ''}
++${feed ? `<div class="fv oc-room-fv" data-feed="${esc(feed.id)}"></div>` : ''}
<ol class="oc-feed" id="oc-feed" aria-live="polite" aria-label="Messages">${msgs.map((m) => roomMessage(m, can, room.slug, me)).join('\n') || `<li class="oc-empty oc-muted">${room.kind === 'system' ? 'No announcements yet.' : 'No messages yet. Say hello.'}</li>`}</ol>
${composer}${tools}`,
});
@@@ -891,6 +895,7 @@ ${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('./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 });
router.get('/updates', async (req, res) => await page(req, res, 200, {
ddiff --git a/test/feeds.test.js b/test/feeds.test.js
nnew file mode 100644
iindex 0000000..200b07f
---- /dev/null
++++ b/test/feeds.test.js
@@@ -0,0 +1,181 @@
++'use strict';
++/**
++ * Feeds (server/feeds/, docs/platform.md step 2): a person makes a feed and gets its key once; a publisher posts events
++ * with the key (one, a batch, Live's envelopes, Claude Code's hook JSON as is); secrets and home folders are wiped on the
++ * way in; viewers get what the feed's settings let them see, after its delay, over HTTP and the socket; a feed on a Live
++ * channel slot reaches that slot's live chat as Live's vibe-coding frame; rooms, the embed and the pages show it.
++ */
++const assert = require('assert');
++const { ids } = require('openvibe-contracts');
++const { boot, suite } = require('./helpers');
++let feeds;
++
++const t = suite('feeds');
++const SITE = 'https://openvibe.chat';
++let h, ann, bob, feed, key, streamId;
++const api = (method, path, u, body, headers) => h.http(method, `/api/chat/feeds${path}`, { token: u && u.token, body, headers });
++const pub = (path, body, k = key) => fetch(`${h.base}/api/chat/feeds/${feed.id}${path}`, {
++ method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${k}` }, body: JSON.stringify(body),
++}).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
++const view = async (q = '') => (await fetch(`${h.base}/api/chat/feeds/${feed.id}${q}`)).json();
++const patch = (settings, extra = {}) => api('PATCH', `/${feed.id}`, ann, { settings, ...extra });
++
++t('boot', async () => {
++ h = await boot({ env: { CHAT_WEB_URL: SITE } });
++ feeds = require('../server/feeds/feeds');
++ ann = h.addUser('ann', { subject: ids.newId('user') });
++ bob = h.addUser('bob', { subject: ids.newId('user') });
++ for (const u of [ann, bob]) await h.ctx.upsertUser(h.live.users.get(u.id));
++ const channel = h.addChannel(ann.id);
++ streamId = h.addStream(ann.id, channel, { managed: { id: 77, title: 'Camp code', slug: 'camp-code' } });
++ await h.ctx.ensureStream(streamId);
++});
++
++t('a person makes a feed and gets its key once; others cannot touch it', async () => {
++ const r = await api('POST', '', ann, { title: 'Building Pixel Lamp' });
++ assert.strictEqual(r.status, 201, r.text);
++ feed = r.body.feed; key = r.body.key;
++ assert.ok(/^fed_[0-9A-HJKMNP-TV-Z]{26}$/.test(feed.id) && /^ovf_[A-Za-z0-9_-]{32}$/.test(key));
++ assert.strictEqual(feed.key_hint, key.slice(-4));
++ assert.ok(!('key_hash' in feed), 'the hash is never shown');
++ assert.deepStrictEqual((await api('GET', '/mine', ann)).body.feeds.map((f) => f.id), [feed.id]);
++ assert.strictEqual((await api('PATCH', `/${feed.id}`, bob, { title: 'Mine' })).status, 404);
++ assert.strictEqual((await api('PATCH', `/${feed.id}`, ann, { slot: 77 })).status, 200, 'her own slot');
++ assert.strictEqual((await api('POST', '', bob, { title: 'Sneaky', slot: 77 })).status, 403, 'not his slot');
++});
++
++t('publishing needs the key; secrets and home folders are wiped on the way in', async () => {
++ assert.strictEqual((await pub('/events', { eventType: 'prompt', text: 'hi' }, `ovf_${'x'.repeat(32)}`)).status, 401);
++ const r = await pub('/events', { eventType: 'prompt', text: 'Use sk-ant-api03-abcdefghijklmnopqrstuvwx and PASSWORD=hunter22 in /home/ann/pixel-lamp/.env' });
++ assert.strictEqual(r.status, 202, JSON.stringify(r.body));
++ assert.strictEqual(r.body.events[0].delivered, true);
++ const stored = await h.db.get('SELECT payload FROM feed_events WHERE feed_id = ?', [feed.id]);
++ const text = JSON.stringify(stored.payload);
++ assert.ok(!text.includes('sk-ant') && !text.includes('hunter22') && !text.includes('/home/ann'), text);
++ assert.ok(text.includes('[hidden]') && text.includes('~/pixel-lamp'));
++});
++
++t('a batch, Live\'s envelopes, and a repeated event delivered once; viewers see what the settings allow', async () => {
++ const r = await pub('/events', { events: [
++ { type: 'vibe-coding.event', event: { eventId: 'e-think', eventType: 'thinking', thinking: { text: 'Maybe the lamp needs a dimmer' } } },
++ { eventId: 'e-tool', eventType: 'tool.call', summary: 'Ran npm test in server/lamp', tool: { name: 'Bash', argumentsPreview: 'npm test' } },
++ { eventId: 'e-file', eventType: 'file.change', file: { relativePath: 'server/lamp/dimmer.js' } },
++ { eventId: 'e-file', eventType: 'file.change', file: { relativePath: 'server/lamp/dimmer.js' } },
++ { eventId: 'e-done', eventType: 'response', text: 'Added a dimmer.' },
++ ] });
++ assert.deepStrictEqual(r.body.events.map((e) => e.delivered), [false, true, true, false, true], 'thinking is off by default; the repeat is not delivered again');
++ const v = await view();
++ const byId = Object.fromEntries(v.events.map((e) => [e.eventId, e]));
++ assert.ok(!byId['e-think'], 'thinking hidden');
++ assert.strictEqual(byId['e-tool'].tool.argumentsPreview, null, 'what tools were given is off by default');
++ assert.strictEqual(byId['e-file'].file.relativePath, 'dimmer.js', 'file names only');
++ assert.strictEqual(byId['e-done'].response.text, 'Added a dimmer.');
++ assert.strictEqual(v.feed.enabled, true);
++ await patch({ show_prompts: 0, redact_file_paths: 0 });
++ const v2 = await view();
++ assert.ok(!v2.events.some((e) => e.eventType === 'prompt'), 'turning prompts off hides them in the history too');
++ assert.strictEqual(v2.events.find((e) => e.eventId === 'e-file').file.relativePath, 'server/lamp/dimmer.js');
++ await patch({ show_prompts: 1, redact_file_paths: 1 });
++});
++
++t('Claude Code\'s hooks, as they are: prompt, tools, files, the answer; never blocks it', async () => {
++ const base = { session_id: 'abc-123', cwd: '/home/ann/pixel-lamp', transcript_path: '/home/ann/.claude/x.jsonl' };
++ assert.deepStrictEqual((await pub('/hooks/claude-code', { ...base, hook_event_name: 'UserPromptSubmit', prompt: 'Make the lamp glow purple', prompt_id: 'p1' })).body, {});
++ await pub('/hooks/claude-code', { ...base, hook_event_name: 'PreToolUse', tool_name: 'Edit', tool_input: { file_path: '/home/ann/pixel-lamp/web/lamp.css' }, tool_use_id: 'tu0' });
++ await pub('/hooks/claude-code', { ...base, hook_event_name: 'PostToolUse', tool_name: 'Edit', tool_input: { file_path: '/home/ann/pixel-lamp/web/lamp.css', old_string: 'a', new_string: 'b' }, tool_response: { ok: true }, tool_use_id: 'tu1' });
++ await pub('/hooks/claude-code', { ...base, hook_event_name: 'Stop', last_assistant_message: 'The lamp glows purple now.' });
++ const mine = (await view('?limit=50')).events.filter((e) => e.publisher && e.publisher.integrationId === 'claude-code');
++ assert.deepStrictEqual(mine.map((e) => e.eventType), ['prompt', 'file.change', 'response'], 'PreToolUse is not shown; an edit is a file event');
++ assert.strictEqual(mine[0].prompt.text, 'Make the lamp glow purple');
++ assert.deepStrictEqual([mine[1].summary, mine[1].file.relativePath], ['Editing lamp.css', 'lamp.css'], 'the folder is not shown');
++ assert.strictEqual(mine[2].response.text, 'The lamp glows purple now.');
++ await pub('/hooks/claude-code', { ...base, hook_event_name: 'PostToolUse', tool_name: 'Bash', tool_input: { command: 'cat /home/ann/pixel-lamp/.env' }, tool_use_id: 'tu2' });
++ const ran = (await view('?limit=50')).events.pop();
++ assert.deepStrictEqual([ran.summary, ran.tool.argumentsPreview], ['Ran a command', null], 'what a command was is off by default');
++ assert.strictEqual((await pub('/hooks/claude-code', { hook_event_name: 'UserPromptSubmit', prompt: 'x' }, `ovf_${'y'.repeat(32)}`)).status, 401);
++});
++
++t('the delay is the server\'s: the history waits for it too', async () => {
++ await patch({ delay_ms: 60000 });
++ await pub('/events', { eventId: 'e-late', eventType: 'response', text: 'Not yet' });
++ assert.ok(!(await view()).events.some((e) => e.eventId === 'e-late'));
++ await patch({ delay_ms: 0 });
++ assert.ok((await view()).events.some((e) => e.eventId === 'e-late'));
++});
++
++t('the socket: watchers and the Live slot\'s chat hear new steps', async () => {
++ const watcher = await h.ws({});
++ watcher.sendJson({ type: 'feed_watch', feed: feed.id });
++ await watcher.next((m) => m.type === 'feed_watching');
++ const chat = await h.ws({ token: bob.token, stream: streamId });
++ await chat.next((m) => m.type === 'users-list');
++ await pub('/events', { eventId: 'e-live', eventType: 'tool.call', summary: 'Ran the lamp tests', tool: { name: 'Bash' } });
++ const got = await watcher.next((m) => m.type === 'feed_event' && m.event.eventId === 'e-live');
++ assert.strictEqual(got.feed, feed.id);
++ const frame = await chat.next((m) => m.type === 'vibe-coding' && m.event.eventId === 'e-live');
++ assert.deepStrictEqual([frame.managed_stream_id, frame.slot_slug], [77, 'camp-code']);
++ watcher.close(); chat.close();
++});
++
++t('Live\'s chat finds a channel\'s feeds; any site may read a feed (no cookies)', async () => {
++ const r = await fetch(`${h.base}/api/chat/feeds?channel=ann`, { headers: { origin: 'https://openvibe.live' } });
++ const list = (await r.json()).feeds;
++ assert.deepStrictEqual(list.map((f) => [f.id, f.managed_stream_id]), [[feed.id, 77]]);
++ const other = await fetch(`${h.base}/api/chat/feeds/${feed.id}`, { headers: { origin: 'https://elsewhere.example' } });
++ assert.strictEqual(other.status, 200);
++ assert.strictEqual(other.headers.get('access-control-allow-origin'), 'https://elsewhere.example');
++ assert.strictEqual(other.headers.get('access-control-allow-credentials'), null);
++ await patch({ paused: 1 });
++ assert.deepStrictEqual((await (await fetch(`${h.base}/api/chat/feeds?slot=77`)).json()).feeds, [], 'a paused feed is not listed');
++ assert.deepStrictEqual((await view()).events, [], 'nor shown');
++ await patch({ paused: 0 });
++});
++
++t('the pages: making a feed shows its key once, the feed page has the hooks, the embed, rooms show the card', async () => {
++ const page = async (path, u, opts = {}) => { const r = await fetch(`${h.base}${path}`, { redirect: 'manual', ...opts, headers: { cookie: `ov_token=${u.token}`, ...(opts.headers || {}) } }); return { status: r.status, text: await r.text(), headers: r.headers }; };
++ assert.ok((await page('/feeds', ann)).text.includes(`href="/feeds/${feed.id}"`));
++ const made = await page('/feeds', ann, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', origin: SITE }, body: 'title=Second+feed' });
++ assert.strictEqual(made.status, 201, made.text.slice(0, 300));
++ assert.ok(/ovf_[A-Za-z0-9_-]{32}/.test(made.text), 'the key, once');
++ const settings = await page(`/feeds/${feed.id}`, ann);
++ assert.ok(settings.text.includes('/hooks/claude-code') && settings.text.includes('OPENVIBE_FEED_KEY') && !/ovf_[A-Za-z0-9_-]{32}/.test(settings.text), 'the hooks, not the key');
++ assert.strictEqual((await page(`/feeds/${feed.id}`, bob)).status, 404);
++ const embed = await fetch(`${h.base}/embed/feed/${feed.id}`);
++ assert.ok(/frame-ancestors \*/.test(embed.headers.get('content-security-policy')) && (await embed.text()).includes(`data-feed="${feed.id}"`));
++ const room = (await h.http('POST', '/api/chat/rooms', { token: ann.token, body: { name: 'Pixel Lamp dev' } })).body.room;
++ await patch({}, { room: room.slug });
++ assert.ok((await page(`/r/${room.slug}`, bob)).text.includes(`class="fv oc-room-fv" data-feed="${feed.id}"`));
++ assert.strictEqual((await api('PATCH', `/${feed.id}`, bob, { room: room.slug })).status, 404);
++});
++
++t('Live\'s feed history comes over once: a feed per slot, its newest events, scrubbed, verified', async () => {
++ const fs = require('fs'); const os = require('os'); const path = require('path');
++ const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'feeds-')), 'live.jsonl');
++ const rows = [
++ { kind: 'slot', managed_stream_id: 77, user_id: ann.id, title: 'Camp code', settings: { enabled: 0, widget_title: 'Night Bus Refactor', show_thinking: 1 } },
++ { kind: 'session', managed_stream_id: 77, session_key: 'copilot-1', publisher: { integrationId: 'github-copilot', integrationLabel: 'GitHub Copilot' }, status: 'ended', created_at: '2026-04-21 23:10:00' },
++ { kind: 'event', managed_stream_id: 77, session_key: 'copilot-1', event_id: 'old-1', seq: 1, type: 'prompt', summary: 'Refactor the bus', payload: JSON.stringify({ eventId: 'old-1', eventType: 'prompt', prompt: { text: 'Refactor with ghp_abcdefghijklmnopqrstuvwxyz0123' } }), created_at: '2026-04-21 23:10:01' },
++ { kind: 'event', managed_stream_id: 77, session_key: 'copilot-1', event_id: 'old-2', seq: 2, type: 'response', summary: 'Done', payload: JSON.stringify({ eventId: 'old-2', eventType: 'response', response: { text: 'Done' } }), created_at: '2026-04-21 23:11:00' },
++ ];
++ fs.writeFileSync(file, rows.map((r) => JSON.stringify(r)).join('\n'));
++ const { main } = require('../scripts/import-live-feeds');
++ const said = [];
++ assert.strictEqual(await main(['--file', file], (m) => said.push(m)), 0);
++ assert.ok(said.some((m) => m.includes('dry run')) && !(await h.db.get("SELECT 1 FROM feeds WHERE legacy_source = 'live:managed_stream:77'")));
++ assert.strictEqual(await main(['--file', file, '--apply'], (m) => said.push(m)), 0, said.join('\n'));
++ const copied = await h.db.get("SELECT * FROM feeds WHERE legacy_source = 'live:managed_stream:77'");
++ assert.deepStrictEqual([copied.title, Number(copied.owner_id), Number(copied.managed_stream_id), copied.key_hash], ['Night Bus Refactor', ann.id, 77, null]);
++ const events = await h.db.all('SELECT event_id, payload FROM feed_events WHERE feed_id = ? ORDER BY id', [copied.id]);
++ assert.deepStrictEqual(events.map((e) => e.event_id), ['old-1', 'old-2']);
++ assert.ok(!JSON.stringify(events[0].payload).includes('ghp_'), 'old events are scrubbed too');
++ assert.strictEqual(await main(['--file', file, '--apply'], () => {}), 0, 'a second run adds nothing');
++ assert.strictEqual((await h.db.get("SELECT count(*)::int AS n FROM feeds WHERE legacy_source = 'live:managed_stream:77'")).n, 1);
++});
++
++t('deleting a feed deletes what was published to it', async () => {
++ assert.strictEqual((await api('DELETE', `/${feed.id}`, ann)).status, 204);
++ assert.strictEqual((await h.db.get('SELECT count(*)::int AS n FROM feed_events WHERE feed_id = ?', [feed.id])).n, 0);
++ assert.strictEqual((await fetch(`${h.base}/api/chat/feeds/${feed.id}`)).status, 404);
++});
++
++t.run(async () => { if (h && h.close) await h.close(); });
ddiff --git a/test/pg-schema.test.js b/test/pg-schema.test.js
iindex 56e956f..5a2139f 100644
---- a/test/pg-schema.test.js
++++ b/test/pg-schema.test.js
@@@ -27,7 +27,7 @@ const TABLES = [
'channel_moderators', 'channel_moderation_settings', 'emotes', 'user_tags', 'chat_ai_summaries', 'chat_timeline_events',
'ctx_users', 'ctx_streams', 'ctx_managed_streams', 'ctx_channels', 'ctx_sync', 'events_outbox', 'service_outbox',
'chat_ingress_applied', 'audio_requests', 'import_hold', 'import_runs', 'chat_meta', 'deploy_releases',
-- 'chat_event_inbox', 'calls', 'rooms', 'room_members', 'room_messages', 'room_attachments', 'room_ai_members', 'room_ai_usage', 'vibe_usage', 'inboxes', 'inbox_members', 'inbox_conversations', 'inbox_messages', 'inbox_ai_usage', 'chat_servers', 'chat_server_members', 'chat_server_invites', 'token_revocations', 'chat_control_log',
++ 'chat_event_inbox', 'calls', 'rooms', 'room_members', 'room_messages', 'room_attachments', 'room_ai_members', 'room_ai_usage', 'vibe_usage', 'inboxes', 'inbox_members', 'inbox_conversations', 'inbox_messages', 'inbox_ai_usage', 'feeds', 'feed_sessions', 'feed_events', 'chat_servers', 'chat_server_members', 'chat_server_invites', 'token_revocations', 'chat_control_log',
'network_blocks', 'account_data_events', 'ticket_conversations', 'ticket_messages', 'telemetry_samples',
];
const INDEXES = [