openvibers/openvibe.chat Commits

Workspace rooms take alerts: signed hooks (a Watch webhook action, an OpenVibe event, a line) become messages from Vibe and can queue a task for the hook's maker (#167)

OP OpenVibers committed 19 days ago · 73d6f040b8 · 8 files, +338 −1

Changes

ddiff --git a/README.md b/README.md
iindex 47a691d..5ead91c 100644
---- a/README.md
++++ b/README.md
@@@ -405,6 +405,14 @@ member on their first visit, and nobody else reads it, whatever role they once h
- **Results** of every task, whoever ran it, are announced in the room by its Vibe with a link to the task's page
(`/r/:slug/tasks/:id`: details, the full result, sources, cost). Open pages refresh the panel when the chat socket
sends `room_task`.
++- **Alerts into the room** (`server/rooms/hooks.js`, migration 0029). The room's owner makes up to five hooks on its
++ settings page: an address (`POST /api/chat/hooks/rh_…`) and a secret, shown once. A POST signed like an
++ OpenVibe.Events delivery (X-OpenVibe-Signature-V2 over the raw body, ±5 minutes; an OpenVibe.Watch webhook action with
++ the secret as a project `secret_ref` sends exactly that) becomes a message from the room's Vibe: a Watch firing
++ ("🔔 Site errors: Live home p95 fired: 2.4 (was 0.9)" and the watch's link), an OpenVibe event, or `{ "text" }`. A hook
++ can also queue a task of a chosen kind for the person who made it, while they may still post there. Wrong or stale
++ signature 401, unknown hook 404, unreadable body 422, past 60 an hour 429; a repeated X-OpenVibe-Delivery-Id changes
++ nothing. Hooks are in their maker's export and go when the account does.
- **Cancelling** is the asker's or a moderator's. Limits: 20 tasks an hour per person, 300 a day per room.
- **A shared screen** (migration `0028_room_screen.sql`): a room's managers set a screen share link from OpenVibe.Bot
(`https://openvibe.bot/screen/…`, made by the computer's owner on its screen page; nothing else is accepted). Members
ddiff --git a/migrations/0029_room_hooks.sql b/migrations/0029_room_hooks.sql
nnew file mode 100644
iindex 0000000..b7eda81
---- /dev/null
++++ b/migrations/0029_room_hooks.sql
@@@ -0,0 +1,19 @@
++-- phase: expand
++-- Incoming hooks for workspace rooms (server/rooms/hooks.js; owner 2026-10-25: the shared room "integrated with Watch,
++-- Run and the rest"). A room's owner makes a hook: an address and a secret, shown once. A signed POST to it (signed like
++-- an OpenVibe.Events delivery, as a Watch webhook action signs one) becomes a message from the room's Vibe, and, when
++-- the hook says so, a task in the room's queue for the person who made the hook.
++CREATE TABLE IF NOT EXISTS room_hooks (
++ id text COLLATE "C" PRIMARY KEY, -- rh_<random>: the last part of the hook's address
++ room_id bigint NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
++ label text NOT NULL, -- what the room sees before each message (<= 60)
++ secret text NOT NULL, -- whsec_…: checks X-OpenVibe-Signature-V2 (shown once)
++ queue_kind text COLLATE "C" -- NULL: a message only; else the kind of task each one queues
++ CHECK (queue_kind IS NULL OR queue_kind IN ('answer', 'research', 'web', 'code', 'computer', 'other')),
++ created_by bigint NOT NULL, -- Chat user id: queued tasks are theirs
++ deliveries integer NOT NULL DEFAULT 0,
++ last_at text COLLATE "C",
++ created_at text COLLATE "C" DEFAULT ov_now()
++);
++CREATE INDEX IF NOT EXISTS room_hooks_room_idx ON room_hooks (room_id);
++CREATE INDEX IF NOT EXISTS room_hooks_creator_idx ON room_hooks (created_by);
ddiff --git a/server/app.js b/server/app.js
iindex 28e17b7..dbd9cf0 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -192,6 +192,8 @@ function createApp({ chatServer, relay, events = null, callServer = null }) {
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));
++ // A workspace room's incoming hooks check a signature over the raw body, so they read it before the JSON parser does.
++ app.post('/api/chat/hooks/:id', express.raw({ type: () => true, limit: '64kb' }), require('./rooms/hooks').ingress);
app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: true, limit: '256kb' }));
app.use(cookieParser());
ddiff --git a/server/chat/account-data.js b/server/chat/account-data.js
iindex 9cd7517..075b261 100644
---- a/server/chat/account-data.js
++++ b/server/chat/account-data.js
@@@ -61,6 +61,8 @@ const EXPORTS = [
['ai_members_added.json', `SELECT * FROM room_ai_members WHERE created_by_subject = ANY(@s) ORDER BY id DESC LIMIT ${LIMIT}`],
// Work they put on a workspace room's queue (server/rooms/workspace.js), with its results.
['room_tasks.json', `SELECT t.id, r.slug AS room, t.title, t.details, t.kind, t.status, t.via, t.result, t.result_url, t.sources, t.error, t.created_at, t.finished_at FROM room_tasks t JOIN rooms r ON r.id = t.room_id WHERE t.requested_subject = ANY(@s) OR t.requested_by = ANY(@ids) ORDER BY t.id DESC LIMIT ${LIMIT}`],
++ // Hooks they made into a workspace room (server/rooms/hooks.js), without their secrets.
++ ['room_hooks.json', `SELECT h.id, r.slug AS room, h.label, h.queue_kind, h.deliveries, h.last_at, h.created_at FROM room_hooks h JOIN rooms r ON r.id = h.room_id WHERE h.created_by = ANY(@ids) ORDER BY h.created_at DESC LIMIT ${LIMIT}`],
['vibe_usage.json', `SELECT * FROM vibe_usage WHERE user_id = ANY(@ids) ORDER BY day DESC LIMIT ${LIMIT}`],
['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}`],
@@@ -131,6 +133,8 @@ const ERASE_AFTER_ROOMS = [
['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)'],
++ // A hook queues work as the person who made it: it goes with them.
++ ['room_hooks', 'DELETE FROM room_hooks WHERE created_by = ANY(@ids)'],
// A workspace room's queue keeps the work the team relies on, without who asked or who took it.
[null, "UPDATE room_tasks SET requested_by = 0, requested_subject = NULL WHERE requested_subject = ANY(@s) OR requested_by = ANY(@ids)"],
[null, "UPDATE room_tasks SET runner = 'a former member' WHERE runner ~ '^user:[0-9]+$' AND substr(runner, 6)::bigint = ANY(@ids)"],
ddiff --git a/server/rooms/hooks.js b/server/rooms/hooks.js
nnew file mode 100644
iindex 0000000..3a17264
---- /dev/null
++++ b/server/rooms/hooks.js
@@@ -0,0 +1,152 @@
++'use strict';
++/**
++ * Incoming hooks for workspace rooms (migrations/0029_room_hooks.sql): what other systems see lands in the room. A room's
++ * owner makes a hook on the room's settings page; its address and secret are shown once.
++ *
++ * POST /api/chat/hooks/:id the raw body, signed like an OpenVibe.Events delivery (X-OpenVibe-Timestamp and
++ * X-OpenVibe-Signature-V2 with the hook's secret, ±5 minutes: openvibe-sdk/events
++ * signDeliveryHeaders makes them, and an OpenVibe.Watch webhook action sends them)
++ *
++ * The body, as JSON, is one of:
++ * a Watch firing { type: 'watch.watch.triggered', watch: { name, url }, observation: { value, previous_value } }
++ * an OpenVibe event { event: { type, data } } (data.text, .summary or .title is the line, if any)
++ * a line { text } (or { message })
++ * It becomes a message from the room's Vibe (its first AI member), "🔔 <label>: …". A hook with a queue kind also adds
++ * a task of that kind to the room's queue for the person who made it (server/rooms/workspace.js), while they may still
++ * post there: quick kinds run at once on their OpenVibe.Actor allowance.
++ *
++ * 202 when it is in the room. 401 for a missing, stale or wrong signature, 404 for no such hook (or a room that is no
++ * longer a workspace), 422 for a body it cannot read, 429 past 60 deliveries an hour per hook. A repeat of the same
++ * X-OpenVibe-Delivery-Id within the hour is answered 202 and changes nothing.
++ */
++const crypto = require('crypto');
++const db = require('../db/database');
++const rooms = require('./rooms');
++
++const MAX_PER_ROOM = 5;
++const PER_HOUR = 60;
++const LINE_MAX = 1500;
++const ID_RE = /^rh_[A-Za-z0-9_-]{20,40}$/;
++const KINDS = ['answer', 'research', 'web', 'code', 'computer', 'other'];
++const recent = new Map(); // hook id → { since, n, ids: Set } for the rate limit and repeats, per process
++
++class HookError extends Error { constructor(status, code, message) { super(message); this.status = status; this.code = code; } }
++const fail = (status, code, message) => { throw new HookError(status, code, message); };
++const oneLine = (v, max) => String(v == null ? '' : v).replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
++const valueText = (v) => (v == null ? 'no value' : typeof v === 'string' ? v : (() => { try { return JSON.stringify(v); } catch { return String(v); } })());
++
++const shape = (h) => ({ id: h.id, label: h.label, queue_kind: h.queue_kind || null, deliveries: Number(h.deliveries) || 0, last_at: h.last_at || null, created_at: h.created_at });
++
++async function mustManage(room, user) {
++ const a = await rooms.access(room, user);
++ if (!a.read) fail(404, 'rooms.not_found', 'No such room');
++ if (!rooms.isWorkspace(room)) fail(409, 'hooks.not_workspace', 'Only workspace rooms take incoming hooks');
++ if (!a.manage) fail(403, 'hooks.owner_only', 'Only the room\'s owner makes hooks');
++ return a;
++}
++
++async function list(room) {
++ return (await db.all('SELECT * FROM room_hooks WHERE room_id = ? ORDER BY created_at, id', [room.id])).map(shape);
++}
++
++/** A new hook. → { hook, secret }: the secret is never shown again. */
++async function create(room, user, { label, queue_kind = null } = {}) {
++ await mustManage(room, user);
++ const name = oneLine(label, 60);
++ if (name.length < 2) fail(422, 'hooks.label', 'Give the hook a short name, such as "Site errors"');
++ const kind = KINDS.includes(queue_kind) ? queue_kind : null;
++ const n = await db.get('SELECT COUNT(*) AS n FROM room_hooks WHERE room_id = ?', [room.id]);
++ if (Number(n && n.n) >= MAX_PER_ROOM) fail(429, 'hooks.too_many', `At most ${MAX_PER_ROOM} hooks a room`);
++ const id = `rh_${crypto.randomBytes(18).toString('base64url')}`;
++ const secret = `whsec_${crypto.randomBytes(24).toString('base64url')}`;
++ const row = await db.get('INSERT INTO room_hooks (id, room_id, label, secret, queue_kind, created_by) VALUES (?, ?, ?, ?, ?, ?) RETURNING *', [id, room.id, name, secret, kind, user.id]);
++ return { hook: shape(row), secret };
++}
++
++async function remove(room, user, id) {
++ await mustManage(room, user);
++ await db.run('DELETE FROM room_hooks WHERE room_id = ? AND id = ?', [room.id, String(id || '')]);
++}
++
++/** The room's line for a body, or null when the body is none of the forms above. */
++function lineOf(body) {
++ if (!body || typeof body !== 'object' || Array.isArray(body)) return null;
++ if (body.type === 'watch.watch.triggered' && body.watch && typeof body.watch === 'object') {
++ const o = body.observation || {};
++ const was = o.previous_value === undefined || o.previous_value === null ? '' : ` (was ${oneLine(valueText(o.previous_value), 200)})`;
++ const url = /^https:\/\//.test(String(body.watch.url || '')) ? `\n${oneLine(body.watch.url, 500)}` : '';
++ return { title: `${oneLine(body.watch.name || 'A watch', 120)} fired`, text: `${oneLine(body.watch.name || 'A watch', 120)} fired: ${oneLine(valueText(o.value), 400)}${was}${url}` };
++ }
++ if (body.event && typeof body.event === 'object' && typeof body.event.type === 'string') {
++ const d = body.event.data && typeof body.event.data === 'object' ? body.event.data : {};
++ const said = oneLine(d.text || d.summary || d.title || '', LINE_MAX);
++ const type = oneLine(body.event.type, 120);
++ return { title: said ? `${type}: ${said}` : type, text: said ? `${type}: ${said}` : type };
++ }
++ const text = typeof body.text === 'string' ? body.text : typeof body.message === 'string' ? body.message : null;
++ if (text && oneLine(text, LINE_MAX)) return { title: oneLine(text, 200), text: oneLine(text, LINE_MAX) };
++ return null;
++}
++
++/** One delivery: check it, post it, queue it. → { message_id, task_id, repeat } */
++async function deliver(id, rawBody, headers, deps = {}) {
++ if (!ID_RE.test(String(id || ''))) fail(404, 'hooks.not_found', 'No such hook');
++ const hook = await db.get('SELECT * FROM room_hooks WHERE id = ?', [id]);
++ if (!hook) fail(404, 'hooks.not_found', 'No such hook');
++ const { verifyDeliveryV2 } = require('openvibe-sdk/events');
++ if (!verifyDeliveryV2(rawBody, headers, hook.secret, deps.now ? { now: deps.now() } : {})) fail(401, 'hooks.signature', 'The signature is missing, stale or wrong');
++ const room = await db.get('SELECT * FROM rooms WHERE id = ?', [hook.room_id]);
++ if (!room || !rooms.isWorkspace(room)) fail(404, 'hooks.not_found', 'No such hook');
++
++ const now = deps.now ? deps.now() : Date.now();
++ let r = recent.get(id);
++ if (!r || now - r.since > 3600_000) { r = { since: now, n: 0, ids: new Set() }; recent.set(id, r); }
++ const deliveryId = oneLine(headers['x-openvibe-delivery-id'] || '', 120);
++ if (deliveryId && r.ids.has(deliveryId)) return { message_id: null, task_id: null, repeat: true };
++ if (r.n >= PER_HOUR) fail(429, 'hooks.slow_down', `At most ${PER_HOUR} deliveries an hour to one hook`);
++
++ let body;
++ try { body = JSON.parse(Buffer.isBuffer(rawBody) ? rawBody.toString('utf8') : String(rawBody)); } catch { body = null; }
++ const line = lineOf(body);
++ if (!line) fail(422, 'hooks.body', 'Send JSON: a Watch firing, an OpenVibe event, or { "text": "…" }');
++ r.n += 1;
++ if (deliveryId) r.ids.add(deliveryId);
++
++ const member = (await db.all("SELECT * FROM room_ai_members WHERE room_id = ? AND enabled = 1 ORDER BY (handle = 'vibe') DESC, id LIMIT 1", [room.id]))[0];
++ let posted = null;
++ if (member) {
++ posted = await rooms.postAsAi(room, member, `🔔 ${hook.label}: ${line.text}`);
++ try { require('../chat/chat-server').broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message: posted }); } catch { /* sockets are optional */ }
++ }
++ let task = null;
++ if (hook.queue_kind) {
++ const creator = await require('../live-context').getUserById(Number(hook.created_by));
++ try {
++ task = await require('./workspace').add(room, creator, {
++ title: `${hook.label}: ${line.title}`.slice(0, 200), kind: hook.queue_kind, via: 'api',
++ details: `From the room's hook "${hook.label}":\n\n${JSON.stringify(body, null, 2).slice(0, 5500)}`,
++ }, deps.work || {});
++ } catch (err) {
++ // The person who made the hook can no longer post here, or the queue is full: the message still stands.
++ if (!(err && err.status)) throw err;
++ }
++ }
++ await db.run('UPDATE room_hooks SET deliveries = deliveries + 1, last_at = ov_now() WHERE id = ?', [id]);
++ return { message_id: posted ? posted.id : null, task_id: task ? task.id : null, repeat: false };
++}
++
++/** The Express handler for POST /api/chat/hooks/:id (mounted with a raw body parser before the JSON one). */
++async function ingress(req, res) {
++ try {
++ const out = await deliver(req.params.id, Buffer.isBuffer(req.body) ? req.body : Buffer.from(''), req.headers);
++ res.status(202).json({ ok: true, ...out });
++ } catch (err) {
++ if (err instanceof HookError) return res.status(err.status).json({ error: err.message, code: err.code });
++ console.warn('[Hooks] delivery failed:', err && err.message);
++ res.status(500).json({ error: 'Could not take that right now', code: 'hooks.error' });
++ }
++}
++
++function _reset() { recent.clear(); }
++
++module.exports = { HookError, MAX_PER_ROOM, list, create, remove, deliver, ingress, lineOf, _reset };
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex 3010d60..07b6c00 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -559,6 +559,7 @@ ${key ? `<form class="oc-inline" method="post" action="/settings/ai-key/delete">
// ── Rooms (server/rooms/) ──
const rooms = require('../rooms/rooms');
++ const roomHooks = require('../rooms/hooks');
const pictures = require('../rooms/pictures');
const aiMembers = require('../rooms/ai-members');
// A picture sent from the room's form (server/rooms/pictures.js): one image, kept in memory only until Pics has it.
@@@ -820,6 +821,14 @@ ${ais.length < aiMembers.MAX_PER_ROOM ? aiForm(null) : ''}` : '';
${aiSection}`,
});
}
++ const hookList = can.manage && rooms.isWorkspace(room) ? await roomHooks.list(room) : [];
++ const hookSection = can.manage && rooms.isWorkspace(room) ? `<h2 id="oc-hooks">Alerts into this room</h2>
++<p class="oc-muted">Other systems post here through a hook: an OpenVibe.Watch webhook action, an OpenVibe.Events subscription, or anything that signs its POST the same way. Each one arrives as a message from the room's Vibe, and can add a task to the queue for you. At most ${roomHooks.MAX_PER_ROOM}.</p>
++${hookList.length ? `<ul class="oc-members">${hookList.map((h) => `<li><strong>${esc(h.label)}</strong> <span class="oc-muted">${h.queue_kind ? `queues ${esc(h.queue_kind)} work` : 'a message only'} · ${h.deliveries} received${h.last_at ? ` · last ${esc(String(h.last_at).slice(0, 16))}` : ''}</span><form class="oc-inline" method="post" action="/r/${esc(room.slug)}/hooks/${encodeURIComponent(h.id)}/delete"><button type="submit" class="oc-link">Delete</button></form></li>`).join('')}</ul>` : ''}
++${hookList.length < roomHooks.MAX_PER_ROOM ? `<form class="oc-form" method="post" action="/r/${esc(room.slug)}/hooks">
++<label>Name <input name="label" required minlength="2" maxlength="60" placeholder="Site errors"></label>
++<label>Each alert <select name="queue_kind"><option value="">posts a message</option><option value="research">posts and asks Vibe to look into it (research, runs on your allowance)</option><option value="code">posts and queues a code task for a worker</option><option value="other">posts and queues a task for a worker</option></select></label>
++<p><button type="submit">Make a hook</button></p></form>` : ''}` : '';
const communityUrl = config.web.communityUrl;
const attachList = can.manage ? `<h2>Attached to</h2>${attachments.length ? `<ul class="oc-members">${attachments.map((a) => `<li><a href="${esc(a.service === 'community' ? `${communityUrl}/s/${encodeURIComponent(a.resource)}` : '#')}">${esc(a.title || a.resource)}</a> <span class="oc-muted">${esc(a.service === 'community' ? 'OpenVibe.Community space' : a.service)}</span><form class="oc-inline" method="post" action="/r/${esc(room.slug)}/attachments/${encodeURIComponent(a.service)}/${encodeURIComponent(a.resource)}/detach"><button type="submit" class="oc-link">Detach</button></form></li>`).join('')}</ul>` : '<p class="oc-muted">Not attached anywhere. A Community space you own can attach this room from the space\'s page.</p>'}` : '';
await page(req, res, 200, {
@@@ -838,6 +847,7 @@ ${room.kind === 'call' ? `<label>People who join <select name="join_role">${room
<label>Slow mode (seconds between messages, 0 = off) <input name="slow_seconds" type="number" min="0" max="600" value="${Number(room.slow_seconds) || 0}"></label>
${rooms.isWorkspace(room) ? `<label>Shared screen <input name="screen_url" type="url" value="${esc(room.screen_url || '')}" placeholder="https://openvibe.bot/screen/…"> <span class="oc-muted">A screen share link from OpenVibe.Bot (a computer's owner makes it on its screen page). Members see the screen beside the conversation, and with a "use" link they can click and type on it. Empty: no screen.</span></label>` : ''}
<p><button type="submit">Save</button></p></form>` : ''}
++${hookSection}
<h2>Members</h2>
<p class="oc-muted">${esc(roleHelp(room.kind))}</p>
<form class="oc-new" method="post" action="/r/${esc(room.slug)}/members"><label for="oc-invite">Add someone</label> <input id="oc-invite" name="username" required pattern="[A-Za-z0-9_]{3,24}" placeholder="their username"> <select name="role" aria-label="Their role">${choices.filter((r) => r !== 'none' && r !== 'blocked').map((r) => roleOption(r, addRole)).join('')}</select> <button type="submit">Add</button></form>
@@@ -865,6 +875,43 @@ ${attachList}`,
res.redirect(303, `/r/${room.slug}/settings?saved=1`);
}));
++ router.post('/r/:slug/hooks', formLimit, sameSite, roomsOrError(async (req, res) => {
++ const actor = await needUser(req, res); if (!actor) return;
++ const room = await rooms.bySlug(req.params.slug);
++ if (!room || !(await rooms.access(room, actor.user)).read) return res.redirect(303, '/rooms');
++ req.roomBack = `/r/${room.slug}/settings`;
++ let made;
++ try { made = await roomHooks.create(room, actor.user, { label: req.body && req.body.label, queue_kind: (req.body && req.body.queue_kind) || null }); } catch (err) {
++ if (err instanceof roomHooks.HookError) return res.redirect(303, `/r/${room.slug}/settings?error=${encodeURIComponent(err.message)}#oc-hooks`);
++ throw err;
++ }
++ const address = `${config.web.baseUrl}/api/chat/hooks/${made.hook.id}`;
++ res.set('Cache-Control', 'no-store');
++ await page(req, res, 200, {
++ actor, title: `${room.name}: new hook`, path: req.path, active: 'rooms', robots: 'noindex, nofollow',
++ body: `<p><a href="/r/${esc(room.slug)}/settings#oc-hooks">← Room settings</a></p><h1>Hook "${esc(made.hook.label)}" is ready</h1>
++${notice('ok', 'Copy the secret now: it is not shown again. Anyone with the address and the secret can post in the room.')}
++<label class="oc-form">Address <input readonly value="${esc(address)}"></label>
++<label class="oc-form">Secret <input readonly value="${esc(made.secret)}"></label>
++<h2>From OpenVibe.Watch</h2>
++<ol><li>On openvibe.services, open your project's Secrets page, save the secret (for example as <code>staff_room_hook</code>) and grant it to <code>watch</code>.</li>
++<li>On the watch, add a webhook action to the address above, with that secret as its <code>secret_ref</code> (the watch names the project).</li>
++<li>Each time the watch fires, Vibe says so here${made.hook.queue_kind ? `, and a ${esc(made.hook.queue_kind)} task joins the queue for you` : ''}.</li></ol>
++<h2>From anything else</h2>
++<p>POST JSON (<code>{"text": "…"}</code>, or an OpenVibe event <code>{"event": {"type", "data"}}</code>) signed like an OpenVibe.Events delivery: <code>openvibe-sdk/events</code> <code>signDeliveryHeaders(body, secret)</code> gives the headers.</p>`,
++ });
++ }));
++ router.post('/r/:slug/hooks/:id/delete', formLimit, sameSite, roomsOrError(async (req, res) => {
++ const actor = await needUser(req, res); if (!actor) return;
++ const room = await rooms.bySlug(req.params.slug);
++ if (!room || !(await rooms.access(room, actor.user)).read) return res.redirect(303, '/rooms');
++ try { await roomHooks.remove(room, actor.user, req.params.id); } catch (err) {
++ if (err instanceof roomHooks.HookError) return res.redirect(303, `/r/${room.slug}/settings?error=${encodeURIComponent(err.message)}#oc-hooks`);
++ throw err;
++ }
++ res.redirect(303, `/r/${room.slug}/settings?saved=1#oc-hooks`);
++ }));
++
router.post('/r/:slug/members', formLimit, sameSite, roomsOrError(async (req, res) => {
const actor = await needUser(req, res); if (!actor) return;
const room = await rooms.bySlug(req.params.slug);
ddiff --git a/test/pg-schema.test.js b/test/pg-schema.test.js
iindex 460c499..a6fd8cf 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', 'room_tasks', 'vibe_usage', 'inboxes', 'inbox_members', 'inbox_conversations', 'inbox_messages', 'inbox_ai_usage', 'inbox_saved_replies', 'inbox_webhooks', 'inbox_actions', 'inbox_translations', 'feeds', 'feed_sessions', 'feed_events', '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', 'room_tasks', 'room_hooks', 'vibe_usage', 'inboxes', 'inbox_members', 'inbox_conversations', 'inbox_messages', 'inbox_ai_usage', 'inbox_saved_replies', 'inbox_webhooks', 'inbox_actions', 'inbox_translations', '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 = [
ddiff --git a/test/room-hooks.test.js b/test/room-hooks.test.js
nnew file mode 100644
iindex 0000000..43afc8b
---- /dev/null
++++ b/test/room-hooks.test.js
@@@ -0,0 +1,105 @@
++'use strict';
++/**
++ * Incoming hooks for workspace rooms (server/rooms/hooks.js, migration 0029): only the room's owner (staff, in the staff
++ * room) makes one and sees its secret once; a delivery signed like an OpenVibe.Events delivery becomes a message from
++ * the room's Vibe (a Watch firing, an OpenVibe event, a line); a missing, wrong or stale signature is 401, an unknown
++ * hook 404, a body it cannot read 422; a repeat of a delivery id changes nothing; a hook with a queue kind adds a task
++ * for the person who made it; the settings page lists hooks without their secrets.
++ */
++const assert = require('assert');
++const { ids } = require('openvibe-contracts');
++const { signDeliveryHeaders } = require('openvibe-sdk/events');
++const { boot, suite } = require('./helpers');
++
++const t = suite('room hooks');
++let h, staff, user, rooms, hooks, made;
++const deliver = (id, body, secret, headers = {}) => {
++ const raw = typeof body === 'string' ? body : JSON.stringify(body);
++ return h.http('POST', `/api/chat/hooks/${id}`, { raw, headers: { 'content-type': 'application/json', ...(secret ? signDeliveryHeaders(raw, secret) : {}), ...headers } });
++};
++const lastMessage = async () => {
++ const room = await rooms.bySlug('staff');
++ return h.db.getDb().prepare('SELECT message, ai_member_id FROM room_messages WHERE room_id = ? ORDER BY id DESC LIMIT 1').get(room.id);
++};
++
++t('boot', async () => {
++ h = await boot();
++ rooms = require('../server/rooms/rooms');
++ hooks = require('../server/rooms/hooks');
++ staff = h.addUser('mia', { role: 'admin', subject: ids.newId('user') });
++ user = h.addUser('pat', { subject: ids.newId('user') });
++ for (const u of [staff, user]) await h.ctx.upsertUser(h.live.users.get(u.id));
++});
++
++t('the owner makes a hook and sees its secret once; nobody else makes one', async () => {
++ const room = await rooms.bySlug('staff');
++ const me = await require('../server/live-context').getUserById(staff.id);
++ made = await hooks.create(room, me, { label: 'Site errors' });
++ assert.match(made.hook.id, /^rh_/);
++ assert.match(made.secret, /^whsec_/);
++ assert.ok(!JSON.stringify(await hooks.list(room)).includes(made.secret), 'never listed with its secret');
++ const pat = await require('../server/live-context').getUserById(user.id);
++ await assert.rejects(hooks.create(room, pat, { label: 'Mine' }), (e) => e.status === 404, 'not staff: no such room');
++ const cookie = `ov_token=${staff.token}`;
++ const r = await h.http('POST', '/r/staff/hooks', { headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' }, raw: 'label=Deploys&queue_kind=research' });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.match(r.text, /whsec_[A-Za-z0-9_-]+/);
++ assert.match(r.text, /\/api\/chat\/hooks\/rh_/);
++ const page = await h.http('GET', '/r/staff/settings', { headers: { cookie } });
++ assert.match(page.text, /Site errors/);
++ assert.match(page.text, /Deploys/);
++ assert.ok(!/whsec_/.test(page.text), 'the settings page never shows a secret');
++});
++
++t('a signed Watch firing becomes a message from Vibe', async () => {
++ const firing = { type: 'watch.watch.triggered', watch: { id: 'wch_1', name: 'Live home p95', url: 'https://openvibe.watch/watches/wch_1' }, check_run_id: 'run_1', observation: { value: 2.4, previous_value: 0.9 } };
++ const r = await deliver(made.hook.id, firing, made.secret, { 'x-openvibe-delivery-id': 'run_1.0' });
++ assert.strictEqual(r.status, 202, r.text);
++ const m = await lastMessage();
++ assert.ok(m.ai_member_id, 'from the room\'s Vibe');
++ assert.strictEqual(m.message, '🔔 Site errors: Live home p95 fired: 2.4 (was 0.9)\nhttps://openvibe.watch/watches/wch_1');
++ const again = await deliver(made.hook.id, firing, made.secret, { 'x-openvibe-delivery-id': 'run_1.0' });
++ assert.strictEqual(again.status, 202);
++ assert.strictEqual(again.body.repeat, true);
++ assert.strictEqual((await lastMessage()).message, m.message);
++});
++
++t('other bodies: an event, a line; refusals', async () => {
++ let r = await deliver(made.hook.id, { event: { type: 'media.vod.ready', data: { title: 'VOD 6100 is ready' } } }, made.secret);
++ assert.strictEqual(r.status, 202);
++ assert.strictEqual((await lastMessage()).message, '🔔 Site errors: media.vod.ready: VOD 6100 is ready');
++ r = await deliver(made.hook.id, { text: 'Backup finished\u0007 in 4 minutes' }, made.secret);
++ assert.strictEqual(r.status, 202);
++ assert.strictEqual((await lastMessage()).message, '🔔 Site errors: Backup finished in 4 minutes');
++ assert.strictEqual((await deliver(made.hook.id, { text: 'no signature' }, null)).status, 401);
++ assert.strictEqual((await deliver(made.hook.id, { text: 'wrong key' }, 'whsec_notthesecretnotthesecret12345')).status, 401);
++ const raw = JSON.stringify({ text: 'old' });
++ const stale = signDeliveryHeaders(raw, made.secret, { now: Date.now() - 20 * 60_000 });
++ assert.strictEqual((await h.http('POST', `/api/chat/hooks/${made.hook.id}`, { raw, headers: { 'content-type': 'application/json', ...stale } })).status, 401, 'stale');
++ assert.strictEqual((await deliver('rh_nosuchhooknosuchhook123', { text: 'x' }, made.secret)).status, 404);
++ assert.strictEqual((await deliver(made.hook.id, { nothing: true }, made.secret)).status, 422);
++ assert.strictEqual((await deliver(made.hook.id, 'not json', made.secret)).status, 422);
++});
++
++t('a hook with a queue kind adds a task for the person who made it', async () => {
++ const room = await rooms.bySlug('staff');
++ const me = await require('../server/live-context').getUserById(staff.id);
++ const q = await hooks.create(room, me, { label: 'Broken builds', queue_kind: 'code' });
++ const r = await deliver(q.hook.id, { text: 'OpenVibe.Tools main is red' }, q.secret);
++ assert.strictEqual(r.status, 202, r.text);
++ assert.ok(r.body.task_id);
++ const task = h.db.getDb().prepare('SELECT title, kind, via, requested_by, status FROM room_tasks WHERE id = ?');
++ const row = await task.get(r.body.task_id);
++ assert.deepStrictEqual([row.title, row.kind, row.via, Number(row.requested_by), row.status], ['Broken builds: OpenVibe.Tools main is red', 'code', 'api', staff.id, 'queued']);
++ await hooks.remove(room, me, q.hook.id);
++ assert.strictEqual((await deliver(q.hook.id, { text: 'gone' }, q.secret)).status, 404, 'a deleted hook is gone');
++});
++
++t('lineOf reads only the forms it knows', () => {
++ assert.strictEqual(hooks.lineOf(null), null);
++ assert.strictEqual(hooks.lineOf([1]), null);
++ assert.strictEqual(hooks.lineOf({ text: ' ' }), null);
++ assert.ok(!hooks.lineOf({ type: 'watch.watch.triggered', watch: { name: 'x', url: 'javascript:alert(1)' }, observation: {} }).text.includes('javascript'), 'only https links');
++});
++
++t.run(async () => { if (h && h.close) await h.close(); });