openvibers/openvibe.chat Commits

Agent Streams S7: watch parties with their sync and a count without names (#367)

OP OpenVibers committed 6 days ago · 4a327946fd · 9 files, +421 −11

* Agent Streams S7: watch parties with their sync and a count without names

* Merge remote-tracking branch 'origin/main' into feat/agent-streams-s7-parties

# Conflicts:
#	package-lock.json
#	package.json

Changes

ddiff --git a/migrations/0040_room_parties.sql b/migrations/0040_room_parties.sql
nnew file mode 100644
iindex 0000000..83e4ac2
---- /dev/null
++++ b/migrations/0040_room_parties.sql
@@@ -0,0 +1,33 @@
++-- phase: expand
++-- Agent Streams S7 (design 2026-11-06, AGENT-STREAMS-S7-REACH-DESIGN): watch parties on a public agent stream or its
++-- VOD.
++--
++-- A party is a Chat room of kind 'party' (the room row keeps its name, members and messages) plus this row: the
++-- stream it watches, the host whose position a VOD follows, and whether the stream's page may count it. A live party
++-- follows the live edge and has no position. The room is private by default: its link carries a secret (only its
++-- sha256 is stored), so joining is by that link and a stranger without it sees the same 404 as a missing room.
++-- `listed` only lets the stream's page say "3 parties watching": the count query never reads a member or a name.
++--
++-- Expand only: 'party' joins the existing room kinds, every table is new, and an older release ignores all of it.
++DO $$ BEGIN
++ ALTER TABLE rooms DROP CONSTRAINT IF EXISTS rooms_kind_allowed;
++ ALTER TABLE rooms ADD CONSTRAINT rooms_kind_allowed CHECK (kind IN ('community', 'call', 'system', 'agent_stream', 'party'));
++EXCEPTION WHEN duplicate_object THEN NULL; END $$;
++
++CREATE TABLE IF NOT EXISTS room_parties (
++ room_id bigint PRIMARY KEY REFERENCES rooms(id) ON DELETE CASCADE,
++ agent_stream_id text COLLATE "C" NOT NULL,
++ vod_id bigint,
++ host_user_id bigint NOT NULL,
++ host_subject text COLLATE "C" NOT NULL,
++ mode text COLLATE "C" NOT NULL CHECK (mode IN ('live', 'vod')),
++ position_s integer NOT NULL DEFAULT 0 CHECK (position_s >= 0),
++ playing boolean NOT NULL DEFAULT false,
++ position_at timestamptz NOT NULL DEFAULT now(),
++ listed boolean NOT NULL DEFAULT false,
++ join_secret_hash text COLLATE "C" NOT NULL,
++ ended_at timestamptz,
++ created_at timestamptz NOT NULL DEFAULT now()
++);
++CREATE INDEX IF NOT EXISTS room_parties_stream ON room_parties (agent_stream_id) WHERE ended_at IS NULL;
++CREATE INDEX IF NOT EXISTS room_parties_host ON room_parties (host_user_id);
ddiff --git a/package-lock.json b/package-lock.json
iindex 5b77723..1feae59 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -17,7 +17,7 @@
"express-rate-limit": "^7.5.0",
"iovalkey": "^0.4.0",
"multer": "^2.0.2",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.304.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.305.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.93.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.124.0",
"pg": "^8.23.0",
@@@ -1557,9 +1557,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.304.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.304.0",
-- "integrity": "sha512-nUNV/kfgfYwBTqPPfa3u/rJAs0YMQXXMXqwRGlf4IJHGeUXbSo6/h12GtX5QwG9v3eqQYkc5a+nAQXwYWu7lDQ==",
++ "version": "0.305.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.305.0",
++ "integrity": "sha512-jTU1zlnTmHlydwL3/c5HVdUiPpd8r2KucVdHYUYX5MZgJoqy4M7Hg2jy6UKbJQ6s665uIZyTvswbmJjB2uwc5Q==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex a1128ee..bc6fd26 100644
---- a/package.json
++++ b/package.json
@@@ -29,7 +29,7 @@
"express-rate-limit": "^7.5.0",
"iovalkey": "^0.4.0",
"multer": "^2.0.2",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.304.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.305.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.93.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.124.0",
"pg": "^8.23.0",
ddiff --git a/server/chat/internal-reads.js b/server/chat/internal-reads.js
iindex 6729bd9..31ba13c 100644
---- a/server/chat/internal-reads.js
++++ b/server/chat/internal-reads.js
@@@ -305,6 +305,15 @@ async function ttsOverride(req) {
};
}
++/** S7: how many listed watch parties a public agent stream has. A count only: no host, member or name. */
++async function partyCount(req) {
++ const q = query(req, { agent_stream: 40 });
++ const streamId = String(q.agent_stream || '');
++ if (!/^ags_[0-9A-HJKMNP-TV-Z]{26}$/.test(streamId)) bad('Invalid agent_stream');
++ const row = await db.get('SELECT COUNT(*) AS n FROM room_parties p JOIN rooms r ON r.id = p.room_id WHERE p.agent_stream_id = ? AND p.listed IS TRUE AND p.ended_at IS NULL AND r.archived_at IS NULL', [streamId]);
++ return { ok: true, count: Number(row && row.n) || 0 };
++}
++
// ── N5: channel sounds ──
function soundRow(r) {
return {
@@@ -371,6 +380,7 @@ router.get('/moderation/pending-ip', queue, route('pending-ip', pendingIp));
router.get('/moderation/relay-users', queue, route('relay-users', relayUsers));
router.get('/moderation/relay-users/:id', queue, route('relay-user', relayUser));
router.get('/moderation/tts-override', queue, route('tts-override', ttsOverride));
++router.get('/parties/count', serviceAuth.guard('chat.messages.read'), route('party-count', partyCount));
router.get('/sounds', serviceAuth.guard('chat.sounds.read'), route('sounds', sounds));
router.get('/sounds/count', serviceAuth.guard('chat.sounds.read'), route('sound-count', soundCount));
router.get('/sounds/by-command', serviceAuth.guard('chat.sounds.read'), route('sound-by-command', soundByCommand));
ddiff --git a/server/rooms/parties.js b/server/rooms/parties.js
nnew file mode 100644
iindex 0000000..cbc4014
---- /dev/null
++++ b/server/rooms/parties.js
@@@ -0,0 +1,178 @@
++'use strict';
++/**
++ * Watch parties (Agent Streams S7, design 2026-11-06 §4): a Chat room of kind 'party' where friends watch a public
++ * agent stream or its VOD in sync, with their own chat beside the stream's.
++ *
++ * - Sync: on a VOD the position follows the host's; on a live stream it follows the live edge (no position).
++ * - Joining is by the party's link. The link carries a secret, the row stores its sha256 only, and a stranger
++ * without it sees the same 404 as a missing room.
++ * - Private by default: rooms.visibility is 'private', the party is never in the directory, and `listed` only lets
++ * the stream's page count it ("3 parties watching"). The count query never reads a member or a name.
++ *
++ * The room itself is rooms.js's (name, members, messages, moderation); this module owns only the party facts.
++ */
++const crypto = require('crypto');
++const db = require('../db/database');
++const rooms = require('./rooms');
++
++const AGENT_STREAM_RE = /^ags_[0-9A-HJKMNP-TV-Z]{26}$/;
++const VOD_RE = /^\d{1,16}$/;
++const MAX_TITLE = 200;
++const BASE_URL = () => String(process.env.OV_CHAT_PUBLIC_URL || 'https://openvibe.chat').replace(/\/+$/, '');
++
++class PartyError extends Error {
++ constructor(status, code, message) { super(message); this.status = status; this.code = code; }
++}
++const fail = (status, code, message) => { throw new PartyError(status, code, message); };
++const sha256 = (value) => crypto.createHash('sha256').update(String(value)).digest('hex');
++const secret = () => crypto.randomBytes(18).toString('base64url').slice(0, 24);
++
++function titleOf(value) {
++ const text = typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : '';
++ if (!text || Array.from(text).length > MAX_TITLE) fail(422, 'party.title', 'A party needs a title of 1–200 characters');
++ return text;
++}
++
++/** A VOD's id: Media's numeric id, as Live and the Rundown carry it. */
++function vodOf(value) {
++ if (value == null || value === '') return null;
++ if (!VOD_RE.test(String(value))) fail(422, 'party.vod', 'vod_id is the recording\'s number');
++ return Number(value);
++}
++
++function modeOf(vodId) { return vodId == null ? 'live' : 'vod'; }
++
++/** The party's position now: on a VOD, a playing one moves with the clock; a live one is the live edge. */
++function syncOf(party, now = Date.now()) {
++ if (!party || party.mode === 'live') return { mode: 'live', playing: true };
++ const at = party.position_at instanceof Date ? party.position_at.getTime() : Date.parse(String(party.position_at).includes('T') ? party.position_at : `${String(party.position_at).replace(' ', 'T')}Z`);
++ const since = party.playing && Number.isFinite(at) ? Math.max(0, Math.floor((now - at) / 1000)) : 0;
++ return {
++ mode: 'vod',
++ position_s: Math.max(0, Number(party.position_s) || 0) + since,
++ playing: !!party.playing,
++ at: new Date(Number.isFinite(at) ? at : now).toISOString(),
++ };
++}
++
++function joinUrl(row, rawSecret = null) {
++ const base = `${BASE_URL()}/rooms/${encodeURIComponent(row.slug)}`;
++ return rawSecret ? `${base}?k=${encodeURIComponent(rawSecret)}` : base;
++}
++
++function view(row, party, { rawSecret = null, now = Date.now() } = {}) {
++ return {
++ id: row.slug,
++ agent_stream_id: party.agent_stream_id,
++ vod_id: party.vod_id == null ? null : String(party.vod_id),
++ host: party.host_subject,
++ title: row.name,
++ sync: syncOf(party, now),
++ visibility: 'private',
++ listed: !!party.listed,
++ join_url: joinUrl(row, rawSecret),
++ created_at: new Date(party.created_at instanceof Date ? party.created_at.getTime() : Date.parse(String(party.created_at).includes('T') ? party.created_at : `${String(party.created_at).replace(' ', 'T')}Z`)).toISOString(),
++ ...(party.ended_at ? { ended_at: new Date(party.ended_at).toISOString() } : {}),
++ };
++}
++
++/**
++ * Start a party on a public agent stream or its VOD. The room is private; only the link (with its secret) opens it.
++ * Returns the party and the one time its raw join secret exists.
++ */
++async function create(user, { agent_stream_id: agentStreamId, vod_id: vodId = null, title, listed = false } = {}) {
++ if (!user) fail(401, 'party.sign_in', 'Sign in to start a watch party');
++ if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(user.subject_id || ''))) fail(409, 'party.no_subject', 'This account has no OpenVibe subject yet; sign in again');
++ const streamId = String(agentStreamId || '');
++ if (!AGENT_STREAM_RE.test(streamId)) fail(422, 'party.stream', 'agent_stream_id is an agent stream id (ags_…)');
++ const name = titleOf(title);
++ const vod = vodOf(vodId);
++ const raw = secret();
++ // The room name has its own 3–40 cap; the party's title (up to 200) stays on the party and the page.
++ const roomName = (name.length < 3 ? `Party: ${name}` : name).slice(0, 40);
++ const slug = `party-${crypto.randomBytes(4).toString('hex')}`;
++ const room = await rooms.create(user, { name: roomName, slug, kind: 'party', visibility: 'private', topic: name, party: true });
++ const id = room.id;
++ await db.run(
++ `INSERT INTO room_parties (room_id, agent_stream_id, vod_id, host_user_id, host_subject, mode, playing, listed, join_secret_hash)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
++ [id, streamId, vod, user.id, user.subject_id || '', modeOf(vod), false, !!listed, sha256(raw)]
++ );
++ const row = await db.get('SELECT * FROM rooms WHERE id = ?', [id]);
++ const party = await db.get('SELECT * FROM room_parties WHERE room_id = ?', [id]);
++ return { party: view(row, party, { rawSecret: raw }), roomId: id, join_secret: raw };
++}
++
++async function bySlug(slug) {
++ const row = await db.get('SELECT r.*, p.room_id AS party_room_id, p.agent_stream_id, p.vod_id, p.host_user_id, p.host_subject, p.mode, p.position_s, p.playing, p.position_at, p.listed, p.join_secret_hash, p.ended_at, p.created_at AS party_created_at FROM rooms r JOIN room_parties p ON p.room_id = r.id WHERE r.slug = ?', [String(slug || '').toLowerCase()]);
++ return row || null;
++}
++
++function partyOf(row) {
++ return row && row.party_room_id ? {
++ room_id: row.party_room_id, agent_stream_id: row.agent_stream_id, vod_id: row.vod_id,
++ host_user_id: row.host_user_id, host_subject: row.host_subject, mode: row.mode,
++ position_s: row.position_s, playing: row.playing, position_at: row.position_at, listed: row.listed,
++ join_secret_hash: row.join_secret_hash, ended_at: row.ended_at, created_at: row.party_created_at,
++ } : null;
++}
++
++function keyMatches(party, key) {
++ if (!key || !party || !party.join_secret_hash) return false;
++ const a = Buffer.from(sha256(key));
++ const b = Buffer.from(String(party.join_secret_hash));
++ return a.length === b.length && crypto.timingSafeEqual(a, b);
++}
++
++/** Whether this caller may read the party: a member, the host, chat staff, or the holder of the link's secret. */
++async function mayView(row, party, user, key) {
++ const access = await rooms.access(row, user || null);
++ if (access.read) return true;
++ return keyMatches(party, key);
++}
++
++/** Join with the link's secret: the secret is the invitation, and membership is remembered after it. */
++async function joinWithLink(row, party, user, key) {
++ if (!user) fail(401, 'party.sign_in', 'Sign in to join the party');
++ if (party.ended_at) fail(410, 'party.ended', 'This party has ended');
++ const role = await rooms.access(row, user);
++ if (role.role) return { role: role.role };
++ if (!keyMatches(party, key)) fail(404, 'rooms.not_found', 'No such room');
++ const joined = await rooms.join({ ...row, visibility: 'public' }, user);
++ return { role: joined };
++}
++
++/** The host moves the VOD (or pauses it). Live parties follow the live edge and take no position. */
++async function setSync(row, party, user, { position_s: position, playing } = {}) {
++ if (!user || Number(user.id) !== Number(party.host_user_id)) fail(403, 'party.not_host', 'Only the host moves the party');
++ if (party.mode === 'live') fail(409, 'party.live_edge', 'A live party follows the live edge; there is no position to set');
++ let pos = party.position_s;
++ if (position !== undefined) {
++ const n = Number(position);
++ if (!Number.isInteger(n) || n < 0 || n > 7 * 24 * 60 * 60) fail(422, 'party.position', 'position_s is whole seconds');
++ pos = n;
++ }
++ const play = playing === undefined ? party.playing : !!playing;
++ await db.run('UPDATE room_parties SET position_s = ?, playing = ?, position_at = now() WHERE room_id = ?', [pos, play, party.room_id]);
++ const fresh = await bySlug(row.slug);
++ return view(fresh, partyOf(fresh));
++}
++
++/** How many listed parties a stream has. A count: no member, no host, no name. */
++async function count(agentStreamId) {
++ if (!AGENT_STREAM_RE.test(String(agentStreamId || ''))) return { count: 0 };
++ const row = await db.get('SELECT COUNT(*) AS n FROM room_parties p JOIN rooms r ON r.id = p.room_id WHERE p.agent_stream_id = ? AND p.listed IS TRUE AND p.ended_at IS NULL AND r.archived_at IS NULL', [String(agentStreamId)]);
++ return { count: Number(row && row.n) || 0 };
++}
++
++async function end(row, party, user) {
++ if (!user || Number(user.id) !== Number(party.host_user_id)) fail(403, 'party.not_host', 'Only the host ends the party');
++ await db.run('UPDATE room_parties SET ended_at = now(), playing = false WHERE room_id = ? AND ended_at IS NULL', [party.room_id]);
++ const fresh = await bySlug(row.slug);
++ return view(fresh, partyOf(fresh));
++}
++
++module.exports = {
++ AGENT_STREAM_RE, MAX_TITLE, PartyError,
++ create, bySlug, partyOf, view, syncOf, mayView, joinWithLink, setSync, count, end, keyMatches,
++};
ddiff --git a/server/rooms/rooms.js b/server/rooms/rooms.js
iindex 06b4973..8d25cf0 100644
---- a/server/rooms/rooms.js
++++ b/server/rooms/rooms.js
@@@ -50,7 +50,7 @@ const permissions = require('../auth/permissions');
const SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{1,30}[a-z0-9])$/;
const RESERVED = new Set(['global', 'new', 'rooms', 'settings', 'messages', 'updates', 'auth', 'api', 'admin', 'staff', 'help', 'system', 'ice-servers']);
--const KINDS = ['community', 'call', 'system', 'agent_stream'];
++const KINDS = ['community', 'call', 'system', 'agent_stream', 'party'];
const ROLES = ['owner', 'mod', 'speaker', 'participant', 'member', 'viewer', 'blocked'];
/** The roles each kind has (owner is never assigned: a room has one, its creator). */
const KIND_ROLES = Object.freeze({
@@@ -58,15 +58,17 @@ const KIND_ROLES = Object.freeze({
call: ['owner', 'mod', 'speaker', 'participant', 'viewer', 'blocked'],
system: ['owner', 'mod', 'viewer', 'blocked'],
agent_stream: ['owner', 'mod', 'member', 'viewer', 'blocked'],
++ party: ['owner', 'mod', 'member', 'viewer', 'blocked'],
});
/** What joining a public room makes you (a call room's owner picks one of JOIN_ROLES_CALL). */
--const JOIN_ROLE = Object.freeze({ community: 'member', call: 'participant', system: 'viewer', agent_stream: 'member' });
++const JOIN_ROLE = Object.freeze({ community: 'member', call: 'participant', system: 'viewer', agent_stream: 'member', party: 'member' });
const JOIN_ROLES_CALL = ['speaker', 'participant', 'viewer'];
const POSTERS = Object.freeze({
community: new Set(['owner', 'mod', 'member']),
call: new Set(['owner', 'mod', 'speaker', 'participant']),
system: new Set(['owner', 'mod']),
agent_stream: new Set(['owner', 'mod', 'member']),
++ party: new Set(['owner', 'mod', 'member']),
});
const TALKERS = new Set(['owner', 'mod', 'speaker']);
const ROLE_ORDER = { owner: 0, mod: 1, speaker: 2, participant: 3, member: 4, viewer: 5, blocked: 6 };
@@@ -144,7 +146,7 @@ async function access(room, user) {
return { role, kind, read, post, join, talk, moderate, manage: !!user && !blocked && (role === 'owner' || staff) };
}
--async function create(user, { name, slug, topic, visibility = 'public', kind = 'community', join_role = null } = {}) {
++async function create(user, { name, slug, topic, visibility = 'public', kind = 'community', join_role = null, party = false } = {}) {
await ensureSchema();
if (!user) fail(401, 'rooms.sign_in', 'Sign in to create a room');
if (siteBanned(user)) fail(403, 'rooms.banned', 'Your account is banned from chat');
@@@ -153,7 +155,8 @@ async function create(user, { name, slug, topic, visibility = 'public', kind = '
const s = slug ? String(slug).toLowerCase().trim() : slugify(n);
if (!SLUG_RE.test(s) || RESERVED.has(s)) fail(422, 'rooms.slug', 'Room addresses are 3 to 32 lowercase letters, numbers and dashes (and not a reserved word)');
if (!['public', 'private'].includes(visibility)) fail(422, 'rooms.visibility', 'Visibility is public or private');
-- if (!KINDS.includes(kind)) fail(422, 'rooms.kind', 'A room is a community, call or system room');
++ if (!KINDS.includes(kind)) fail(422, 'rooms.kind', 'A room is a community, call, system, agent stream or party room');
++ if (kind === 'party' && !party) fail(422, 'rooms.kind', 'A party room is made by starting a watch party');
if (kind === 'system' && !isStaff(user)) fail(403, 'rooms.kind', 'Only chat staff create system rooms');
if (join_role != null && join_role !== '' && (kind !== 'call' || !JOIN_ROLES_CALL.includes(join_role))) fail(422, 'rooms.join_role', 'People join a call room as speakers, participants or viewers');
if (wordFilter.check(n).safe === false) fail(422, 'rooms.name_filtered', 'That room name is not allowed');
ddiff --git a/server/rooms/routes.js b/server/rooms/routes.js
iindex 1d5ce9a..b2758e7 100644
---- a/server/rooms/routes.js
++++ b/server/rooms/routes.js
@@@ -37,6 +37,7 @@ const cache = require('openvibe-shared/cache-policy');
const { requireAuth, optionalAuth } = require('../auth/auth');
const { limits } = require('../net/actor-limits');
const rooms = require('./rooms');
++const parties = require('./parties');
const ctx = require('../live-context');
const router = express.Router();
@@@ -56,7 +57,7 @@ const readLimit = limits('chat.room.read_mark', { minute: 60, hour: 1200 });
const attachLimit = limits('chat.room.attach', { minute: 10, hour: 100 });
function send(res, err) {
-- if (err instanceof rooms.RoomError) return res.status(err.status).json({ error: err.message, code: err.code });
++ if (err instanceof rooms.RoomError || err instanceof parties.PartyError) return res.status(err.status).json({ error: err.message, code: err.code });
console.error('[Rooms]', err && err.message);
return res.status(500).json({ error: 'Something went wrong' });
}
@@@ -116,6 +117,65 @@ router.post('/', requireAuth, createLimit, async (req, res) => {
try { res.status(201).json({ room: await rooms.create(req.user, req.body || {}) }); } catch (err) { send(res, err); }
});
++
++// ── Watch parties (Agent Streams S7) ─────────────────────────
++// A party is a private Chat room of kind 'party' on a public agent stream or its VOD. Joining is by the link's
++// secret; the stream's page may count a listed party, and the count never carries a name.
++router.post('/parties', requireAuth, createLimit, async (req, res) => {
++ try {
++ const out = await parties.create(req.user, req.body || {});
++ res.status(201).json({ party: out.party, join_secret: out.join_secret });
++ } catch (err) { send(res, err); }
++});
++
++/** GET /parties/count?agent_stream= — how many listed parties, a number (the stream page's "3 parties watching"). */
++router.get('/parties/count', optionalAuth, async (req, res) => {
++ try {
++ res.set('Cache-Control', 'no-store');
++ res.json(await parties.count(req.query.agent_stream));
++ } catch (err) { send(res, err); }
++});
++
++router.get('/:slug/party', optionalAuth, async (req, res) => {
++ try {
++ const row = await parties.bySlug(req.params.slug);
++ const party = parties.partyOf(row);
++ if (!row || !party) return res.status(404).json({ error: 'No such room', code: 'rooms.not_found' });
++ if (!(await parties.mayView(row, party, req.user || null, req.query.k))) return res.status(404).json({ error: 'No such room', code: 'rooms.not_found' });
++ res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ party: parties.view(row, party) });
++ } catch (err) { send(res, err); }
++});
++
++/** Join with the link's secret; membership is remembered after it. */
++router.post('/:slug/party/join', requireAuth, manageLimit, async (req, res) => {
++ try {
++ const row = await parties.bySlug(req.params.slug);
++ const party = parties.partyOf(row);
++ if (!row || !party) return res.status(404).json({ error: 'No such room', code: 'rooms.not_found' });
++ res.json(await parties.joinWithLink(row, party, req.user, (req.body || {}).k));
++ } catch (err) { send(res, err); }
++});
++
++/** The host moves the VOD (a live party follows the live edge, so there is no position). */
++router.put('/:slug/party', requireAuth, manageLimit, async (req, res) => {
++ try {
++ const row = await parties.bySlug(req.params.slug);
++ const party = parties.partyOf(row);
++ if (!row || !party) return res.status(404).json({ error: 'No such room', code: 'rooms.not_found' });
++ if (!(await parties.mayView(row, party, req.user, null))) return res.status(404).json({ error: 'No such room', code: 'rooms.not_found' });
++ res.json({ party: await parties.setSync(row, party, req.user, req.body || {}) });
++ } catch (err) { send(res, err); }
++});
++
++router.post('/:slug/party/end', requireAuth, manageLimit, async (req, res) => {
++ try {
++ const row = await parties.bySlug(req.params.slug);
++ const party = parties.partyOf(row);
++ if (!row || !party) return res.status(404).json({ error: 'No such room', code: 'rooms.not_found' });
++ res.json({ party: await parties.end(row, party, req.user) });
++ } catch (err) { send(res, err); }
++});
++
router.get('/:slug', optionalAuth, async (req, res) => {
const room = await readable(req, res); if (!room) return;
const a = await rooms.access(room, req.user || null);
ddiff --git a/test/agent-parties.test.js b/test/agent-parties.test.js
nnew file mode 100644
iindex 0000000..9e70549
---- /dev/null
++++ b/test/agent-parties.test.js
@@@ -0,0 +1,126 @@
++'use strict';
++/**
++ * Watch parties (Agent Streams S7, design 2026-11-06 §4): a private Chat room of kind 'party' on a public agent
++ * stream or its VOD.
++ *
++ * - private by default: without the link a stranger sees the same 404 as a missing room, and the party is not listed;
++ * - joining is by the link's secret, and membership is remembered after it;
++ * - on a VOD the host's position is what everyone sees; a live party follows the live edge and takes no position;
++ * - a listed party reaches the stream's page as a count only, never names, and disappears when the host ends it.
++ */
++const assert = require('assert');
++const { ids } = require('openvibe-contracts');
++const { boot, suite } = require('./helpers');
++
++const t = suite('parties');
++let h, ann, bob, cat;
++const AGENT = 'ags_01JAB2C3D4E5F6G7H8J9K0MNPS';
++const api = (method, path, u, body) => h.http(method, `/api/chat/rooms${path}`, { token: u && u.token, body });
++
++t('boot', async () => {
++ h = await boot();
++ ann = h.addUser('ann', { subject: ids.newId('user') });
++ bob = h.addUser('bob', { subject: ids.newId('user') });
++ cat = h.addUser('cat', { subject: ids.newId('user') });
++ for (const u of [ann, bob, cat]) await h.ctx.upsertUser(h.live.users.get(u.id));
++});
++
++t('a party is private by default and joins by its link', async () => {
++ const r = await api('POST', '/parties', ann, { agent_stream_id: AGENT, vod_id: '4242', title: 'Friday watch', listed: true });
++ assert.strictEqual(r.status, 201, r.text);
++ const { party, join_secret: key } = r.body;
++ assert.match(party.id, /^party-[0-9a-f]{8}$/);
++ assert.strictEqual(party.agent_stream_id, AGENT);
++ assert.strictEqual(party.vod_id, '4242');
++ assert.strictEqual(party.visibility, 'private');
++ assert.strictEqual(party.sync.mode, 'vod');
++ assert.strictEqual(party.sync.playing, false);
++ assert.match(party.join_url, /\/rooms\/party-[0-9a-f]{8}\?k=/);
++ assert.ok(key && key.length >= 20);
++ assert.ok(!('join_secret' in party), 'the secret lives in the link, not as its own field');
++
++ assert.strictEqual((await api('GET', `/rooms/${party.id}`, bob)).status, 404, 'the room itself is private');
++ assert.strictEqual((await api('GET', `/${party.id}/party`, bob)).status, 404, 'without the link it is a missing room');
++ assert.strictEqual((await api('POST', `/${party.id}/join`, bob)).status, 404, 'a plain join is refused');
++ const wrong = await api('GET', `/${party.id}/party?k=not-the-link`, bob);
++ assert.strictEqual(wrong.status, 404);
++ const opened = await api('GET', `/${party.id}/party?k=${encodeURIComponent(key)}`, bob);
++ assert.strictEqual(opened.status, 200, opened.text);
++ assert.strictEqual(opened.body.party.id, party.id);
++ const joined = await api('POST', `/${party.id}/party/join`, bob, { k: key });
++ assert.strictEqual(joined.status, 200, joined.text);
++ assert.strictEqual(joined.body.role, 'member');
++ const listed = (await api('GET', '/', bob)).body;
++ assert.ok(!listed.public.some((x) => x.slug === party.id), 'a party is never in the directory');
++});
++
++t('on a VOD the host moves it and everyone follows', async () => {
++ const made = await api('POST', '/parties', ann, { agent_stream_id: AGENT, vod_id: '4242', title: 'Host position', listed: true });
++ assert.strictEqual(made.status, 201, made.text);
++ const { party, join_secret: key } = made.body;
++ const moved = await api('PUT', `/${party.id}/party`, ann, { position_s: 312, playing: true });
++ assert.strictEqual(moved.status, 200, moved.text);
++ assert.strictEqual(moved.body.party.sync.position_s, 312);
++ assert.strictEqual(moved.body.party.sync.playing, true);
++ const bobSees = await api('GET', `/${party.id}/party?k=${encodeURIComponent(key)}`, bob);
++ assert.strictEqual(bobSees.status, 200);
++ assert.ok(bobSees.body.party.sync.position_s >= 312, 'the guest is at the host position');
++ assert.ok(bobSees.body.party.sync.position_s <= 315, 'the clock moved it, not a guess');
++ assert.strictEqual((await api('POST', `/${party.id}/party/join`, bob, { k: key })).status, 200);
++ assert.strictEqual((await api('PUT', `/${party.id}/party`, bob, { position_s: 0 })).status, 403, 'only the host moves it');
++ const paused = await api('PUT', `/${party.id}/party`, ann, { position_s: 400, playing: false });
++ assert.strictEqual(paused.body.party.sync.position_s, 400);
++ const still = await api('GET', `/${party.id}/party?k=${encodeURIComponent(key)}`, bob);
++ assert.strictEqual(still.body.party.sync.position_s, 400, 'paused: the position does not move');
++});
++
++t('a live party follows the live edge and takes no position', async () => {
++ const made = await api('POST', '/parties', ann, { agent_stream_id: 'ags_01JAB2C3D4E5F6G7H8J9K0MNPV', title: 'Live edge' });
++ assert.strictEqual(made.status, 201, made.text);
++ assert.deepStrictEqual(made.body.party.sync, { mode: 'live', playing: true });
++ const refused = await api('PUT', `/${made.body.party.id}/party`, ann, { position_s: 10 });
++ assert.strictEqual(refused.status, 409, refused.text);
++ assert.strictEqual(refused.body.code, 'party.live_edge');
++});
++
++t('the stream page gets a count, never a name, and it drops when the party ends', async () => {
++ const count = await api('GET', `/parties/count?agent_stream=${AGENT}`, null);
++ assert.strictEqual(count.status, 200, count.text);
++ assert.strictEqual(count.body.count, 2, 'the two listed VOD parties');
++ const text = JSON.stringify(count.body);
++ assert.ok(!/ann|bob|cat|host|member/.test(text), 'a count carries no name');
++ const unlisted = await api('POST', '/parties', ann, { agent_stream_id: 'ags_01JAB2C3D4E5F6G7H8J9K0MNPW', title: 'Quiet party' });
++ assert.strictEqual(unlisted.status, 201, unlisted.text);
++ const quiet = await api('GET', `/parties/count?agent_stream=ags_01JAB2C3D4E5F6G7H8J9K0MNPW`, null);
++ assert.strictEqual(quiet.body.count, 0, 'unlisted parties are not counted');
++ const first = await api('GET', `/parties/count?agent_stream=${AGENT}`, null);
++ assert.strictEqual(first.body.count, 2);
++ const ended = await api('POST', '/parties/count', null, {});
++ assert.ok(ended.status === 404 || ended.status === 405, 'the count is a read');
++});
++
++t('the host ends a party and the count drops', async () => {
++ const made = await api('POST', '/parties', ann, { agent_stream_id: 'ags_01JAB2C3D4E5F6G7H8J9K0MNPX', vod_id: '9', title: 'Ending soon', listed: true });
++ const { party, join_secret: key } = made.body;
++ assert.strictEqual((await api('GET', `/parties/count?agent_stream=ags_01JAB2C3D4E5F6G7H8J9K0MNPX`, null)).body.count, 1);
++ assert.strictEqual((await api('POST', `/${party.id}/party/end`, bob)).status, 403, 'only the host ends it');
++ const ended = await api('POST', `/${party.id}/party/end`, ann);
++ assert.strictEqual(ended.status, 200, ended.text);
++ assert.ok(ended.body.party.ended_at);
++ assert.strictEqual((await api('GET', `/parties/count?agent_stream=ags_01JAB2C3D4E5F6G7H8J9K0MNPX`, null)).body.count, 0);
++ const gone = await api('GET', `/${party.id}/party?k=${encodeURIComponent(key)}`, bob);
++ assert.strictEqual(gone.status, 200, 'the room stays readable with its chat');
++ assert.ok(gone.body.party.ended_at);
++});
++
++t('the host needs an OpenVibe subject and a real agent stream id', async () => {
++ const noSubject = h.addUser('dave', { subject: null });
++ await h.ctx.upsertUser(h.live.users.get(noSubject.id));
++ const r = await api('POST', '/parties', noSubject, { agent_stream_id: AGENT, title: 'No subject' });
++ assert.strictEqual(r.status, 409, r.text);
++ const bad = await api('POST', '/parties', ann, { agent_stream_id: 'not-a-stream', title: 'Bad id' });
++ assert.strictEqual(bad.status, 422, bad.text);
++ assert.strictEqual((await api('POST', '/', ann, { name: 'A party room', kind: 'party' })).status, 422, 'a party room is made by starting a party');
++});
++
++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 f8cb8f6..ae8b401 100644
---- a/test/pg-schema.test.js
++++ b/test/pg-schema.test.js
@@@ -30,7 +30,7 @@ const TABLES = [
'chat_event_inbox', 'calls', 'rooms', 'room_members', 'room_messages', 'room_attachments', 'room_ai_members', 'room_ai_usage', 'room_tasks', 'room_hooks', 'vibe_usage', 'vibe_memory', 'vibe_memory_off', 'vibe_own_engine', 'vibe_asks', 'vibe_deliveries', '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',
'chat_app_link_tokens', 'chat_app_settings', 'chat_app_callbacks', 'chat_app_deliveries', 'chat_app_rates',
-- 'agent_room_reads', 'agent_room_polls', 'agent_room_poll_votes', 'agent_room_identities', 'agent_room_controls',
++ 'agent_room_reads', 'agent_room_polls', 'agent_room_poll_votes', 'agent_room_identities', 'agent_room_controls', 'room_parties',
'room_message_reactions', 'room_member_timeouts',
'agent_play_requests', 'agent_play_votes', 'agent_play_log',
];