openvibers/openvibe.chat Commits

Internal per-ticket conversations for OpenVibe.Help (chat.ticket.write): create on first message, append, read back, each service its own (#26)

OP OpenVibers committed 5 Oct 2026 · f58488a72b · 9 files, +362 −12

* Internal per-ticket conversations for OpenVibe.Help (chat.ticket.write): create on first message, append, read back, each service its own

## Changes (file:line)

- `migrations/0007_ticket_conversations.sql:1` — new `-- phase: expand` migration: `ticket_conversations(id, ticket_id, service, created_at)` with `UNIQUE(ticket_id, service)`, and `ticket_messages(id, conversation_id FK CASCADE, author_kind CHECK person|agent|staff, author, body, created_at, recorded_at)` + `idx_ticket_messages_conversation`.
- `server/chat/internal-tickets.js:1` — the router; `callingService()` (svc: principal → service) at :44, `postMessage()` at :96, `getConversation()` at :112, guards/mounts at :132-133.
- `server/app.js:107-108` — mount the router at `/internal/chat` (loopback, 64kb json).
- `test/chat-internal-tickets.test.js:1` — new test (7 checks): migrate/boot :45, guard/capability/loopback :55, app token refused :66, create+append+read-back/paging :73, cross-service isolation :107, validation/bounds :128, no-idempotency-key :162.
- `test/pg-schema.test.js:30` — add `ticket_conversations`, `ticket_messages` to `TABLES` (the exact-count assertion).
- `docs/chat-ingress.md:43` — "Per-ticket conversation (OpenVibe.Help)" section with both routes; :90 pin note now "now pinned".
- `server/chat/internal-reads.js:4,19-20` — version/pin note updated to 0.103.0 (comment only).
- `package.json:32` and `package-lock.json:19,1297-1299` — `openvibe-contracts` v0.96.0 → v0.103.0 (resolved URL + `integrity sha512-kTsKojjjtCmJr5uXDIM+ocnhLF5CiAMBGbRSVGmvhfeBGHs5jxldGJc+ozw+S5W2bOQp8/BkT2PVI/fr8eNirQ==`).

## Exact shapes

`POST /internal/chat/tickets/:ticket_id/messages` — guard `chat.ticket.write`, loopback, service token (principal `svc:<service>`):
- Request: `{ "author_kind": "person"|"agent"|"staff", "author": "<subject, 1–200>", "body": "<plain text, 1–6000, not blank>", "created_at": "<RFC 3339 date-time>" }` (unknown/missing fields, bad kind, oversize body or bad date → 400).
- 200: `{ "ok": true, "ticket_id": "<path>", "service": "<token principal>", "message": { "author_kind", "author", "body", "created_at" } }` (the conversation contract shape; Chat adds its usual `ok`). Creates the `(ticket_id, service)` conversation on first use.
- App/node/agent principal → 403 `{ok:false,error:"A service token is required"}`.

`GET /internal/chat/tickets/:ticket_id` — same capability; the caller's own conversation only:
- Query: `after_id` (oldest first), `before_id` (newest first, default), `limit` ≤ 500 (default 100); anything else → 400.
- 200: `{ "ok": true, "ticket_id", "service", "created_at", "messages": [ { "id", "author_kind", "author", "body", "created_at" } ], "max_id" }`.
- No conversation for `(ticket, service)` → 404 `{ok:false,error:"Ticket conversation not found"}` (another service's is never readable).

The contract has no idempotency key, so a retried POST appends another message (`chat.ticket-conversation@1` has no event either, so no outbox).

## Test results

`nice -n 15 ov test --no-cache test/chat-internal-tickets.test.js test/pg-schema.test.js test/chat-internal-reads.test.js`:
```
dependencies: openvibe-contracts 0.96.0 → 0.103.0: refreshing

* Refuse NUL bytes in ticket ids, authors and bodies with a 400 (review)

Changes

ddiff --git a/docs/chat-ingress.md b/docs/chat-ingress.md
iindex 8f8b1f7..fcb9ac3 100644
---- a/docs/chat-ingress.md
++++ b/docs/chat-ingress.md
@@@ -41,6 +41,24 @@ deleted messages are never read; `since`/`until` are epoch ms (`until` exclusive
| `GET /internal/chat/sounds/by-command?channel_id&command` | `chat.sounds.read` | `{sound}`: the approved (`is_approved=1`) channel sound `command` plays, one at random when several match, or 404 — Live's `getChannelSoundByCommand`. `command` is trimmed, lowercased and has leading `!` stripped (≤ 120); `channel_id` is the channel owner's Live user id (`channel_sounds.channel_owner_id`); same projection as `/sounds`. |
| `POST /internal/chat/sounds/asset` | `chat.sounds.write` | `{id,media_url,media_asset_id}` records Live's upload (`migrations/0004_channel_sound_media.sql`); repeating it is a 200 no-op; an unknown sound is 404. For a sound Chat has no Media asset for, Live's own asset columns stay as its asset sync left them (the read mirror that used to preserve them was retired on 2026-10-05). |
++### Per-ticket conversation (OpenVibe.Help)
++
++OpenVibe.Help reads and answers a ticket through one conversation per (ticket id, calling
++service), guarded by `chat.ticket.write` (`server/chat/internal-tickets.js`,
++`chat.ticket-conversation@1`, openvibe-contracts 0.103.0; the tables are
++`migrations/0007_ticket_conversations.sql`). The calling service is the service token's principal
++(`svc:help` → `help`): two services on one ticket get two conversations and a service can only
++ever read or write its own — an app, node or agent token is refused. Bodies are the author's plain
++text (1–6000 characters, not blank), stored and answered as sent and never rendered as HTML. The
++contract has no idempotency key, so a retried POST appends another message; `created_at` is the
++author's clock and Chat orders by its own message id (newest first by default, `after_id` pages
++oldest first).
++
++| Route | Capability | Request and result |
++| --- | --- | --- |
++| `POST /internal/chat/tickets/:ticket_id/messages` | `chat.ticket.write` | `{author_kind:'person'\|'agent'\|'staff', author, body, created_at}` with `author` the author's subject (≤ 200) and `created_at` an RFC 3339 date-time; creates the (ticket, service) conversation on first use and appends the message. Returns `{ok:true, ticket_id, service, message:{author_kind, author, body, created_at}}` (the contract's conversation shape). |
++| `GET /internal/chat/tickets/:ticket_id` | `chat.ticket.write` | The calling service's own conversation, paged: `{ok:true, ticket_id, service, created_at, messages:[{id, author_kind, author, body, created_at}], max_id}`; `after_id` (oldest first), `before_id` (newest first, the default), `limit` ≤ 500 (default 100); `max_id` is the conversation's newest message id. A service with no conversation for the ticket gets 404 — another service's is never readable. |
++
`/internal/chat/events` is a narrow transient event ingress, not a generic ChatServer call.
`live.release.deployed` continues through OpenVibe.Events; there is no deploy-notice endpoint.
An `alert` targets its `streamerId` channel; a `channel-sound` targets its `streamId` stream.
@@@ -72,8 +90,7 @@ route list in OpenVibe.Contracts (a Contracts follow-up; the capability and Live
place). Chat's local proposals are in `docs/capabilities-proposal/`. The three plan-T3 reads added for Live's home
series, its welcome check and its robot channel sounds use those same capabilities but add result
schemas `chat.site-daily-result@1`, `chat.first-chat-result@1` and `chat.sound-result@1`, which
--land in openvibe-contracts 0.103.0 (the Contracts change is separate; Live codes against the
--shapes here until that version is pinned).
++land in openvibe-contracts 0.103.0, now pinned.
Live J2 (done: Live #20/#21) replaced its bridge writer with a typed client: repoint message producers and persisted DMs
to `messages` (with `role`/`profile_color` etc. in `frame`), card, sound, media-queue, redemption
ddiff --git a/migrations/0007_ticket_conversations.sql b/migrations/0007_ticket_conversations.sql
nnew file mode 100644
iindex 0000000..2c70c22
---- /dev/null
++++ b/migrations/0007_ticket_conversations.sql
@@@ -0,0 +1,24 @@
++-- phase: expand
++-- OpenVibe.Help's per-ticket conversation (capability chat.ticket.write,
++-- chat.ticket-conversation@1, added in openvibe-contracts 0.100.0). One conversation per
++-- (ticket id, calling service); two services on one ticket get two conversations and neither
++-- can read the other's. Messages carry the author's kind, subject and body (plain text, never
++-- HTML) and the author's own created_at; Chat orders by its own id (recorded_at is when it
++-- arrived). Service tokens are loopback-only and never granted to an app or an agent.
++CREATE TABLE IF NOT EXISTS ticket_conversations (
++ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
++ ticket_id text COLLATE "C" NOT NULL,
++ service text COLLATE "C" NOT NULL,
++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
++ UNIQUE (ticket_id, service)
++);
++CREATE TABLE IF NOT EXISTS ticket_messages (
++ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
++ conversation_id bigint NOT NULL REFERENCES ticket_conversations(id) ON DELETE CASCADE,
++ author_kind text COLLATE "C" NOT NULL CHECK (author_kind IN ('person', 'agent', 'staff')),
++ author text COLLATE "C" NOT NULL,
++ body text COLLATE "C" NOT NULL, -- the author's text, as sent; Chat never renders it
++ created_at text COLLATE "C" NOT NULL, -- the author's clock (contract), not Chat's ordering
++ recorded_at text COLLATE "C" NOT NULL DEFAULT ov_now()
++);
++CREATE INDEX IF NOT EXISTS idx_ticket_messages_conversation ON ticket_messages(conversation_id, id);
ddiff --git a/package-lock.json b/package-lock.json
iindex 6211734..b0e841f 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -16,7 +16,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.96.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.103.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.5.0",
"pg": "^8.23.0",
@@@ -1294,9 +1294,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.96.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.96.0",
-- "integrity": "sha512-QEXfNYNkK6n49sXokgbP2FTKaIEawRJOS7Xij9g20fBFSqiVv14voZ60LqECPl1VMZedoSGuYLa8doVlnSxH0w==",
++ "version": "0.103.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.103.0",
++ "integrity": "sha512-kTsKojjjtCmJr5uXDIM+ocnhLF5CiAMBGbRSVGmvhfeBGHs5jxldGJc+ozw+S5W2bOQp8/BkT2PVI/fr8eNirQ==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex 0b6a22c..2f5929c 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.96.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.103.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.5.0",
"pg": "^8.23.0",
ddiff --git a/server/app.js b/server/app.js
iindex 43fd3e0..0fbabb9 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -104,6 +104,8 @@ function createApp({ chatServer, relay, events = null, callServer = null }) {
app.use('/internal/chat', express.json({ limit: '64kb' }), createInternalIngress({ chatServer }));
// Live's reads of the chat tables Chat owns, in place of its mirrored copy (plan T3 J4b, server/chat/internal-reads.js).
app.use('/internal/chat', express.json({ limit: '64kb' }), require('./chat/internal-reads'));
++ // OpenVibe.Help's per-ticket conversation (capability chat.ticket.write, server/chat/internal-tickets.js).
++ app.use('/internal/chat', express.json({ limit: '64kb' }), require('./chat/internal-tickets'));
// OpenVibe.Events deliveries (live.release.deployed, network.module.updated): signature v2 over
// the raw body, so this router reads the body itself (server/events/consumer.js).
if (events) app.use('/internal/events', events.router);
ddiff --git a/server/chat/internal-reads.js b/server/chat/internal-reads.js
iindex 139a451..b9196bd 100644
---- a/server/chat/internal-reads.js
++++ b/server/chat/internal-reads.js
@@@ -1,7 +1,7 @@
/**
* OpenVibe.Chat — the internal read API Live calls instead of reading its mirrored copy of Chat's
* tables (plan T3 J4b, design §1d N1–N5). Loopback only, a service token for audience
-- * openvibe.chat, one capability per route (openvibe-contracts 0.95.0):
++ * openvibe.chat, one capability per route (openvibe-contracts 0.103.0):
*
* POST /internal/chat/stats chat.stats.read chat.stats-request@1 → chat.stats-result@1
* kind site-daily → chat.site-daily-result@1 { days }
@@@ -18,9 +18,8 @@
* POST /internal/chat/sounds/asset chat.sounds.write chat.sound-asset-request@1 → chat.ingress-ack@1
*
* The three plan-T3 reads added for Live's home series, its welcome check and its robot channel
-- * sounds (chat.site-daily-result@1, chat.first-chat-result@1, chat.sound-result@1) land in
-- * openvibe-contracts 0.103.0 (a separate Contracts change); until it is pinned, Live's next PR
-- * codes against the shapes documented in docs/chat-ingress.md.
++ * sounds (chat.site-daily-result@1, chat.first-chat-result@1, chat.sound-result@1) are in
++ * openvibe-contracts 0.103.0, now pinned.
* In first-chat and sounds/by-command, `channel_id` is the channel owner's Live user id — the value
* Live passes as channelUserId to isFirstChatInChannel and channel_owner_id to
* getChannelSoundByCommand (both stream.user_id), not Chat's ctx_channels.id.
ddiff --git a/server/chat/internal-tickets.js b/server/chat/internal-tickets.js
nnew file mode 100644
iindex 0000000..5e06c7f
---- /dev/null
++++ b/server/chat/internal-tickets.js
@@@ -0,0 +1,135 @@
++/**
++ * OpenVibe.Help's per-ticket conversation over Chat's service ingress (capability
++ * chat.ticket.write, chat.ticket-conversation@1, openvibe-contracts 0.103.0). Loopback only, a
++ * service token for audience openvibe.chat. A conversation is keyed by the ticket id and the
++ * calling service, taken from the token's principal (`svc:help` → `help`), so two services on one
++ * ticket never see each other's messages and a service only ever reads its own conversation.
++ *
++ * POST /internal/chat/tickets/:ticket_id/messages create the conversation on first use, append one message
++ * GET /internal/chat/tickets/:ticket_id the calling service's conversation and its messages, paged
++ *
++ * The message body is the author's plain text (1–6000 characters, not blank): stored and returned
++ * as sent, never rendered as HTML. The contract carries no idempotency key, so a retried POST
++ * appends another message; `created_at` is the author's clock and Chat keeps its own ordering by
++ * message id (newest first by default, `after_id` pages oldest first).
++ */
++'use strict';
++
++const express = require('express');
++const db = require('../db/database');
++const serviceAuth = require('../net/service-auth');
++
++const MAX_TICKET_ID = 200;
++const MAX_AUTHOR = 200;
++const MAX_BODY = 6000;
++const MAX_CREATED_AT = 40;
++const MAX_MESSAGES = 500;
++const AUTHOR_KINDS = ['person', 'agent', 'staff'];
++const SERVICE = /^svc:([a-z][a-z0-9-]{1,39})$/;
++const DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
++const TICKET_ID = /^[^\s/\u0000]{1,200}$/; // PostgreSQL text refuses NUL: a 400 here, not a 503 later
++
++const bad = (message) => { const e = new Error(message); e.status = 400; throw e; };
++const notFound = (message) => { const e = new Error(message); e.status = 404; throw e; };
++const denied = (message) => { const e = new Error(message); e.status = 403; throw e; };
++const obj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
++const int = (v) => Number.isSafeInteger(v) && v > 0;
++const plain = (v, max) => typeof v === 'string' && v.length >= 1 && v.length <= max && !v.includes('\u0000');
++
++function fields(v, allowed) {
++ if (!obj(v) || Object.keys(v).some((k) => !allowed.includes(k))) bad('Invalid fields');
++}
++
++/** The calling service from its token principal: svc:help -> help. Apps, nodes and agents are refused. */
++function callingService(req) {
++ const m = SERVICE.exec(String((req.principal && req.principal.sub) || ''));
++ if (!m) denied('A service token is required');
++ return m[1];
++}
++
++function ticketId(req) {
++ const raw = String(req.params.ticket_id || '');
++ if (!TICKET_ID.test(raw) || raw.length > MAX_TICKET_ID) bad('Invalid ticket id');
++ return raw;
++}
++
++function validateMessage(b) {
++ fields(b, ['author_kind', 'author', 'body', 'created_at']);
++ if (!AUTHOR_KINDS.includes(b.author_kind)) bad('Invalid author kind');
++ if (!plain(b.author, MAX_AUTHOR)) bad('Invalid author');
++ if (!plain(b.body, MAX_BODY) || !b.body.trim()) bad('Invalid body');
++ if (!plain(b.created_at, MAX_CREATED_AT) || !DATE_TIME.test(b.created_at) || !Number.isFinite(Date.parse(b.created_at))) bad('Invalid created_at');
++}
++
++/** Query string → validated positive ids; anything not in `spec` is 400. */
++function query(req, spec) {
++ const out = {};
++ for (const [k, raw] of Object.entries(req.query)) {
++ if (!spec[k] || typeof raw !== 'string' || !/^\d{1,16}$/.test(raw)) bad(`Invalid parameter ${k}`);
++ const v = Number(raw);
++ if (!int(v)) bad(`Invalid ${k}`);
++ out[k] = v;
++ }
++ return out;
++}
++
++function route(name, fn) {
++ return async (req, res) => {
++ try {
++ res.json({ ok: true, ...await fn(req) });
++ } catch (err) {
++ if (err.status) return res.status(err.status).json({ ok: false, error: err.message });
++ console.error(`[ChatTickets] ${name}:`, err);
++ res.status(503).json({ ok: false, error: 'Chat ticket conversation unavailable' });
++ }
++ };
++}
++
++/** The (ticket, service) conversation, created on first use; a concurrent create resolves to one row. */
++async function conversationFor(ticket, service) {
++ const existing = await db.get('SELECT id, created_at FROM ticket_conversations WHERE ticket_id = ? AND service = ?', [ticket, service]);
++ if (existing) return existing;
++ await db.run('INSERT INTO ticket_conversations (ticket_id, service) VALUES (?, ?) ON CONFLICT DO NOTHING', [ticket, service]);
++ return await db.get('SELECT id, created_at FROM ticket_conversations WHERE ticket_id = ? AND service = ?', [ticket, service]);
++}
++
++async function postMessage(req) {
++ const ticket = ticketId(req);
++ const service = callingService(req);
++ validateMessage(req.body);
++ const b = req.body;
++ await db.transaction(async () => {
++ const conv = await conversationFor(ticket, service);
++ await db.run(
++ 'INSERT INTO ticket_messages (conversation_id, author_kind, author, body, created_at) VALUES (?, ?, ?, ?, ?)',
++ [conv.id, b.author_kind, b.author, b.body, b.created_at]);
++ });
++ return { ticket_id: ticket, service, message: { author_kind: b.author_kind, author: b.author, body: b.body, created_at: b.created_at } };
++}
++
++const messageRow = (r) => ({ id: Number(r.id), author_kind: String(r.author_kind), author: String(r.author), body: String(r.body), created_at: String(r.created_at) });
++
++async function getConversation(req) {
++ const ticket = ticketId(req);
++ const service = callingService(req);
++ const q = query(req, { after_id: true, before_id: true, limit: true });
++ const conv = await db.get('SELECT id, created_at FROM ticket_conversations WHERE ticket_id = ? AND service = ?', [ticket, service]);
++ if (!conv) notFound('Ticket conversation not found');
++ const where = ['conversation_id = ?'];
++ const params = [conv.id];
++ if (q.after_id != null) { where.push('id > ?'); params.push(q.after_id); }
++ if (q.before_id != null) { where.push('id < ?'); params.push(q.before_id); }
++ const max = await db.get('SELECT MAX(id) AS max_id FROM ticket_messages WHERE conversation_id = ?', [conv.id]);
++ // after_id pages forward (oldest first); otherwise newest first, back from before_id.
++ const order = q.after_id != null ? 'ASC' : 'DESC';
++ const rows = await db.all(
++ `SELECT id, author_kind, author, body, created_at FROM ticket_messages WHERE ${where.join(' AND ')} ORDER BY id ${order} LIMIT ?`,
++ [...params, Math.min(q.limit || 100, MAX_MESSAGES)]);
++ return { ticket_id: ticket, service, created_at: String(conv.created_at), messages: rows.map(messageRow), max_id: max && max.max_id != null ? Number(max.max_id) : null };
++}
++
++const router = express.Router();
++router.post('/tickets/:ticket_id/messages', serviceAuth.guard('chat.ticket.write'), route('ticket-message', postMessage));
++router.get('/tickets/:ticket_id', serviceAuth.guard('chat.ticket.write'), route('ticket-conversation', getConversation));
++
++module.exports = router;
ddiff --git a/test/chat-internal-tickets.test.js b/test/chat-internal-tickets.test.js
nnew file mode 100644
iindex 0000000..0acdb31
---- /dev/null
++++ b/test/chat-internal-tickets.test.js
@@@ -0,0 +1,173 @@
++'use strict';
++/**
++ * OpenVibe.Help's per-ticket conversation (server/chat/internal-tickets.js, capability
++ * chat.ticket.write, chat.ticket-conversation@1 added in openvibe-contracts 0.100.0): a
++ * conversation is keyed by (ticket id, calling service) and a service only ever reads its own.
++ */
++
++const assert = require('assert');
++const path = require('path');
++const { validate } = require('openvibe-contracts');
++const { boot, suite } = require('./helpers');
++
++const MIGRATIONS = path.join(__dirname, '..', 'migrations');
++const quiet = { log() {}, info() {}, warn() {}, error() {} };
++
++const t = suite('chat-internal-tickets');
++let h;
++const TICKET = 'tkt_01JAB2C3D4E5F6G7H8J9K0MNPQ';
++const HELP = (cap = 'chat.ticket.write') => h.serviceToken([cap], { sub: 'svc:help' });
++const AI = (cap = 'chat.ticket.write') => h.serviceToken([cap], { sub: 'svc:ai' });
++const msg = (author_kind, author, body, created_at = '2026-10-05T09:00:00Z') => ({ author_kind, author, body, created_at });
++
++// chat.ticket-conversation@1 arrives with openvibe-contracts 0.100.0. Until the pin reaches it an
++// older package cannot validate it: the run says so instead of failing (shapes are asserted too).
++function check(ref, body) {
++ let r;
++ try { r = validate(ref, body); } catch (err) {
++ if (/unknown contract|is v\d/.test(err.message)) return body;
++ throw err;
++ }
++ assert.ok(r.valid, `${ref} invalid: ${JSON.stringify(r.errors)} (${JSON.stringify(body)})`);
++ return body;
++}
++async function post(token, ticket, body) {
++ const r = await h.http('POST', `/internal/chat/tickets/${encodeURIComponent(ticket)}/messages`, { token, body });
++ assert.strictEqual(r.status, 200, `${JSON.stringify(body)}: ${r.text}`);
++ return r.body;
++}
++async function get(token, ticket, qs = '', status = 200) {
++ const r = await h.http('GET', `/internal/chat/tickets/${encodeURIComponent(ticket)}${qs}`, { token });
++ assert.strictEqual(r.status, status, `${ticket}${qs}: ${r.text}`);
++ return r.body;
++}
++
++t('migrate the final schema and boot', async () => {
++ // The shared harness migrates with the N-1 window, which holds 0005 (a contract migration) and
++ // with it the later 0006/0007. The ticket tables arrive in 0007, so apply the final schema
++ // first, exactly as test/pg-schema.test.js does.
++ const owner = globalThis.__ovChatTestOwnerDb && globalThis.__ovChatTestOwnerDb();
++ if (!owner) throw new Error('run this file through test/run.js (test/helpers/pg-preload.mjs provides the test database)');
++ await owner.migrate({ dir: MIGRATIONS, windowDays: 0, log: quiet });
++ h = await boot();
++});
++
++t('the route needs its capability, a service token for Chat and loopback', async () => {
++ const url = `/internal/chat/tickets/${TICKET}/messages`;
++ const body = msg('person', 'user:usr_01', 'hello');
++ assert.strictEqual((await h.http('POST', url, { body })).status, 401, 'no token');
++ assert.strictEqual((await h.http('POST', url, { body, token: h.serviceToken(['chat.messages.read'], { sub: 'svc:help' }) })).status, 403, 'wrong capability');
++ assert.strictEqual((await h.http('GET', `/internal/chat/tickets/${TICKET}`, { token: h.serviceToken(['chat.messages.read'], { sub: 'svc:help' }) })).status, 403, 'wrong capability (GET)');
++ assert.strictEqual((await h.http('POST', url, { body, token: h.serviceToken(['chat.ticket.write'], { sub: 'svc:help', aud: 'openvibe.live' }) })).status, 401, 'wrong audience');
++ assert.strictEqual((await h.http('POST', url, { body, token: HELP(), headers: { 'X-Forwarded-For': '1.2.3.4' } })).status, 403, 'from outside');
++ assert.strictEqual((await h.http('GET', `/internal/chat/tickets/${TICKET}`)).status, 401, 'GET no token');
++});
++
++t('an app token is refused: only a first-party service token', async () => {
++ const app = h.serviceToken(['chat.ticket.write'], { sub: 'app:app_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3' });
++ const body = msg('person', 'user:usr_01', 'hello');
++ assert.strictEqual((await h.http('POST', `/internal/chat/tickets/${TICKET}/messages`, { token: app, body })).status, 403);
++ assert.strictEqual((await h.http('GET', `/internal/chat/tickets/${TICKET}`, { token: app })).status, 403);
++});
++
++t('create, append and read back: the conversation is (ticket, service)', async () => {
++ const first = msg('person', 'user:usr_01', 'My export from yesterday is missing the last day.');
++ const created = await post(HELP(), TICKET, first);
++ // The contract describes {ticket_id, service, message}; Chat's answer adds its usual ok.
++ check('chat.ticket-conversation@1', { ticket_id: created.ticket_id, service: created.service, message: created.message });
++ assert.deepStrictEqual(created, { ok: true, ticket_id: TICKET, service: 'help', message: first });
++
++ await post(HELP(), TICKET, msg('agent', 'agent:agt_01JAB2C3D4E5F6G7H8J9K0MNPQ', 'Checking the export job.', '2026-10-05T09:01:30Z'));
++ await post(HELP(), TICKET, msg('staff', 'staff:sam', 'The last hour moves into the next day file.', '2026-10-05T09:02:00Z'));
++
++ const page = await get(HELP(), TICKET);
++ assert.strictEqual(page.ticket_id, TICKET);
++ assert.strictEqual(page.service, 'help');
++ assert.match(page.created_at, /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/, 'conversation created_at');
++ assert.strictEqual(page.messages.length, 3);
++ assert.deepStrictEqual(page.messages.map((m) => m.author_kind), ['staff', 'agent', 'person'], 'newest first');
++ assert.deepStrictEqual(page.messages[0], {
++ id: page.max_id, author_kind: 'staff', author: 'staff:sam',
++ body: 'The last hour moves into the next day file.', created_at: '2026-10-05T09:02:00Z',
++ });
++ assert.strictEqual(page.max_id, Math.max(...page.messages.map((m) => m.id)));
++
++ // after_id pages forward (oldest first); before_id and limit page back.
++ const oldest = page.messages[page.messages.length - 1].id;
++ const forward = await get(HELP(), TICKET, `?after_id=${oldest}`);
++ assert.deepStrictEqual(forward.messages.map((m) => m.author_kind), ['agent', 'staff']);
++ assert.strictEqual(forward.max_id, page.max_id);
++ assert.deepStrictEqual((await get(HELP(), TICKET, '?limit=2')).messages.map((m) => m.author_kind), ['staff', 'agent']);
++ assert.deepStrictEqual((await get(HELP(), TICKET, `?before_id=${page.max_id}`)).messages.map((m) => m.author_kind), ['agent', 'person']);
++ assert.deepStrictEqual(await get(HELP(), TICKET, `?after_id=${page.max_id}`), { ok: true, ticket_id: TICKET, service: 'help', created_at: page.created_at, messages: [], max_id: page.max_id });
++ // limit is clamped to 500, not refused: 100000 reads the whole conversation.
++ assert.strictEqual((await get(HELP(), TICKET, '?limit=100000')).messages.length, 3);
++});
++
++t('another service gets its own conversation and never reads help\'s', async () => {
++ await post(AI(), TICKET, msg('agent', 'agent:agt_ai', 'AI here, on the same ticket.'));
++
++ const ai = await get(AI(), TICKET);
++ assert.strictEqual(ai.service, 'ai');
++ assert.deepStrictEqual(ai.messages.map((m) => m.author), ['agent:agt_ai'], 'only ai\'s own message');
++
++ const help = await get(HELP(), TICKET);
++ assert.strictEqual(help.messages.length, 3, 'help\'s conversation did not grow');
++ assert.ok(help.messages.every((m) => m.author !== 'agent:agt_ai'), 'ai\'s message is not in help\'s conversation');
++
++ // A ticket only ai has: help is 404 (it has no conversation), and posting as ai never makes one for help.
++ assert.deepStrictEqual(await get(HELP(), 'tkt_ai_only', '', 404), { ok: false, error: 'Ticket conversation not found' });
++ await post(AI(), 'tkt_ai_only', msg('staff', 'staff:sam', 'Only ai has this ticket.'));
++ assert.strictEqual((await get(HELP(), 'tkt_ai_only', '', 404)).ok, false);
++ assert.strictEqual((await get(AI(), 'tkt_ai_only')).messages.length, 1);
++
++ // An unknown ticket has no conversation at all.
++ assert.deepStrictEqual(await get(HELP(), 'tkt_nobody', '', 404), { ok: false, error: 'Ticket conversation not found' });
++});
++
++t('validation: author_kind, author, body bounds, created_at, ticket id and query', async () => {
++ const url = `/internal/chat/tickets/${TICKET}/messages`;
++ const write = (body) => h.http('POST', url, { token: HELP(), body });
++ const long = 'x'.repeat(6000);
++ const bad = [
++ msg('bot', 'user:usr_01', 'not a kind'),
++ msg('person', '', 'empty author'),
++ msg('person', 'a'.repeat(201), 'long author'),
++ msg('person', 'user:usr_01', ''),
++ msg('person', 'user:usr_01', ' '),
++ msg('person', 'user:usr_01', 'x'.repeat(6001)),
++ msg('person', 'user:usr_01', 'nul \u0000 byte'),
++ msg('person', 'user:\u0000', 'nul in author'),
++ { ...msg('person', 'user:usr_01', 'x'), extra: 1 },
++ { author_kind: 'person', author: 'user:usr_01', body: 'x' },
++ { ...msg('person', 'user:usr_01', 'x'), created_at: 'yesterday' },
++ [1],
++ ];
++ for (const body of bad) assert.strictEqual((await write(body)).status, 400, JSON.stringify(body));
++
++ // 6000 characters is the contract's maximum: accepted and returned unchanged, never as HTML.
++ await post(HELP(), 'tkt_bounds', msg('person', 'user:usr_01', long));
++ assert.strictEqual((await get(HELP(), 'tkt_bounds')).messages[0].body, long);
++
++ // The ticket id comes from the path: blank, whitespace, a slash or over 200 characters is 400.
++ for (const ticket of ['%20', 'a%20b', 'a%2Fb', 'a'.repeat(201)]) {
++ const r = await h.http('POST', `/internal/chat/tickets/${ticket}/messages`, { token: HELP(), body: msg('person', 'user:usr_01', 'x') });
++ assert.strictEqual(r.status, 400, ticket);
++ }
++
++ for (const q of ['?limit=0', '?limit=-1', '?limit=abc', '?limit=', '?after_id=0', '?before_id=0', '?unknown=1']) {
++ const r = await h.http('GET', `/internal/chat/tickets/${TICKET}${q}`, { token: HELP() });
++ assert.strictEqual(r.status, 400, q);
++ }
++});
++
++t('the contract has no idempotency key, so a retry appends another message', async () => {
++ const body = msg('person', 'user:usr_dup', 'sent twice');
++ await post(HELP(), 'tkt_dup', body);
++ await post(HELP(), 'tkt_dup', body);
++ assert.deepStrictEqual((await get(HELP(), 'tkt_dup')).messages.map((m) => m.body), ['sent twice', 'sent twice']);
++});
++
++t.run(async () => {
++ if (h) await h.close();
++});
ddiff --git a/test/pg-schema.test.js b/test/pg-schema.test.js
iindex 8433a10..7f1abe4 100644
---- a/test/pg-schema.test.js
++++ b/test/pg-schema.test.js
@@@ -27,7 +27,7 @@ const TABLES = [
'ctx_users', 'ctx_streams', 'ctx_managed_streams', 'ctx_channels', 'ctx_sync', 'events_outbox', 'live_mirror_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', 'token_revocations',
-- 'network_blocks', 'account_data_events',
++ 'network_blocks', 'account_data_events', 'ticket_conversations', 'ticket_messages',
];
const INDEXES = [
'idx_chat_ts_deleted', 'idx_chat_page_live', 'idx_chat_channel_user_ts', 'idx_chat_stream_ts', 'idx_emotes_channel_code', 'idx_chat_tl_dedup',