openvibers/openvibe.tips Commits
Deliver paid chat effects through OpenVibe.Chat's ingress
Changes
@@@ -63,10 +63,13 @@ BILLING_URL=http://127.0.0.1:4600TIPS_EVENTS_SECRET=# ── Chat delivery ───────────────────────────────────────────--# live-chat (Live's /internal/tips/deliveries, docs/live-patch.diff) | test | none (production default)++# chat (OpenVibe.Chat's /internal/chat/messages + /internal/chat/events) | test | none (production default)++# chat needs the tips principal granted chat.message.send and chat.event.publish (audience openvibe.chat)++# and identity.subject.resolve (the creator's Live user id is Chat's room).#TIPS_CHAT_ADAPTER=none--#LIVE_INTERNAL_URL=http://127.0.0.1:3000--#LIVE_AUDIENCE=openvibe.live++#TIPS_CHAT_URL=http://127.0.0.1:4400++#CHAT_AUDIENCE=openvibe.chat++#OV_NETWORK_AUDIENCE=openvibe.network#TIPS_DELIVERY_MAX_ATTEMPTS=6#TIPS_DELIVERY_BACKOFF_MS=2000,10000,60000,300000,900000@@@ -44,8 +44,9 @@ from *interaction delivered*.PowerChat, once Billing receives the PowerChat webhook)- **OpenVibe.Events** — Billing's events in (signed webhook + openvibe-sdk inbox), Tips' events out(openvibe-sdk transactional outbox)--- **OpenVibe.Network** — SSO for people, service tokens, JWKS, identity resolve--- **OpenVibe.Live** — chat delivery through `/internal/tips/deliveries` (deployed in Live since `f11f809`; [docs/live-patch.diff](docs/live-patch.diff) is the original patch) until OpenVibe.Chat exposes paid messages; overlay consumer++- **OpenVibe.Network** — SSO for people, service tokens, JWKS, identity resolve (a creator's Chat room)++- **OpenVibe.Chat** — chat delivery through its typed ingress (`/internal/chat/messages`, `/internal/chat/events`)++- **OpenVibe.Live** — overlay consumer- **OpenVibe.Shared** v2.2.0 (app icon, SSR footer, noscript nav, release manifest, legal pages, boost page moves) andthe Network's `navbar.js`; **openvibe-contracts** v0.79.0 and **openvibe-sdk** v0.25.0 (service tokens,the async data layer `openvibe-sdk/db`, the PostgreSQL outbox and inbox — the events outbox is the SDK's@@@ -65,7 +66,8 @@ Called elsewhere, as the service principal `tips`:|---|---|---|| OpenVibe.Billing | `billing.intent.create`, `billing.transfer.create` | checkout, and a tip from credit || OpenVibe.Events | `events.event.publish`; `events.subscription.manage` once (`npm run subscribe`) | the outbox relay; the `billing.transaction.*` and `billing.receipt.*` subscriptions |--| OpenVibe.Live | `live.tips_delivery.write` (only with `TIPS_CHAT_ADAPTER=live-chat`) | chat lines and alerts through `/internal/tips/deliveries` |++| OpenVibe.Chat | `chat.message.send`, `chat.event.publish` (only with `TIPS_CHAT_ADAPTER=chat`) | donation lines and TTS through `/internal/chat/messages`, the alert through `/internal/chat/events` |++| OpenVibe.Network | `identity.subject.resolve` (only with `TIPS_CHAT_ADAPTER=chat`) | the creator's Live user id (Chat's room) from `/internal/identity/resolve` |## Run it@@@ -73,7 +75,7 @@ Called elsewhere, as the service principal `tips`:fnm exec --using=22.22.1 npm installcp .env.example .env # OV_OAUTH_CLIENT_SECRET, TIPS_FORM_SECRET, TIPS_EVENTS_SECRET, …npm run dev # http://localhost:4610 (without DATABASE_URL: an embedded PGlite database in data/pglite)--npm test # every test/*.test.js: stub Network/Billing/Events/Live, PGlite, random ports++npm test # every test/*.test.js: stub Network/Billing/Events/Chat, PGlite, random portseval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)" # PostgreSQL 18 + PgBouncer + Valkey 9 containersnpm test # … now also test/integration.test.js on the containersnpm run test:pg # every test file through PgBouncer and Valkey@@@ -95,7 +97,7 @@ payment_state pending ──► settled ──► reversed mirrored frodelivery_state awaiting_payment ──► queued ──► delivered | failed | cancelled```--A delivery failure (Live down, chat refused) is retried with backoff and finally recorded on the++A delivery failure (Chat down, chat refused) is retried with backoff and finally recorded on theeffect; it never touches `payment_state`. A reversal after delivery flips `payment_state` and keeps thedelivery record; a reversal before delivery cancels the queued effects (`tips.interaction.cancelled`).@@@ -284,19 +286,29 @@ within `TIPS_OVERLAY_TTL_MS` becomes `failed` (`tips.overlay.failed`). A reconne## Chat delivery`server/delivery/` — an adapter is `{ name, deliver(job) → { ref } }` (throw to fail; `permanent` stops--retries). `live-chat` posts to Live's `/internal/tips/deliveries` with Tips' service token (audience--`openvibe.live`, `live.tips_delivery.write`), `Idempotency-Key = <interaction>:<effect>`; Live does what--`POST /api/funds/donate` does after the money moved (channel + global broadcast, a `donation` chat--message, the alert sound), TTS through `synthesizeAndBroadcastTTS`, media requests into its queue at no--charge. A job carries the interaction's public view and `privacy`: "Anonymous" and no subject for an--anonymous supporter, `interaction.amount: null` and a line without the amount when it is hidden, no--private message. (Live's route turns a null amount into a `donation` event of 0 Vibes, which its chat--renders as "donated 0 Vibes"; it should render an amount-less line when `privacy.hide_amount` is set.)--The route came from [docs/live-patch.diff](docs/live-patch.diff) and is deployed in Live since--`f11f809` (its idempotency record is in memory only); nothing calls it yet. `test`--records jobs in memory (development; simulations always use it). `none` (the production default) creates--no chat effects, and it is what production runs. When OpenVibe.Chat publishes a paid-message capability, a `chat` adapter replaces--`live-chat`.++retries). `chat` (`server/delivery/chat.js`) posts to OpenVibe.Chat's typed ingress at `TIPS_CHAT_URL`++with Tips' service token (audience `openvibe.chat`; OpenVibe.Chat `docs/chat-ingress.md`):++++| Effect | Chat call | Body |++|---|---|---|++| `chat_line`, `paid_message` | `POST /internal/chat/messages` (`chat.message.send`) | `channel_user_id`, `username` (the public name), `message` (the line), `message_type: donation`, `source_platform: tips`, `mirror: true` (global chat too), `metadata { kind: donation, source, amount, message, username, interaction_id, paid_message, highlight_seconds, test }` |++| | then `POST /internal/chat/events` (`chat.event.publish`) | `target { kind: channel, id }`, `frame { type: alert, streamerId, kind: donation }` (the alert sound) |++| `tts` | `POST /internal/chat/messages` | a `tts` line (`message` = the filtered TTS text) with `tts { voice, identity_key: tips:<interaction>, key: tips-<interaction> }`: Chat saves it and reads it aloud |++| `media_request` | none | fails at once: the media queue is not Chat's |++++Every body's `key` is the effect's delivery id (`<interaction>:<effect>`; the alert `<delivery id>:alert`):++Chat applies a key once and answers a repeat with the first result, so a retry after a 5xx or a lost answer++never posts twice. A 4xx (other than 401/408/425/429) fails the effect at once, everything else is retried++with backoff; a refused alert does not undo the line (`ref.alert: false`). Chat's rooms are Live's ids: the++adapter reads the creator's Live user id from the Network (`GET /internal/identity/resolve`, kept ten++minutes); a creator with no Live account fails at once. The body is rebuilt on each attempt from the++interaction's current public view, so a word-filter change between attempts makes Chat answer 409, which++fails the effect. A job carries the interaction's public view and `privacy`: "Anonymous" and no subject++for an anonymous supporter, `amount: null` and a line without the amount when it is hidden, no private++message. `test` records jobs in memory (development; simulations always use it). `none` (the production++default) creates no chat effects, and it is what production runs. To enable delivery: grant the `tips`++principal the three capabilities above, then set `TIPS_CHAT_ADAPTER=chat` (and `TIPS_CHAT_URL` when Chat++is not on `127.0.0.1:4400`).## Pages (server-rendered, useful without JavaScript)@@@ -317,7 +329,7 @@ references to Billing transaction ids, never a balance. People sign in with Netwclient-credentials tokens for audience `openvibe.tips`, one capability per route. Overlays use revocablescoped tokens, hashed at rest, never creator cookies. Billing's events arrive signed(`TIPS_EVENTS_SECRET`) through an inbox that dedupes them; `/internal/` and `/metrics` are never public.--Tips calls only its configured Network, Billing, Events and Live hosts. Secrets (`OV_OAUTH_CLIENT_SECRET`,++Tips calls only its configured Network, Billing, Events and Chat hosts. Secrets (`OV_OAUTH_CLIENT_SECRET`,`TIPS_FORM_SECRET`, `TIPS_EVENTS_SECRET`) live in `/etc/openvibe/tips.env` (0600).[docs/threat-review.md](docs/threat-review.md) is the written threat review: overlays (tokenised URLs),@@@ -361,10 +373,10 @@ With `VALKEY_URL` every process counts one caller together (`test/integration.te|---|---|| a duplicate provider webhook yields one Billing transaction and one logical interaction | `test/settlement.test.js` (same event redelivered, same transaction under a new event id, foreign donation delivered three times) || overlay replay never charges again | `test/overlays.test.js` (three `Last-Event-ID` replays: transfers, contributions, goal total, payable, events unchanged) |--| the creator can use openvibe.tips with Live offline | pages, API, overlays and settlement need no Live call (`test/pages.test.js`, `test/overlays.test.js` run with no Live); only the `live-chat` adapter talks to Live and its failure never touches payment (`test/delivery.test.js`) — **not yet demonstrated on the real host** |++| the creator can use openvibe.tips with Live offline | pages, API, overlays and settlement need no Live call (`test/pages.test.js`, `test/overlays.test.js` run with no Live); chat delivery goes to OpenVibe.Chat and its failure never touches payment (`test/delivery.test.js`) — **not yet demonstrated on the real host** || creator totals reconcile exactly to Billing | `test/api.test.js` (totals = the stub Billing payable) || simulation never counted; token revocation immediate; goals from settled only; reversal keeps the delivery record | `test/overlays.test.js`, `test/api.test.js`, `test/settlement.test.js` |--| an EXTERNAL PowerChat tip Billing announced is celebrated once: Live chat line, overlay, goal; never Billing money | `test/external.test.js` (redelivery and republish, same key as `POST /interactions/external`, Tips goal ids, anonymous, test receipts, malformed payloads) |++| an EXTERNAL PowerChat tip Billing announced is celebrated once: chat line, overlay, goal; never Billing money | `test/external.test.js` (redelivery and republish, same key as `POST /interactions/external`, Tips goal ids, anonymous, test receipts, malformed payloads) || paid messages are filtered before they are shown or read; the creator and their moderators hide and show them without touching the money | `test/moderation.test.js` (mask, hold and release, invisible/full-width evasion, TTS text, invitation links, hide cancels queued chat/TTS and retracts the overlay live/replay/state, restore, pending payments, who may moderate, event payloads) || the suite passes on PostgreSQL behind PgBouncer in transaction mode; one settlement or reversal raced across two processes counts once; the unpaid-checkout limit holds under concurrency; leased work runs once; flushing Valkey changes no money answer (ADR-007 2026-09-24, ADR-035) | `npm run test:pg`; `test/integration.test.js` (two app instances on one database and one Valkey) || a supporter's privacy holds everywhere; the creator chooses what public pages show; export and erasure keep the books reconciled | `test/privacy.test.js` (anonymous / hidden amount / private message across API, overlays, chat jobs, pages and events; goal and supporters page settings; export; erasure scrubs rows, overlay payloads, stored answers and unsent events, emits `tips.interaction.erased`, totals still equal Billing) |@@@ -376,13 +388,13 @@ With `VALKEY_URL` every process counts one caller together (`test/integration.teBilling transfers. For PowerChat checkout, Billing answers with a `checkout_ref` and no URL — Tips needs`TIPS_POWERCHAT_LINK_TEMPLATE` or Billing returning the link.- **EXTERNAL PowerChat tips** arrive as `billing.receipt.external` once Billing is the authority. They need-- the `billing.receipt.*` subscription (`npm run subscribe`) and `TIPS_CHAT_ADAPTER=live-chat`; Live's-- `/internal/tips/deliveries` posts the chat line and plays the alert but does not advance **Live's own**++ the `billing.receipt.*` subscription (`npm run subscribe`) and `TIPS_CHAT_ADAPTER=chat`; the chat++ adapter posts the chat line and plays the alert but does not advance **Live's own**`donation_goals` or send its `goal-update`/`goal-reached` frames (Tips' goals and overlay do advance).PowerChat follow/host/channel-points/subscription notices are not forwarded by anyone.--- **Chat**: `live-chat` needs `TIPS_CHAT_ADAPTER=live-chat` after the cutover (Live's route is deployed); OpenVibe.Chat has no public-- paid-message/TTS capability yet. The Live patch's media-request path (yt-dlp/oEmbed) is not covered by-- its test.++- **Chat**: `TIPS_CHAT_ADAPTER=chat` needs the Network to grant `tips` `chat.message.send` and++ `chat.event.publish` (audience `openvibe.chat`) and `identity.subject.resolve`. Paid media requests have++ no delivery route (Chat does not own the media queue): with `chat` they are recorded failed.- **Refunds of paid media requests** that never played (Billing's `POST /transfers/:id/refund`) are notoffered in Tips yet; a refund made elsewhere arrives as `billing.transaction.reversed` and is applied.- Legal pages use the shared `ugc` profile, which does not describe payments.@@@ -7,11 +7,11 @@"adr": "ADR-012","authoritative": false,"deployed": true,-- "deployment": "Deployed internally, not launched: deployed with `sudo ovhost deploy tips`, /opt/openvibe.tips, unit openvibe-tips, 127.0.0.1:4610 loopback only; https://openvibe.tips still serves the OpenVibe.Sites placeholder. Principal tips; Events subscriptions billing.transaction.* and billing.receipt.* -> /internal/events (secret rotated 2026-09-24); TIPS_CHAT_ADAPTER is empty (no chat side effects).",++ "deployment": "Deployed internally, not launched: deployed with `sudo ovhost deploy tips`, /opt/openvibe.tips, unit openvibe-tips, 127.0.0.1:4610 loopback only; https://openvibe.tips still serves the OpenVibe.Sites placeholder. Principal tips; Events subscriptions billing.transaction.* and billing.receipt.* -> /internal/events (secret rotated 2026-09-24); TIPS_CHAT_ADAPTER is empty (no chat side effects); TIPS_CHAT_ADAPTER=chat delivers through OpenVibe.Chat's ingress once tips is granted chat.message.send, chat.event.publish and identity.subject.resolve.","database": "PostgreSQL 18 through PgBouncer (transaction mode) as the system of record, Valkey 9 for shared state (per-actor limits, overlay fan-out, overlay stream slots), ADR-035: database ov_tips from OpenVibe.Host's data role, schema in migrations/ (0001_initial expand, 0002_drop_legacy_import contract), async on openvibe-sdk/db v0.25.0, money paths serializable with row locks, PostgreSQL outbox and inbox.","contracts": "openvibe-contracts v0.79.0","notes": "Runtime, API, SSR pages, overlays, the Billing settlement consumer and the events outbox are built, tested and deployed, together with /metrics (loopback only), truthful /api/ready (Billing and Events checks), supporter privacy (anonymous, hidden amount, private message), goal and supporter-page settings, export and erasure (tips.interaction.erased), moderation (word filter, hide/show, invited moderators, tips.interaction.moderated) and per-actor limits. Capabilities (18 tips.*) and payload schemas are in openvibe-contracts. No billing.* event had arrived: Billing runs in shadow. A written internal threat review (docs/threat-review.md) with its fixes and test/security.test.js. Not done: an independent security review; the openvibe.tips launch. Live stays the place tips happen until the Billing cutover (blocked on the owner re-pointing the PowerChat webhook) and the launch rule in README.md holds.",-- "updated": "2026-09-29",++ "updated": "2026-10-04","features": ["tips, paid messages, TTS and media requests settled by OpenVibe.Billing (never Tips' own money)","goals counted from settled payments only",@@@ -19,7 +19,7 @@"EXTERNAL PowerChat tips from billing.receipt.external","supporter privacy, export and erasure","moderation: word filter, hide and show, invited moderators",-- "Live chat delivery adapter",++ "OpenVibe.Chat delivery adapter (typed ingress)","server-rendered creator pages useful without JavaScript","/api/v1 by capability","per-actor limits (openvibe-sdk/limits)",ddeleted file mode 100644@@@ -1,19 +0,0 @@--{-- "id": "live.tips_delivery.write",-- "version": "1.0",-- "owner": "live",-- "status": "active",-- "visibility": "internal",-- "description": "OpenVibe.Tips' live-chat adapter: announce a settled tip in the creator's Live chat (donation line, paid message, TTS, media request at no charge). Owned by Live (docs/live-patch.diff adds the route); proposed here for Live's manifest.",-- "permissions": [-- "live:chat"-- ],-- "resourceConstraints": [-- "none"-- ],-- "quotaClass": "standard",-- "events": [],-- "implementedBy": [-- "POST /internal/tips/deliveries"-- ]--}ddeleted file mode 100644@@@ -1,232 +0,0 @@--diff --git a/server/index.js b/server/index.js--index 4bba504..63c0969 100644--+++ b/server/index.js--@@ -564,6 +564,8 @@ app.get('/internal/analytics-summary', (req, res) => {-- const chatLiveContext = require('./chat/live-context-routes');-- app.use('/internal/chat-context', chatLiveContext.contextRouter);-- app.use('/internal/chat-effects', chatLiveContext.effectsRouter);--+// OpenVibe.Tips announces settled tips in the creator's chat here (service token, live.tips_delivery.write).--+app.use('/internal/tips', require('./tips/delivery-routes'));-- app.use('/internal', require('./internal/routes'));------diff --git a/server/tips/delivery-routes.js b/server/tips/delivery-routes.js--new file mode 100644--index 0000000..ff08b97--+++ b/server/tips/delivery-routes.js--@@ -0,0 +1,108 @@--+'use strict';--+/**--+ * OpenVibe.Tips → Live: announce a settled tip in the creator's Live chat (Tips' live-chat--+ * delivery adapter, roadmap Wave 9). The money already moved in OpenVibe.Billing; this route only--+ * does what the tail of POST /api/funds/donate does after the balance changed.--+ *--+ * POST /internal/tips/deliveries capability live.tips_delivery.write (Network service token,--+ * audience openvibe.live; loopback-only like every /internal route)--+ * Idempotency-Key: <interaction id>:<effect> a repeat answers the first result and does nothing--+ * body { delivery_id, effect, test, creator: { type, id: usr_… }, supporter: { name, subject? },--+ * interaction: { id, kind, amount, currency, message }, text, tts?: { text, voice },--+ * media?: { url }, highlight_seconds?, target?: { service: 'live', type: 'stream', id } }--+ * → 200 { ok: true, ref } 404 creator has no Live channel (permanent) 409 not live (retried)--+ *--+ * chat_line | paid_message donation event to the channel room + global, saved as a 'donation'--+ * chat message (metadata.source 'tips'), the donation alert sound--+ * tts synthesizeAndBroadcastTTS on the live stream (or the offline channel room)--+ * media_request into the media queue at cost 0 — Billing charged it, Tips holds the record--+ */--+const express = require('express');--+const db = require('../db/database');--+const { guard } = require('../net/service-guard');--+--+const router = express.Router();--+const done = new Map(); // Idempotency-Key → response (Tips retries within minutes; bounded)--+const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;--+--+function channelUserId(subject) {--+ if (!SUBJECT_RE.test(String(subject || ''))) return null;--+ const row = db.getDb().prepare("SELECT user_id FROM linked_accounts WHERE service = 'network' AND subject_id = ? ORDER BY id DESC LIMIT 1").get(subject);--+ return row ? row.user_id : null;--+}--+--+function streamIdFor(target, userId) {--+ if (target && target.service === 'live' && target.type === 'stream') {--+ const s = db.getStreamById(Number(target.id));--+ if (s && s.user_id === userId && s.is_live) return s.id;--+ }--+ const live = db.getStreamByUserId(userId);--+ return live ? live.id : null;--+}--+--+function remember(key, out) {--+ done.set(key, out);--+ if (done.size > 5000) done.delete(done.keys().next().value);--+ return out;--+}--+--+router.post('/deliveries', guard('live.tips_delivery.write'), express.json({ limit: '32kb' }), async (req, res) => {--+ const key = String(req.get('idempotency-key') || '');--+ if (!/^[A-Za-z0-9_:.-]{8,200}$/.test(key)) return res.status(400).json({ error: 'Idempotency-Key required' });--+ if (done.has(key)) return res.json(done.get(key));--+ const b = req.body || {};--+ const i = b.interaction || {};--+ const userId = channelUserId(b.creator && b.creator.id);--+ if (!userId) return res.status(404).json({ error: 'this creator has no Live channel' });--+ const chatServer = require('../chat/chat-server');--+ const streamId = streamIdFor(b.target, userId);--+ const name = String((b.supporter && b.supporter.name) || 'Someone').slice(0, 80);--+ const amount = Math.max(0, Math.round(Number(i.amount) || 0));--+ const message = String(i.message || '').slice(0, 500);--+ try {--+ if (b.effect === 'chat_line' || b.effect === 'paid_message') {--+ const event = {--+ type: 'donation', username: name, user_id: null, avatar_url: null, amount, message, timestamp: new Date().toISOString(),--+ source: 'tips', paid_message: b.effect === 'paid_message', highlight_seconds: Number(b.highlight_seconds) || 0,--+ };--+ chatServer.broadcastToChannelRoom(userId, streamId, event);--+ try { chatServer.broadcastGlobal({ ...event, global: true, channel_user_id: userId }); } catch { /* non-critical */ }--+ const saved = db.saveChatMessage({--+ stream_id: streamId, channel_user_id: userId, user_id: null, username: name,--+ message: String(b.text || `${name} tipped ${amount.toLocaleString()} Vibes${message ? ': ' + message : ''}`).slice(0, 1000),--+ message_type: 'donation',--+ metadata: { kind: 'donation', amount, message, username: name, source: 'tips', interaction_id: i.id || null, paid_message: event.paid_message, highlight_seconds: event.highlight_seconds, test: !!b.test },--+ });--+ require('../monetization/alerts').playAlertSound(chatServer, userId, streamId, 'donation');--+ return res.json(remember(key, { ok: true, ref: { chat_message_id: saved && saved.lastInsertRowid != null ? Number(saved.lastInsertRowid) : null } }));--+ }--+ if (b.effect === 'tts') {--+ const text = String((b.tts && b.tts.text) || '').slice(0, 1200);--+ if (!text) return res.status(422).json({ error: 'no text' });--+ await chatServer.synthesizeAndBroadcastTTS(streamId, name, text, null, 'tips', `tips:${i.id}`, userId, `tips-${i.id}`);--+ return res.json(remember(key, { ok: true, ref: { stream_id: streamId } }));--+ }--+ if (b.effect === 'media_request') {--+ const mq = require('../media/media-queue');--+ const settings = mq.getSettings(userId);--+ const normalized = await mq.normalizeInput(String((b.media && b.media.url) || ''), settings);--+ const max = Number(settings.max_duration_seconds) || 600;--+ if (Number.isFinite(normalized.duration_seconds) && normalized.duration_seconds > max) return res.status(422).json({ error: `longer than ${max} seconds` });--+ const r = db.createMediaRequest({--+ streamer_id: userId, stream_id: streamId, user_id: null, username: name, input: String(b.media.url),--+ canonical_url: normalized.canonical_url, embed_url: normalized.embed_url, provider: normalized.provider, title: normalized.title,--+ thumbnail_url: normalized.thumbnail_url, duration_seconds: normalized.duration_seconds,--+ // Paid through OpenVibe.Billing and recorded by OpenVibe.Tips: Live charges nothing here.--+ cost: 0, currency: 'free', queue_position: db.getMediaRequestMaxQueuePosition(userId) + 1,--+ });--+ mq.broadcastQueueUpdate(userId);--+ return res.json(remember(key, { ok: true, ref: { media_request_id: Number(r.lastInsertRowid) } }));--+ }--+ return res.status(422).json({ error: `unknown effect ${b.effect}` });--+ } catch (err) {--+ console.warn('[Tips delivery]', b.effect, err.message);--+ return res.status(422).json({ error: err.message });--+ }--+});--+--+module.exports = router;--diff --git a/test/tips-delivery.test.js b/test/tips-delivery.test.js--new file mode 100644--index 0000000..2c3730a--+++ b/test/tips-delivery.test.js--@@ -0,0 +1,99 @@--+'use strict';--+--+// OpenVibe.Tips → Live (roadmap Wave 9): POST /internal/tips/deliveries announces a settled tip in the--+// creator's chat — service token with live.tips_delivery.write, idempotent per delivery id, the--+// creator found by Network subject, no Live balance touched.--+--+const assert = require('assert');--+const fs = require('fs');--+const os = require('os');--+const path = require('path');--+const http = require('http');--+const crypto = require('crypto');--+const express = require('express');--+const { serviceAuth } = require('openvibe-contracts');--+--+const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-tips-delivery-'));--+const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });--+fs.writeFileSync(path.join(tmp, 'network.pem'), keys.publicKey);--+fs.mkdirSync(path.join(tmp, 'sounds'));--+process.env.DB_PATH = path.join(tmp, 'live.db');--+process.env.OV_NETWORK_PUBLIC_KEY = path.join(tmp, 'network.pem');--+process.env.OV_NETWORK_URL = 'https://openvibe.network';--+process.env.SOUNDS_PATH = path.join(tmp, 'sounds');--+console.log = () => {};--+console.warn = () => {};--+--+const ISS = 'https://openvibe.network';--+const SUBJECT = 'usr_01J0000000000000000000000Z';--+const now = () => Math.floor(Date.now() / 1000);--+let jti = 0;--+const token = (cap, aud = 'openvibe.live') => serviceAuth.signServiceToken({ iss: ISS, sub: 'svc:tips', actor_type: 'service', aud: [aud], cap, iat: now(), exp: now() + 300, jti: `tok_tips_${++jti}` }, keys.privateKey);--+--+(async () => {--+ const db = require('../server/db/database');--+ db.initDb();--+ const d = db.getDb();--+ d.prepare("INSERT INTO users (id, username, display_name, password_hash, openvibe_bucks_balance, openvibe_bucks_cashout_balance) VALUES (501, 'alex', 'Alex', 'x', 0, 0)").run();--+ d.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (501, 'network', '77', ?)").run(SUBJECT);--+--+ const chatServer = require('../server/chat/chat-server');--+ const broadcasts = [];--+ chatServer.broadcastToChannelRoom = (uid, sid, ev) => broadcasts.push({ uid, sid, ev });--+ chatServer.broadcastGlobal = () => {};--+ const tts = [];--+ chatServer.synthesizeAndBroadcastTTS = async (...a) => { tts.push(a); };--+--+ const app = express();--+ app.use('/internal/tips', require('../server/tips/delivery-routes'));--+ const server = await new Promise((r) => { const s = http.createServer(app); s.listen(0, '127.0.0.1', () => r(s)); });--+ const base = `http://127.0.0.1:${server.address().port}`;--+ const post = (body, { cap = ['live.tips_delivery.write'], key = 'tint_1:chat_line', headers = {} } = {}) => fetch(`${base}/internal/tips/deliveries`, {--+ method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token(cap)}`, 'Idempotency-Key': key, ...headers }, body: JSON.stringify(body),--+ }).then(async (r) => ({ status: r.status, json: await r.json().catch(() => null) }));--+--+ const job = {--+ delivery_id: 'tint_1:chat_line', effect: 'chat_line', test: false, creator: { type: 'user', id: SUBJECT },--+ supporter: { name: 'Viewer' }, interaction: { id: 'tint_1', kind: 'tip', amount: 250, currency: 'vibes-bits', message: 'gg' },--+ text: 'Viewer tipped 250 Vibes: gg',--+ };--+--+ assert.strictEqual((await post(job, { cap: ['live.chat_effects.write'] })).status, 403, 'needs live.tips_delivery.write');--+ assert.strictEqual((await post(job, { headers: { 'X-Forwarded-For': '1.2.3.4' } })).status, 403, 'loopback only');--+--+ const a = await post(job);--+ assert.strictEqual(a.status, 200);--+ assert.ok(a.json.ref.chat_message_id > 0);--+ assert.strictEqual(broadcasts.length, 1);--+ assert.strictEqual(broadcasts[0].uid, 501);--+ assert.strictEqual(broadcasts[0].ev.type, 'donation');--+ assert.strictEqual(broadcasts[0].ev.amount, 250);--+ const saved = d.prepare("SELECT * FROM chat_messages WHERE message_type = 'donation'").get();--+ assert.strictEqual(saved.message, 'Viewer tipped 250 Vibes: gg');--+ assert.strictEqual(JSON.parse(saved.metadata).source, 'tips');--+--+ // A retry of the same delivery changes nothing.--+ const b = await post(job);--+ assert.deepStrictEqual(b.json, a.json);--+ assert.strictEqual(broadcasts.length, 1);--+ assert.strictEqual(d.prepare("SELECT COUNT(*) AS n FROM chat_messages WHERE message_type = 'donation'").get().n, 1);--+--+ // TTS on the (offline) channel room.--+ const t = await post({ ...job, effect: 'tts', tts: { text: 'read me', voice: 'gary' } }, { key: 'tint_1:tts' });--+ assert.strictEqual(t.status, 200);--+ assert.strictEqual(tts[0][2], 'read me');--+ assert.strictEqual(tts[0][6], 501);--+--+ // Unknown creator: permanent refusal.--+ const u = await post({ ...job, creator: { type: 'user', id: 'usr_01J0000000000000000000000Y' } }, { key: 'tint_2:chat_line' });--+ assert.strictEqual(u.status, 404);--+--+ // No Live balance moved.--+ const user = d.prepare('SELECT openvibe_bucks_balance, openvibe_bucks_cashout_balance FROM users WHERE id = 501').get();--+ assert.deepStrictEqual(user, { openvibe_bucks_balance: 0, openvibe_bucks_cashout_balance: 0 });--+ assert.strictEqual(d.prepare("SELECT COUNT(*) AS n FROM transactions").get().n, 0);--+--+ server.close();--+ process.stdout.write('tips-delivery: all passed\n');--+ process.exit(0);--+})().catch((e) => { process.stderr.write(`${e.stack}\n`); process.exit(1); });@@@ -6,7 +6,8 @@ This is a written internal review, not an independent audit. The roadmap's gateoutside review before launch.**Method:** we read the code paths end to end (`server/`, `public/js/overlay.js`,--`deploy/nginx/openvibe.tips.conf`), and Live's `/internal/tips/deliveries` where Tips depends on it.++`deploy/nginx/openvibe.tips.conf`), and OpenVibe.Chat's `/internal/chat/messages` and `/internal/chat/events`++where Tips depends on them.Every claim below is backed by a test in `test/` or names the line that enforces it. Nothing was runagainst production. Production runs Tips on loopback only, with an empty database, so none of theseissues was ever reachable from the internet.@@@ -22,7 +23,7 @@ issues was ever reachable from the internet.| OBS / an overlay client | an overlay token in the URL | `/overlay/<token>`, `/events` (SSE), `/state` || First-party service | a Network service token with `tips.*` capabilities | `/api/v1` routes, one capability each || OpenVibe.Billing (via Events) | the webhook signing secret | `POST /internal/events` on loopback |--| OpenVibe.Live | Tips' service token (`live.tips_delivery.write`) | Tips calls Live; Live never calls Tips |++| OpenVibe.Chat | Tips' service token (`chat.message.send`, `chat.event.publish`) | Tips calls Chat; Chat never calls Tips |**What we protect:**@@@ -52,7 +53,7 @@ documented), **elsewhere** (the fix belongs to another repository).| 12 | Replay | Webhook, API, form, overlay and event replays. | **holds** | See [Replay](#replay). `test/settlement.test.js`, `test/overlays.test.js`, `test/api.test.js`, `test/pages.test.js` || 13 | Privacy | A hidden amount still moves a public goal bar by that amount. | **residual** | Stated on the tip form. The goal update of such a tip names nobody. || 14 | Privacy | Internal events carry the amount, and the supporter's subject when the tip is not anonymous. | **residual** | The events are `internal` visibility. Goals and reconciliation need the amount. Erasure redacts them in Events. |--| 15 | Privacy, chat | Live's delivery route turns a hidden amount (`null`) into a donation event of 0 Vibes, and Live's chat renders it as "donated 0 Vibes". Live also remembers delivery ids in memory only. | **elsewhere** (Live) | The job carries `privacy.hide_amount`. Live should render a line with no amount. |++| 15 | Privacy, chat | A tip with a hidden amount is posted to Chat as a donation line whose text has no amount and whose `metadata.amount` is `null`. Chat persists the delivery key. | **elsewhere** (Chat) | Chat should render the line's text, never `metadata.amount` as 0. || 16 | Overlays | Overlay tokens never expire. | **residual** | The creator can revoke a token, and revocation closes open streams at once. Every token records when it was last used. || 17 | Rate limits | Tips has no rate limiting of its own. | **residual** | The nginx reference config limits `/api/`, `/auth/` and the form posts. Money limits paid actions, and fixes 3 and 4 bound the rest. || 18 | Paid-message abuse | Supporter names are still free text: only an exact copy of the creator's name is refused. Donor names that Billing relays from PowerChat are not checked. | **residual** | The word filter and moderation cover names. |@@@ -101,7 +102,7 @@ documented), **elsewhere** (the fix belongs to another repository).- **Injection.**- Pages escape everything with `esc()`, and JSON-LD escapes `<`.- The overlay uses `textContent`.-- - Chat lines are plain text that Tips builds. Live renders them with its own escaping, which is Live's++ - Chat lines are plain text that Tips builds. Chat renders them with its own escaping, which is Chat'sresponsibility.- **Impersonation and Unicode tricks:** fixed (#5 and #6). Long runs of combining marks are cut at display.- **Media requests** cannot be private and go through moderation like paid messages. The URL is shown@@@ -109,8 +110,8 @@ documented), **elsewhere** (the fix belongs to another repository).## TTS--- Tips never calls a speech provider. It hands text to Live's `synthesizeAndBroadcastTTS`, and to-- OpenVibe.Chat when an adapter exists.++- Tips never calls a speech provider. It hands text to OpenVibe.Chat (a `tts` line with `tts: { voice }`),++ which synthesizes and broadcasts it.- The text passes through `cleanTts`, which removes control and invisible characters, turns links into"link" and strips markup (#7). The creator's filter then drops blocked words from it rather thanstarring them. Voices come from an allowlist.@@@ -204,8 +205,7 @@ tests validate them against the drafts.## Follow-ups--1. **Live:** render a tip whose amount is hidden (`privacy.hide_amount`, `interaction.amount: null`)-- without an amount, and persist the delivery idempotency key (#15).++1. **Chat:** render a donation line whose `metadata.amount` is `null` without an amount (#15).2. **Contracts:** add the three items above, then register them in `manifests/services/tips.json`.3. **Before launch:** get an independent review of this document and the code. Consider optional overlaytoken expiry (#16).@@@ -49,10 +49,12 @@ function loadConfig(env = process.env) {internalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),issuer: trim(env.OV_NETWORK_ISSUER || networkUrl),publicKey: env.OV_NETWORK_PUBLIC_KEY ? env.OV_NETWORK_PUBLIC_KEY.replace(/\\n/g, '\n') : null,++ // Tips' service tokens for the Network's own API (identity.subject.resolve: a creator's Chat room).++ audience: env.OV_NETWORK_AUDIENCE || 'openvibe.network',},audience: env.TIPS_AUDIENCE || 'openvibe.tips',// Tips' own client credentials (client `tips` in the Network): service tokens for Billing,-- // Events and Live (chat delivery); and the OAuth code flow for browser sign-in.++ // Events, Chat (delivery) and the Network; and the OAuth code flow for browser sign-in.oauth: {clientId: env.OV_OAUTH_CLIENT_ID || 'tips',clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',@@@ -88,13 +90,13 @@ function loadConfig(env = process.env) {},// Chat delivery (paid messages, TTS, media requests, the tip line):-- // live-chat OpenVibe.Live's /internal/tips/deliveries (docs/live-patch.diff)++ // chat OpenVibe.Chat's /internal/chat/messages and /internal/chat/events (server/delivery/chat.js)// test recorded here, nothing leaves the process (development, tests)// none no chat effects are createdchat: {-- adapter: ['live-chat', 'test', 'none'].includes(chatAdapter) ? chatAdapter : 'none',-- liveUrl: trim(env.LIVE_INTERNAL_URL || 'http://127.0.0.1:3000'),-- liveAudience: env.LIVE_AUDIENCE || 'openvibe.live',++ adapter: ['chat', 'test', 'none'].includes(chatAdapter) ? chatAdapter : 'none',++ url: trim(env.TIPS_CHAT_URL || 'http://127.0.0.1:4400'),++ audience: env.CHAT_AUDIENCE || 'openvibe.chat',maxAttempts: int(env.TIPS_DELIVERY_MAX_ATTEMPTS, 6),backoffMs: list(env.TIPS_DELIVERY_BACKOFF_MS || '2000,10000,60000,300000,900000').map(Number),},nnew file mode 100644@@@ -0,0 +1,130 @@++'use strict';++++/**++ * chat adapter: posts a paid effect into the creator's chat room through OpenVibe.Chat's typed++ * ingress (OpenVibe.Chat docs/chat-ingress.md), with Tips' service token (audience openvibe.chat).++ *++ * chat_line, paid_message POST ${TIPS_CHAT_URL}/internal/chat/messages (chat.message.send)++ * a `donation` line in the creator's channel, mirrored to global chat++ * POST ${TIPS_CHAT_URL}/internal/chat/events (chat.event.publish)++ * the channel's donation alert sound (`alert` frame, kind donation)++ * tts POST /internal/chat/messages with tts: { voice }: Chat saves a `tts` line++ * and reads it aloud++ * media_request none: the media queue is not Chat's, so the effect fails at once++ *++ * Every body carries `key` = the effect's delivery id (`<interaction>:<effect>`; the alert++ * `<delivery id>:alert`). Chat applies a key once per principal and answers a repeat with the first++ * result, so the effects worker's retry after a lost answer or a 5xx never posts twice.++ *++ * Chat's rooms are Live's ids: the creator's channel is their Live user id, read once from the++ * Network's identity map (GET /internal/identity/resolve, identity.subject.resolve) and kept for++ * ROOM_TTL_MS. A creator with no Live account has no room: the effect fails at once.++ *++ * Chat refuses a bad body with a 4xx and asks for a retry with the same key with a 5xx (503 while++ * another Chat process holds the delivery). 4xx answers other than 401/408/425/429 are permanent;++ * everything else is retried by the effects worker. A refused alert does not undo the line.++ */++const { serviceAuth } = require('openvibe-contracts');++++const ROOM_TTL_MS = 10 * 60_000;++const RETRYABLE = [401, 408, 425, 429];++++function createChatAdapter(config, { fetchImpl = globalThis.fetch, tokenClients = {} } = {}) {++ const tokens = { ...tokenClients };++ const client = (audience, scope) => {++ if (!tokens[audience]) {++ tokens[audience] = serviceAuth.createTokenClient({++ tokenUrl: `${config.network.internalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,++ audience, scope, fetchImpl,++ });++ }++ return tokens[audience];++ };++ const chatTokens = () => client(config.chat.audience, 'chat.message.send chat.event.publish');++ const networkTokens = () => client(config.network.audience, 'identity.subject.resolve');++ const rooms = new Map();++++ function refused(who, status, body) {++ const e = new Error(`${who} ${status}: ${(body && (body.error || body.detail || body.code)) || 'refused'}`);++ e.permanent = status >= 400 && status < 500 && !RETRYABLE.includes(status);++ return e;++ }++++ async function call(who, tokenClient, url, init, retried = false) {++ let res;++ try {++ res = await fetchImpl(url, { ...init, headers: { Accept: 'application/json', ...init.headers, ...(await tokenClient.authHeaders()) }, signal: AbortSignal.timeout(10000) });++ } catch (e) {++ throw new Error(`${who} unreachable: ${e.message}`);++ }++ if (res.status === 401 && !retried) { tokenClient.invalidate(); return await call(who, tokenClient, url, init, true); }++ const body = await res.json().catch(() => null);++ return { status: res.status, ok: res.ok, body };++ }++++ /** The creator's Chat room: their Live user id, from the Network's identity map. */++ async function roomOf(subject) {++ const hit = rooms.get(subject);++ if (hit && hit.until > Date.now()) return hit.id;++ const r = await call('Network', networkTokens(), `${config.network.internalUrl}/internal/identity/resolve?subject_id=${encodeURIComponent(subject)}`, { method: 'GET' });++ if (!r.ok && r.status !== 404) throw refused('Network', r.status, r.body);++ const live = r.ok && (r.body.legacy_ids || []).find((x) => x.source_system === 'live' && x.source_type === 'user');++ const id = live ? Number(live.source_id) : 0;++ if (!Number.isSafeInteger(id) || id <= 0) throw Object.assign(new Error('this creator has no Live channel for Chat to post in'), { permanent: true });++ rooms.set(subject, { id, until: Date.now() + ROOM_TTL_MS });++ return id;++ }++++ async function post(family, body) {++ const r = await call('Chat', chatTokens(), `${config.chat.url}/internal/chat/${family}`, {++ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),++ });++ if (!r.ok) throw refused('Chat', r.status, r.body);++ return r.body || {};++ }++++ /** The R1 body of a job (the same on every attempt, so Chat's key replay matches it). */++ function message(job, channel) {++ const i = job.interaction;++ const name = job.supporter.name;++ if (job.effect === 'tts') {++ return {++ key: job.delivery_id, channel_user_id: channel, username: name, message: job.tts.text, message_type: 'tts', source_platform: 'tips',++ tts: { voice: job.tts.voice || null, identity_key: `tips:${i.id}`, key: `tips-${i.id}` },++ metadata: { kind: 'tts', source: 'tips', interaction_id: i.id, test: job.test },++ };++ }++ const paid = job.effect === 'paid_message';++ return {++ key: job.delivery_id, channel_user_id: channel, username: name, message: job.text, message_type: 'donation', source_platform: 'tips', mirror: true,++ metadata: {++ kind: 'donation', source: 'tips', amount: i.amount, message: i.message || '', username: name, interaction_id: i.id,++ paid_message: paid, highlight_seconds: paid ? job.highlight_seconds || 0 : 0, test: job.test,++ },++ };++ }++++ async function deliver(job) {++ if (!['chat_line', 'paid_message', 'tts'].includes(job.effect)) {++ throw Object.assign(new Error(`OpenVibe.Chat has no ${job.effect} route`), { permanent: true });++ }++ if (job.effect === 'tts' && !(job.tts && job.tts.text)) throw Object.assign(new Error('no TTS text'), { permanent: true });++ const channel = await roomOf(job.creator.id);++ const sent = await post('messages', message(job, channel));++ const ref = { chat_message_id: sent.id != null ? sent.id : null };++ if (job.effect !== 'tts') {++ try {++ await post('events', { key: `${job.delivery_id}:alert`, target: { kind: 'channel', id: channel }, frame: { type: 'alert', streamerId: channel, kind: 'donation' } });++ ref.alert = true;++ } catch (e) {++ if (!e.permanent) throw e; // retried: the line replays under its key, the alert is sent++ ref.alert = false;++ }++ }++ return { ref };++ }++++ return { name: 'chat', deliver };++}++++module.exports = { createChatAdapter };@@@ -10,9 +10,9 @@* text, tts?: { text, voice }, media?: { url }, highlight_seconds?, target?: EntityRef }** Adapters (TIPS_CHAT_ADAPTER):-- * live-chat OpenVibe.Live's /internal/tips/deliveries — the existing donation path (broadcast to the-- * channel room + global, saved as a 'donation' chat message, the alert sound); see-- * docs/live-patch.diff. Used until OpenVibe.Chat exposes a public paid-message API.++ * chat OpenVibe.Chat's typed ingress (/internal/chat/messages and /internal/chat/events):++ * a 'donation' line in the creator's channel and global chat, the alert sound, TTS read++ * aloud; see ./chat.js* test records jobs in memory (development, tests, and simulations when no chat target should* hear a test)* none no chat effects are created at all@@@ -20,7 +20,7 @@* A delivery failure is retried with backoff and finally recorded as failed on the effect; it never* touches the interaction's payment state (the money is Billing's and already settled).*/--const { createLiveChatAdapter } = require('./live-chat');++const { createChatAdapter } = require('./chat');function createTestAdapter({ name = 'test' } = {}) {const jobs = [];@@@ -43,7 +43,7 @@ function createTestAdapter({ name = 'test' } = {}) {function createAdapters(config, { fetchImpl } = {}) {const adapters = { test: createTestAdapter() };-- if (config.chat.adapter === 'live-chat') adapters['live-chat'] = createLiveChatAdapter(config, { fetchImpl });++ if (config.chat.adapter === 'chat') adapters.chat = createChatAdapter(config, { fetchImpl });return adapters;}ddeleted file mode 100644@@@ -1,59 +0,0 @@--'use strict';----/**-- * live-chat adapter: posts a paid effect into a creator's OpenVibe.Live chat through Live's-- * existing donation path.-- *-- * POST ${LIVE_INTERNAL_URL}/internal/tips/deliveries-- * Authorization: Bearer <Tips service token, audience openvibe.live, capability live.tips_delivery.write>-- * Idempotency-Key: <delivery_id> (Live answers a repeat with the first result)-- * body: the job (server/delivery/index.js)-- * → 200 { ok: true, ref: { chat_message_id?, media_request_id? } }-- *-- * Live has no such route today: docs/live-patch.diff adds it (server/tips/delivery-routes.js,-- * loopback-only like every /internal route, guarded by Live's service-guard). Live maps the creator-- * subject to its channel through linked_accounts and then does exactly what POST /api/funds/donate-- * does after the money moved: broadcastToChannelRoom + broadcastGlobal of the donation event,-- * saveChatMessage(message_type 'donation'), the donation alert sound; TTS through-- * synthesizeAndBroadcastTTS; media requests into its media queue at no charge (Billing already-- * charged). When OpenVibe.Chat publishes a paid-message capability, a `chat` adapter replaces this.-- *-- * 4xx answers other than 408/409/425/429 are permanent (the effect fails at once); everything-- * else is retried by the effects worker.-- */--const { serviceAuth } = require('openvibe-contracts');----function createLiveChatAdapter(config, { fetchImpl = globalThis.fetch, tokenClient } = {}) {-- let tokens = tokenClient || null;-- const client = () => {-- if (!tokens) {-- tokens = serviceAuth.createTokenClient({-- tokenUrl: `${config.network.internalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,-- audience: config.chat.liveAudience, scope: 'live.tips_delivery.write', fetchImpl,-- });-- }-- return tokens;-- };---- async function deliver(job, retried = false) {-- const headers = { 'Content-Type': 'application/json', Accept: 'application/json', 'Idempotency-Key': job.delivery_id, ...(await client().authHeaders()) };-- let res;-- try {-- res = await fetchImpl(`${config.chat.liveUrl}/internal/tips/deliveries`, { method: 'POST', headers, body: JSON.stringify(job), signal: AbortSignal.timeout(10000) });-- } catch (e) {-- throw new Error(`Live unreachable: ${e.message}`);-- }-- if (res.status === 401 && !retried) { client().invalidate(); return await deliver(job, true); }-- const body = await res.json().catch(() => null);-- if (!res.ok) {-- const e = new Error(`Live ${res.status}: ${(body && (body.detail || body.error || body.code)) || 'refused'}`);-- e.permanent = res.status >= 400 && res.status < 500 && ![401, 408, 409, 425, 429].includes(res.status);-- throw e;-- }-- return { ref: (body && body.ref) || null };-- }---- return { name: 'live-chat', deliver };--}----module.exports = { createLiveChatAdapter };@@@ -8,9 +8,9 @@ const { boot, check, done } = require('./helpers/app');const TYPES = ['tips.interaction.ready', 'tips.interaction.failed', 'tips.interaction.cancelled', 'tips.goal.updated', 'tips.overlay.delivered', 'tips.overlay.failed'];(async () => {-- const t = await boot({ live: true });-- const { domain, billing, live } = t;-- const alex = await t.creator('alex', { settings: { tts_enabled: true } });++ const t = await boot({ chat: true });++ const { domain, billing, chat } = t;++ const alex = await t.creator('alex', { liveId: 501, settings: { tts_enabled: true } });const viewer = t.network.newUser('viewer');billing.fund(viewer.subject, 10_000);@@@ -26,11 +26,11 @@ const TYPES = ['tips.interaction.ready', 'tips.interaction.failed', 'tips.interaawait s.waitFor((e) => e.event === 'alert' && e.data.interaction_id === tip.json.interaction.id);s.close();await domain.effects.drain();-- // A delivery Live refuses (tips.interaction.failed), an overlay nobody showed (tips.overlay.failed).-- live.state.fail = 422;++ // A delivery Chat refuses (tips.interaction.failed), an overlay nobody showed (tips.overlay.failed).++ chat.state.fail = 400;await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: 'alex', amount: 5 } });await domain.effects.drain();-- live.state.fail = null;++ chat.state.fail = null;t.clock.offset += 11 * 60 * 1000;await domain.overlays.sweepFailed();t.clock.offset = 0;@@@ -1,78 +1,160 @@'use strict';--// The live-chat delivery adapter: Tips' service token for Live, one delivery id per effect, retries--// for outages, an immediate stop for refusals — and never a change to the payment.++// The chat delivery adapter: OpenVibe.Chat's typed ingress with Tips' service token, the creator's room++// from the Network's identity map, one key per effect (the delivery id) so a retry never posts twice,++// retries for outages, an immediate stop for refusals — and never a change to the payment.const assert = require('assert');const { boot, check, done } = require('./helpers/app');(async () => {-- const t = await boot({ live: true });-- const { domain, billing, live } = t;-- await t.creator('alex', { settings: { tts_enabled: true } });++ const t = await boot({ chat: true });++ const { domain, billing, chat } = t;++ const alex = await t.creator('alex', { liveId: 501, settings: { tts_enabled: true, media_requests_enabled: true } });const viewer = t.network.newUser('viewer');-- billing.fund(viewer.subject, 5000);++ billing.fund(viewer.subject, 20_000);++ const effectOf = (id, effect) => t.db.maybe('SELECT * FROM interaction_effects WHERE interaction_id = $1 AND effect = $2', [id, effect]);++ const messagesFor = (id) => chat.messages.filter((m) => m.body.metadata.interaction_id === id);-- await check('a settled tip is posted to Live with a service token (openvibe.live, live.tips_delivery.write)', async () => {-- const r = await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: 'alex', amount: 120, message: 'gg', target: { service: 'live', type: 'stream', id: '42' } } });++ await check('a settled tip: a donation line (R1) and the alert (R2) in the creator\'s Live channel, keyed by the delivery id', async () => {++ const r = await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: 'alex', amount: 1200, message: 'gg' } });++ const id = r.json.interaction.id;await domain.effects.drain();-- const d = live.deliveries.find((x) => x.job.interaction.id === r.json.interaction.id);-- assert.ok(d, 'delivered to Live');-- assert.strictEqual(d.key, `${r.json.interaction.id}:chat_line`);-- assert.strictEqual(d.job.effect, 'chat_line');-- assert.strictEqual(d.job.text, 'Viewer tipped 120 Vibes: gg');-- assert.deepStrictEqual(d.job.target, { service: 'live', type: 'stream', id: '42' });-- assert.strictEqual(d.job.creator.handle, 'alex');-- assert.strictEqual(d.job.test, false);-- const grant = t.network.grants.find((g) => g.audience === 'openvibe.live');-- assert.strictEqual(grant.scope, 'live.tips_delivery.write');-- const e = await t.db.maybe("SELECT * FROM interaction_effects WHERE interaction_id = $1 AND effect = 'chat_line'", [r.json.interaction.id]);-- assert.deepStrictEqual(e.result.ref, { chat_message_id: 1 }, 'jsonb comes back parsed');++ const [m] = messagesFor(id);++ assert.ok(m, 'posted to Chat');++ assert.deepStrictEqual(m.body, {++ key: `${id}:chat_line`, channel_user_id: 501, username: 'Viewer', message: 'Viewer tipped 1,200 Vibes: gg', message_type: 'donation',++ source_platform: 'tips', mirror: true,++ metadata: { kind: 'donation', source: 'tips', amount: 1200, message: 'gg', username: 'Viewer', interaction_id: id, paid_message: false, highlight_seconds: 0, test: false },++ });++ const alert = chat.events.find((e) => e.key === `${id}:chat_line:alert`);++ assert.deepStrictEqual(alert.body, { key: `${id}:chat_line:alert`, target: { kind: 'channel', id: 501 }, frame: { type: 'alert', streamerId: 501, kind: 'donation' } });++ assert.deepStrictEqual((await effectOf(id, 'chat_line')).result.ref, { chat_message_id: chat.messages.indexOf(m) + 1, alert: true });++ assert.strictEqual(t.network.grants.find((g) => g.audience === 'openvibe.chat').scope, 'chat.message.send chat.event.publish');++ assert.strictEqual(t.network.grants.find((g) => g.audience === 'openvibe.network').scope, 'identity.subject.resolve');++ assert.strictEqual(t.network.resolves.filter((u) => u.includes(encodeURIComponent(alex.subject))).length, 1);});-- await check('TTS goes out as its own effect with the text and voice', async () => {++ await check('a paid message is a donation line flagged paid_message with its highlight', async () => {++ const r = await t.call('POST', '/api/v1/paid-messages', { user: viewer, body: { creator: 'alex', amount: 500, message: 'hello chat' } });++ assert.strictEqual(r.status, 201, r.text);++ const id = r.json.interaction.id;++ await domain.effects.drain();++ const [m] = messagesFor(id);++ const pm = await t.db.maybe('SELECT * FROM paid_messages WHERE interaction_id = $1', [id]);++ assert.deepStrictEqual([m.key, m.body.message_type, m.body.mirror, m.body.metadata.paid_message, m.body.metadata.highlight_seconds, m.body.metadata.message],++ [`${id}:paid_message`, 'donation', true, true, pm.highlight_seconds, 'hello chat']);++ assert.strictEqual(pm.status, 'delivered');++ assert.ok(chat.events.some((e) => e.key === `${id}:paid_message:alert`));++ assert.strictEqual(t.network.resolves.length, 1, 'the room is remembered');++ });++++ await check('TTS: the donation line, then a tts line Chat reads aloud with the filtered text and the voice', async () => {const r = await t.call('POST', '/api/v1/tts-requests', { user: viewer, body: { creator: 'alex', amount: 150, tts: { text: 'hello https://x.example there' } } });assert.strictEqual(r.status, 201, r.text);++ const id = r.json.interaction.id;await domain.effects.drain();-- const d = live.deliveries.find((x) => x.job.interaction.id === r.json.interaction.id && x.job.effect === 'tts');-- assert.deepStrictEqual(d.job.tts, { text: 'hello link there', voice: 'gary' });++ const tts = messagesFor(id).find((m) => m.key === `${id}:tts`);++ assert.deepStrictEqual(tts.body, {++ key: `${id}:tts`, channel_user_id: 501, username: 'Viewer', message: 'hello link there', message_type: 'tts', source_platform: 'tips',++ tts: { voice: 'gary', identity_key: `tips:${id}`, key: `tips-${id}` }, metadata: { kind: 'tts', source: 'tips', interaction_id: id, test: false },++ });++ assert.ok(messagesFor(id).some((m) => m.key === `${id}:chat_line`));++ assert.ok(!chat.events.some((e) => e.key === `${id}:tts:alert`), 'one alert per tip, on its line');});-- await check('Live down (503): retried with backoff; the payment stays settled throughout', async () => {-- live.state.fail = 503;++ await check('Chat down (503): retried later with the same key, posted once; the payment stays settled throughout', async () => {++ chat.state.fail = 503;const r = await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: 'alex', amount: 7 } });const id = r.json.interaction.id;await domain.effects.drain();-- let e = await t.db.maybe("SELECT * FROM interaction_effects WHERE interaction_id = $1 AND effect = 'chat_line'", [id]);++ let e = await effectOf(id, 'chat_line');assert.strictEqual(e.state, 'queued');-- assert.match(e.last_error, /Live 503/);++ assert.match(e.last_error, /Chat 503/);assert.strictEqual((await domain.interactions.get(t.db, id)).payment_state, 'settled');-- live.state.fail = null;++ chat.state.fail = null;t.clock.offset += 5000;await domain.effects.drain();t.clock.offset = 0;-- e = await t.db.maybe("SELECT * FROM interaction_effects WHERE interaction_id = $1 AND effect = 'chat_line'", [id]);++ e = await effectOf(id, 'chat_line');assert.strictEqual(e.state, 'delivered');++ assert.strictEqual(e.attempts, 2);++ assert.deepStrictEqual(chat.calls.filter((c) => c.route === '/internal/chat/messages' && c.key === `${id}:chat_line`).length, 2);++ assert.strictEqual(messagesFor(id).length, 1);assert.strictEqual((await domain.interactions.get(t.db, id)).delivery_state, 'delivered');});-- await check('Live refuses (422): the effect fails at once, tips.interaction.failed, payment untouched', async () => {-- live.state.fail = 422;++ await check('the line posted but the alert hit a 503: the retry replays the line under its key (no second line) and sends the alert', async () => {++ chat.state.failEvents = 503;++ const r = await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: 'alex', amount: 9 } });++ const id = r.json.interaction.id;++ await domain.effects.drain();++ assert.strictEqual((await effectOf(id, 'chat_line')).state, 'queued');++ assert.strictEqual(messagesFor(id).length, 1);++ chat.state.failEvents = null;++ t.clock.offset += 5000;++ await domain.effects.drain();++ t.clock.offset = 0;++ assert.strictEqual(messagesFor(id).length, 1, 'Chat answered the repeat with the first result');++ assert.strictEqual(chat.events.filter((ev) => ev.key === `${id}:chat_line:alert`).length, 1);++ assert.deepStrictEqual((await effectOf(id, 'chat_line')).result.ref.alert, true);++ });++++ await check('a refused alert (4xx) does not undo the line: delivered, alert false', async () => {++ chat.state.failEvents = 400;++ const r = await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: 'alex', amount: 11 } });++ await domain.effects.drain();++ chat.state.failEvents = null;++ const e = await effectOf(r.json.interaction.id, 'chat_line');++ assert.strictEqual(e.state, 'delivered');++ assert.strictEqual(e.result.ref.alert, false);++ });++++ await check('Chat refuses (400): the effect fails at once without a retry, tips.interaction.failed, payment untouched', async () => {++ chat.state.fail = 400;const r = await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: 'alex', amount: 8 } });const id = r.json.interaction.id;await domain.effects.drain();-- live.state.fail = null;++ chat.state.fail = null;++ const e = await effectOf(id, 'chat_line');++ assert.deepStrictEqual([e.state, e.attempts], ['failed', 1]);++ assert.match(e.last_error, /Chat 400/);const i = await domain.interactions.get(t.db, id);-- assert.strictEqual(i.delivery_state, 'failed');-- assert.strictEqual(i.payment_state, 'settled');-- assert.strictEqual(billing.payable.get((await t.domain.profiles.byHandle(t.db, 'alex')).creator_subject) >= 8, true);++ assert.deepStrictEqual([i.delivery_state, i.payment_state], ['failed', 'settled']);++ assert.ok(billing.payable.get(alex.subject) >= 8);assert.strictEqual((await t.outboxRows('tips.interaction.failed')).filter((ev) => ev.subject.id === id).length, 1);});-- await check('simulations never reach Live: their chat effects stay in the test adapter', async () => {-- const alex = await t.domain.profiles.byHandle(t.db, 'alex');-- const before = live.deliveries.length;-- const sim = await domain.interactions.simulate(alex, { kind: 'tip', amount: 100 }, { by: alex.creator_subject });++ await check('a creator with no Live account has no Chat room: failed at once, payment untouched, nothing posted', async () => {++ const nolive = await t.creator('nolive');++ const before = chat.calls.length;++ const r = await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: 'nolive', amount: 10 } });++ await domain.effects.drain();++ const e = await effectOf(r.json.interaction.id, 'chat_line');++ assert.deepStrictEqual([e.state, e.attempts], ['failed', 1]);++ assert.match(e.last_error, /no Live channel/);++ assert.strictEqual((await domain.interactions.get(t.db, r.json.interaction.id)).payment_state, 'settled');++ assert.strictEqual(chat.calls.length, before);++ assert.ok(nolive.subject);++ });++++ await check('a media request has no Chat route: failed at once, the request recorded failed, payment untouched', async () => {++ const r = await t.call('POST', '/api/v1/media-requests', { user: viewer, body: { creator: 'alex', amount: 150, media: { url: 'https://www.youtube.com/watch?v=abc' } } });++ assert.strictEqual(r.status, 201, r.text);++ const id = r.json.interaction.id;++ await domain.effects.drain();++ const e = await effectOf(id, 'media_request');++ assert.strictEqual(e.state, 'failed');++ assert.match(e.last_error, /no media_request route/);++ assert.strictEqual(await t.db.value('SELECT status FROM paid_media_requests WHERE interaction_id = $1', [id]), 'failed');++ assert.strictEqual((await domain.interactions.get(t.db, id)).payment_state, 'settled');++ assert.strictEqual(messagesFor(id).length, 0);++ });++++ await check('simulations never reach Chat: their chat effects stay in the test adapter', async () => {++ const profile = await t.domain.profiles.byHandle(t.db, 'alex');++ const before = chat.calls.length;++ const sim = await domain.interactions.simulate(profile, { kind: 'tip', amount: 100 }, { by: profile.creator_subject });await domain.effects.drain();-- assert.strictEqual(live.deliveries.length, before);++ assert.strictEqual(chat.calls.length, before);assert.ok(t.adapters.test.jobs.some((j) => j.interaction.id === sim.id && j.test));});@@@ -1,16 +1,17 @@'use strict';// EXTERNAL PowerChat tips announced by Billing (billing.receipt.external): once the PowerChat webhook// points at Billing, Live no longer celebrates a tip on a streamer's own PowerChat, so Tips does —--// the chat line through Live's /internal/tips/deliveries (as Live's webhook wrote it), the overlay++// the chat line through OpenVibe.Chat's ingress (as Live's webhook wrote it), the overlay// alert and the goal — exactly once per provider payment, and never counted as Billing money.const assert = require('assert');const { ids } = require('openvibe-contracts');const { boot, check, done } = require('./helpers/app');(async () => {-- const t = await boot({ live: true });-- const { domain, live } = t;-- const alex = await t.creator('alex');++ const t = await boot({ chat: true });++ const { domain, chat } = t;++ const linesFor = (id) => chat.messages.filter((m) => m.body.metadata.interaction_id === id);++ const alex = await t.creator('alex', { liveId: 501 });console.log('external receipts');/** billing.receipt.external as Billing's ops/external.js writes it. */@@@ -30,7 +31,7 @@ const { boot, check, done } = require('./helpers/app');const byRef = (ref) => t.db.maybe("SELECT * FROM tip_interactions WHERE provider = 'powerchat' AND provider_ref = $1", [ref]);let first;-- await check('an EXTERNAL receipt is recorded, settled and announced in Live chat as Live\'s webhook wrote it', async () => {++ await check('an EXTERNAL receipt is recorded, settled and announced in the creator\'s chat as Live\'s webhook wrote it', async () => {const g = await t.call('POST', '/api/v1/goals', { user: alex, body: { title: 'New camera', target_amount: 5000 } });assert.strictEqual(g.status, 201, g.text);first = receipt({ provider_event_id: 'ext-1', amount_cents: 1234, value_bits: 1234, donor_name: 'Generous Fan', message: 'love the stream' });@@@ -41,11 +42,10 @@ const { boot, check, done } = require('./helpers/app');assert.deepStrictEqual([i.creator_subject, i.kind, i.amount, i.amount_cents, i.supporter_name, i.message], [alex.subject, 'tip', 1234, 1234, 'Generous Fan', 'love the stream']);assert.deepStrictEqual([i.funding, i.settlement, i.origin, i.payment_state, i.billing_txn_id], ['external', 'external', 'billing-external', 'settled', null]);await domain.effects.drain();-- const d = live.deliveries.filter((x) => x.job.interaction.id === i.id);++ const d = linesFor(i.id);assert.strictEqual(d.length, 1);-- assert.deepStrictEqual([d[0].key, d[0].job.effect, d[0].job.test], [`${i.id}:chat_line`, 'chat_line', false]);-- assert.strictEqual(d[0].job.text, 'Generous Fan tipped 1,234 Vibes: love the stream (PowerChat)');-- assert.deepStrictEqual(d[0].job.creator, { type: 'user', id: alex.subject, handle: 'alex' });++ assert.deepStrictEqual([d[0].key, d[0].body.channel_user_id, d[0].body.message_type, d[0].body.metadata.test], [`${i.id}:chat_line`, 501, 'donation', false]);++ assert.strictEqual(d[0].body.message, 'Generous Fan tipped 1,234 Vibes: love the stream (PowerChat)');const effects = await t.db.many('SELECT effect, state FROM interaction_effects WHERE interaction_id = $1 ORDER BY id', [i.id]);assert.deepStrictEqual(effects, [{ effect: 'overlay_alert', state: 'delivered' }, { effect: 'chat_line', state: 'delivered' }]);const goal = await domain.goals.present(t.db, await domain.goals.get(t.db, g.json.goal.id));@@@ -63,7 +63,7 @@ const { boot, check, done } = require('./helpers/app');await domain.effects.drain();assert.strictEqual(await t.db.value("SELECT count(*) FROM tip_interactions WHERE provider_ref = 'ext-1'"), 1);const one = await byRef('ext-1');-- assert.strictEqual(live.deliveries.filter((x) => x.job.interaction.id === one.id).length, 1);++ assert.strictEqual(linesFor(one.id).length, 1);});await check('EXTERNAL money stays out of the Billing total', async () => {@@@ -84,23 +84,22 @@ const { boot, check, done } = require('./helpers/app');});await check('anonymous: no donor name; a creator with no tip page is still announced', async () => {-- const bob = t.network.newUser('bob');++ const bob = t.network.newUser('bob', 502);await t.deliver(receipt({ provider_event_id: 'ext-anon', donor_name: null, anonymous: true, message: 'hi' }, { streamer: bob.subject }));const i = await byRef('ext-anon');assert.deepStrictEqual([i.creator_subject, i.supporter_name], [bob.subject, 'Anonymous']);await domain.effects.drain();-- const d = live.deliveries.find((x) => x.job.interaction.id === i.id);-- assert.strictEqual(d.job.text, 'Anonymous tipped 500 Vibes: hi (PowerChat)');-- assert.strictEqual(d.job.creator.handle, null);++ const [d] = linesFor(i.id);++ assert.deepStrictEqual([d.body.message, d.body.username, d.body.channel_user_id], ['Anonymous tipped 500 Vibes: hi (PowerChat)', 'Anonymous', 502]);});-- await check('a test receipt never reaches Live chat or a goal', async () => {-- const before = live.deliveries.length;++ await check('a test receipt never reaches Chat or a goal', async () => {++ const before = chat.calls.length;await t.deliver(receipt({ provider_event_id: 'ext-test', test: true }));await domain.effects.drain();const i = await byRef('ext-test');assert.strictEqual(i.test, true);-- assert.strictEqual(live.deliveries.length, before);++ assert.strictEqual(chat.calls.length, before);assert.strictEqual(t.adapters.test.jobs.filter((j) => j.interaction.id === i.id).length, 1);assert.strictEqual(await t.db.value('SELECT count(*) FROM tip_goal_contributions WHERE interaction_id = $1', [i.id]), 0);});@@@ -7,7 +7,8 @@* opts.valkey an openvibe-sdk/valkey handle (default: the containers' Valkey with TIPS_TEST_STORE=pg,* else none: everything shared stays in the process);* opts.db an existing handle (a second process on the same database); opts.share another instance-- * whose stubs (Network, Billing, Events, Live) this one uses.++ * whose stubs (Network, Billing, Events, Chat) this one uses; opts.chat starts the Chat stub and the++ * chat adapter (else the test adapter).** t.call(method, path, { body, user, cap, sub, key }) user: a stub user → Bearer user JWT;* otherwise a service token with `cap`@@@ -20,7 +21,7 @@ const path = require('path');const http = require('http');const crypto = require('crypto');const { signDelivery, signDeliveryHeaders } = require('openvibe-sdk/events');--const { startNetwork, startBilling, startEvents, startLive } = require('./stubs');++const { startNetwork, startBilling, startEvents, startChat } = require('./stubs');const { testDb, testValkey } = require('./db');const EVENTS_SECRET = 'e'.repeat(48);@@@ -31,7 +32,7 @@ async function boot(opts = {}) {const network = shared ? shared.network : await startNetwork();const billing = shared ? shared.billing : await startBilling(network);const events = shared ? shared.events : await startEvents();-- const live = shared ? shared.live : (opts.live ? await startLive(network) : null);++ const chat = shared ? shared.chat : (opts.chat ? await startChat(network) : null);const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tips-test-'));const env = {NODE_ENV: 'test',@@@ -48,8 +49,8 @@ async function boot(opts = {}) {EVENTS_URL: events.url,EVENTS_RELAY_INTERVAL_MS: '50',TIPS_JOBS: 'off',-- TIPS_CHAT_ADAPTER: live ? 'live-chat' : 'test',-- LIVE_INTERNAL_URL: live ? live.url : 'http://127.0.0.1:9',++ TIPS_CHAT_ADAPTER: chat ? 'chat' : 'test',++ TIPS_CHAT_URL: chat ? chat.url : 'http://127.0.0.1:9',TIPS_OVERLAY_HEARTBEAT_MS: '60000',...(opts.env || {}),};@@@ -149,14 +150,14 @@ async function boot(opts = {}) {const outboxRows = async (type) => (await domain.db.many('SELECT envelope FROM tips_event_outbox ORDER BY id')).map((r) => r.envelope).filter((e) => !type || e.event_type === type);return {-- app, base, call, deliver, sse, creator, domain, db: domain.db, store, valkey, config, clock, network, billing, events, live, logs, dir, outboxRows,++ app, base, call, deliver, sse, creator, domain, db: domain.db, store, valkey, config, clock, network, billing, events, chat, logs, dir, outboxRows,adapters: app.locals.adapters,close: async () => {await domain.overlays.close();server.closeAllConnections();await new Promise((r) => server.close(r));await app.locals.outbox.stop();-- if (!shared) await Promise.all([network.close(), billing.close(), events.close(), live && live.close()]);++ if (!shared) await Promise.all([network.close(), billing.close(), events.close(), chat && chat.close()]);await store.close();if (ownValkey) await ownValkey.close();fs.rmSync(dir, { recursive: true, force: true });@@@ -2,13 +2,16 @@/*** Stand-ins for the services Tips talks to, each on a random port.*-- * startNetwork() JWKS, client-credentials token endpoint (scope → cap), user JWTs (signUser)++ * startNetwork() JWKS, client-credentials token endpoint (scope → cap), user JWTs (signUser), the++ * identity map (newUser(name, liveId) → GET /internal/identity/resolve legacy_ids)* startBilling() /api/v1/intents, /transfers, /transfers/:id/refund with Billing's rules that* matter here (idempotency keys, funds, self-dealing, capability + audience checks),* and the billing.transaction.* envelopes it would publish (billing.events)* startEvents() POST /api/v1/events recording what Tips' outbox relays* (Billing and Events also answer GET /api/health, which /api/ready probes)-- * startLive() POST /internal/tips/deliveries recording chat deliveries (the live-chat adapter)++ * startChat() POST /internal/chat/messages (R1) and /internal/chat/events (R2) recording what the++ * chat adapter sends, with Chat's rules: capability per route, one result per key,++ * 409 for a key reused with another body*/const http = require('http');const crypto = require('crypto');@@@ -29,6 +32,7 @@ async function startNetwork() {const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });const grants = [];const legacy = {};++ const resolves = [];let issuer = 'http://network.test';let n = 100;@@@ -57,6 +61,16 @@ async function startNetwork() {const cap = String(body.scope || '').split(/\s+/).filter(Boolean);return send(res, 200, { access_token: signService({ sub: `svc:${body.client_id}`, aud: [body.audience], cap }), token_type: 'Bearer', expires_in: 300 });}++ if (req.url.startsWith('/internal/identity/resolve?') && req.method === 'GET') {++ const v = serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: publicPem, issuer, audience: 'openvibe.network' });++ if (!v.ok) return send(res, 401, { code: v.code });++ if (!(v.claims.cap || []).includes('identity.subject.resolve')) return send(res, 403, { code: 'capability.denied' });++ resolves.push(req.url);++ const subject = new URL(req.url, 'http://x').searchParams.get('subject_id');++ const liveIds = Object.keys(legacy).filter((id) => legacy[id].subject === subject);++ if (!liveIds.length) return send(res, 404, { code: 'identity.subject_not_found' });++ return send(res, 200, { subject: { type: 'user', id: subject }, legacy_ids: liveIds.map((id) => ({ source_system: 'live', source_type: 'user', source_id: id })) });++ }if (req.url === '/internal/identity/resolve-batch' && req.method === 'POST') {const v = serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: publicPem, issuer, audience: 'openvibe.network' });if (!v.ok) return send(res, 401, { code: v.code });@@@ -73,7 +87,7 @@ async function startNetwork() {});const url = await listen(server);issuer = url;-- return { url, publicPem, signService, signUser, newUser, legacy, grants, close: () => new Promise((r) => server.close(r)) };++ return { url, publicPem, signService, signUser, newUser, legacy, grants, resolves, close: () => new Promise((r) => server.close(r)) };}/** Billing as Tips sees it. */@@@ -194,27 +208,39 @@ async function startEvents() {return { url, published, tokens, close: () => new Promise((r) => server.close(r)) };}--async function startLive(network) {-- const deliveries = [];-- const state = { fail: null };++/** OpenVibe.Chat's typed ingress as Tips' chat adapter uses it (OpenVibe.Chat docs/chat-ingress.md). */++async function startChat(network) {++ const messages = []; // { key, body } per R1 call Chat applied++ const events = []; // { key, body } per R2 call Chat applied++ const calls = []; // every authorized POST: { route, key }++ const state = { fail: null, failEvents: null };const seen = new Map();++ const CAP = { '/internal/chat/messages': 'chat.message.send', '/internal/chat/events': 'chat.event.publish' };const server = http.createServer(async (req, res) => {const raw = await readBody(req);-- if (req.url !== '/internal/tips/deliveries' || req.method !== 'POST') return send(res, 404, {});-- const v = serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: network.publicPem, issuer: network.url, audience: 'openvibe.live' });-- if (!v.ok) return send(res, 401, { code: v.code });-- if (!(v.claims.cap || []).includes('live.tips_delivery.write')) return send(res, 403, { code: 'capability.denied' });-- if (state.fail) return send(res, state.fail, { code: 'live.refused', detail: 'stub refusal' });-- const key = req.headers['idempotency-key'];-- if (seen.has(key)) return send(res, 200, seen.get(key));-- const job = JSON.parse(raw);-- deliveries.push({ key, job });-- const out = { ok: true, ref: { chat_message_id: deliveries.length } };-- seen.set(key, out);++ const cap = CAP[req.url];++ if (!cap || req.method !== 'POST') return send(res, 404, {});++ const v = serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: network.publicPem, issuer: network.url, audience: 'openvibe.chat' });++ if (!v.ok) return send(res, 401, { ok: false, error: v.code });++ if (!(v.claims.cap || []).includes(cap)) return send(res, 403, { ok: false, error: 'capability.denied' });++ const body = JSON.parse(raw || '{}');++ calls.push({ route: req.url, key: body.key });++ const fail = req.url === '/internal/chat/events' ? state.failEvents || state.fail : state.fail;++ if (fail) return send(res, fail, { ok: false, error: 'stub refusal' });++ if (!body.key || !/^[A-Za-z0-9:._-]{1,160}$/.test(body.key)) return send(res, 400, { ok: false, error: 'A valid idempotency key is required' });++ const id = `${v.claims.sub}|${req.url}|${body.key}`;++ if (seen.has(id)) {++ const old = seen.get(id);++ if (old.raw !== JSON.stringify(body)) return send(res, 409, { ok: false, error: 'Idempotency key reused with a different body' });++ return send(res, 200, old.out);++ }++ let out;++ if (req.url === '/internal/chat/messages') { messages.push({ key: body.key, body }); out = { ok: true, id: messages.length }; } else { events.push({ key: body.key, body }); out = { ok: true }; }++ seen.set(id, { raw: JSON.stringify(body), out });return send(res, 200, out);});const url = await listen(server);-- return { url, deliveries, state, close: () => new Promise((r) => server.close(r)) };++ return { url, messages, events, calls, state, close: () => new Promise((r) => server.close(r)) };}--module.exports = { startNetwork, startBilling, startEvents, startLive };++module.exports = { startNetwork, startBilling, startEvents, startChat };
@@ -63,10 +63,13 @@ BILLING_URL=http://127.0.0.1:4600
@@ -63,10 +63,13 @@ BILLING_URL=http://127.0.0.1:4600
TIPS_EVENTS_SECRET=
TIPS_EVENTS_SECRET=
# ── Chat delivery ───────────────────────────────────────────
# ── Chat delivery ───────────────────────────────────────────
-# live-chat (Live's /internal/tips/deliveries, docs/live-patch.diff) | test | none (production default)
+# chat (OpenVibe.Chat's /internal/chat/messages + /internal/chat/events) | test | none (production default)
+# chat needs the tips principal granted chat.message.send and chat.event.publish (audience openvibe.chat)
+# and identity.subject.resolve (the creator's Live user id is Chat's room).
#TIPS_CHAT_ADAPTER=none
#TIPS_CHAT_ADAPTER=none
-#LIVE_INTERNAL_URL=http://127.0.0.1:3000
-#LIVE_AUDIENCE=openvibe.live
+#TIPS_CHAT_URL=http://127.0.0.1:4400
+#CHAT_AUDIENCE=openvibe.chat
+#OV_NETWORK_AUDIENCE=openvibe.network
#TIPS_DELIVERY_MAX_ATTEMPTS=6
#TIPS_DELIVERY_MAX_ATTEMPTS=6
#TIPS_DELIVERY_BACKOFF_MS=2000,10000,60000,300000,900000
#TIPS_DELIVERY_BACKOFF_MS=2000,10000,60000,300000,900000
@@ -44,8 +44,9 @@ from *interaction delivered*.
@@ -44,8 +44,9 @@ from *interaction delivered*.
PowerChat, once Billing receives the PowerChat webhook)
PowerChat, once Billing receives the PowerChat webhook)
- **OpenVibe.Events** — Billing's events in (signed webhook + openvibe-sdk inbox), Tips' events out
- **OpenVibe.Events** — Billing's events in (signed webhook + openvibe-sdk inbox), Tips' events out
(openvibe-sdk transactional outbox)
(openvibe-sdk transactional outbox)
-- **OpenVibe.Network** — SSO for people, service tokens, JWKS, identity resolve
-- **OpenVibe.Live** — chat delivery through `/internal/tips/deliveries` (deployed in Live since `f11f809`; [docs/live-patch.diff](docs/live-patch.diff) is the original patch) until OpenVibe.Chat exposes paid messages; overlay consumer
+- **OpenVibe.Network** — SSO for people, service tokens, JWKS, identity resolve (a creator's Chat room)
+- **OpenVibe.Chat** — chat delivery through its typed ingress (`/internal/chat/messages`, `/internal/chat/events`)
+- **OpenVibe.Live** — overlay consumer
- **OpenVibe.Shared** v2.2.0 (app icon, SSR footer, noscript nav, release manifest, legal pages, boost page moves) and
- **OpenVibe.Shared** v2.2.0 (app icon, SSR footer, noscript nav, release manifest, legal pages, boost page moves) and
the Network's `navbar.js`; **openvibe-contracts** v0.79.0 and **openvibe-sdk** v0.25.0 (service tokens,
the Network's `navbar.js`; **openvibe-contracts** v0.79.0 and **openvibe-sdk** v0.25.0 (service tokens,
the async data layer `openvibe-sdk/db`, the PostgreSQL outbox and inbox — the events outbox is the SDK's
the async data layer `openvibe-sdk/db`, the PostgreSQL outbox and inbox — the events outbox is the SDK's
@@ -65,7 +66,8 @@ Called elsewhere, as the service principal `tips`:
@@ -65,7 +66,8 @@ Called elsewhere, as the service principal `tips`:
|---|---|---|
|---|---|---|
| OpenVibe.Billing | `billing.intent.create`, `billing.transfer.create` | checkout, and a tip from credit |
| OpenVibe.Billing | `billing.intent.create`, `billing.transfer.create` | checkout, and a tip from credit |
| OpenVibe.Events | `events.event.publish`; `events.subscription.manage` once (`npm run subscribe`) | the outbox relay; the `billing.transaction.*` and `billing.receipt.*` subscriptions |
| OpenVibe.Events | `events.event.publish`; `events.subscription.manage` once (`npm run subscribe`) | the outbox relay; the `billing.transaction.*` and `billing.receipt.*` subscriptions |
-| OpenVibe.Live | `live.tips_delivery.write` (only with `TIPS_CHAT_ADAPTER=live-chat`) | chat lines and alerts through `/internal/tips/deliveries` |
+| OpenVibe.Chat | `chat.message.send`, `chat.event.publish` (only with `TIPS_CHAT_ADAPTER=chat`) | donation lines and TTS through `/internal/chat/messages`, the alert through `/internal/chat/events` |
+| OpenVibe.Network | `identity.subject.resolve` (only with `TIPS_CHAT_ADAPTER=chat`) | the creator's Live user id (Chat's room) from `/internal/identity/resolve` |
## Run it
## Run it
@@ -73,7 +75,7 @@ Called elsewhere, as the service principal `tips`:
@@ -73,7 +75,7 @@ Called elsewhere, as the service principal `tips`:
fnm exec --using=22.22.1 npm install
fnm exec --using=22.22.1 npm install
cp .env.example .env # OV_OAUTH_CLIENT_SECRET, TIPS_FORM_SECRET, TIPS_EVENTS_SECRET, …
cp .env.example .env # OV_OAUTH_CLIENT_SECRET, TIPS_FORM_SECRET, TIPS_EVENTS_SECRET, …
npm run dev # http://localhost:4610 (without DATABASE_URL: an embedded PGlite database in data/pglite)
npm run dev # http://localhost:4610 (without DATABASE_URL: an embedded PGlite database in data/pglite)
-npm test # every test/*.test.js: stub Network/Billing/Events/Live, PGlite, random ports
+npm test # every test/*.test.js: stub Network/Billing/Events/Chat, PGlite, random ports
eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)" # PostgreSQL 18 + PgBouncer + Valkey 9 containers
eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)" # PostgreSQL 18 + PgBouncer + Valkey 9 containers
npm test # … now also test/integration.test.js on the containers
npm test # … now also test/integration.test.js on the containers
npm run test:pg # every test file through PgBouncer and Valkey
npm run test:pg # every test file through PgBouncer and Valkey
@@ -95,7 +97,7 @@ payment_state pending ──► settled ──► reversed mirrored fro
@@ -95,7 +97,7 @@ payment_state pending ──► settled ──► reversed mirrored fro
delivery_state awaiting_payment ──► queued ──► delivered | failed | cancelled
delivery_state awaiting_payment ──► queued ──► delivered | failed | cancelled
```
```
-A delivery failure (Live down, chat refused) is retried with backoff and finally recorded on the
+A delivery failure (Chat down, chat refused) is retried with backoff and finally recorded on the
effect; it never touches `payment_state`. A reversal after delivery flips `payment_state` and keeps the
effect; it never touches `payment_state`. A reversal after delivery flips `payment_state` and keeps the
delivery record; a reversal before delivery cancels the queued effects (`tips.interaction.cancelled`).
delivery record; a reversal before delivery cancels the queued effects (`tips.interaction.cancelled`).
@@ -284,19 +286,29 @@ within `TIPS_OVERLAY_TTL_MS` becomes `failed` (`tips.overlay.failed`). A reconne
@@ -284,19 +286,29 @@ within `TIPS_OVERLAY_TTL_MS` becomes `failed` (`tips.overlay.failed`). A reconne
## Chat delivery
## Chat delivery
`server/delivery/` — an adapter is `{ name, deliver(job) → { ref } }` (throw to fail; `permanent` stops
`server/delivery/` — an adapter is `{ name, deliver(job) → { ref } }` (throw to fail; `permanent` stops
-retries). `live-chat` posts to Live's `/internal/tips/deliveries` with Tips' service token (audience
-`openvibe.live`, `live.tips_delivery.write`), `Idempotency-Key = <interaction>:<effect>`; Live does what
-`POST /api/funds/donate` does after the money moved (channel + global broadcast, a `donation` chat
-message, the alert sound), TTS through `synthesizeAndBroadcastTTS`, media requests into its queue at no
-charge. A job carries the interaction's public view and `privacy`: "Anonymous" and no subject for an
-anonymous supporter, `interaction.amount: null` and a line without the amount when it is hidden, no
-private message. (Live's route turns a null amount into a `donation` event of 0 Vibes, which its chat
-renders as "donated 0 Vibes"; it should render an amount-less line when `privacy.hide_amount` is set.)
-The route came from [docs/live-patch.diff](docs/live-patch.diff) and is deployed in Live since
-`f11f809` (its idempotency record is in memory only); nothing calls it yet. `test`
-records jobs in memory (development; simulations always use it). `none` (the production default) creates
-no chat effects, and it is what production runs. When OpenVibe.Chat publishes a paid-message capability, a `chat` adapter replaces
-`live-chat`.
+retries). `chat` (`server/delivery/chat.js`) posts to OpenVibe.Chat's typed ingress at `TIPS_CHAT_URL`
+with Tips' service token (audience `openvibe.chat`; OpenVibe.Chat `docs/chat-ingress.md`):
+
+| Effect | Chat call | Body |
+|---|---|---|
+| `chat_line`, `paid_message` | `POST /internal/chat/messages` (`chat.message.send`) | `channel_user_id`, `username` (the public name), `message` (the line), `message_type: donation`, `source_platform: tips`, `mirror: true` (global chat too), `metadata { kind: donation, source, amount, message, username, interaction_id, paid_message, highlight_seconds, test }` |
+| | then `POST /internal/chat/events` (`chat.event.publish`) | `target { kind: channel, id }`, `frame { type: alert, streamerId, kind: donation }` (the alert sound) |
+| `tts` | `POST /internal/chat/messages` | a `tts` line (`message` = the filtered TTS text) with `tts { voice, identity_key: tips:<interaction>, key: tips-<interaction> }`: Chat saves it and reads it aloud |
+| `media_request` | none | fails at once: the media queue is not Chat's |
+
+Every body's `key` is the effect's delivery id (`<interaction>:<effect>`; the alert `<delivery id>:alert`):
+Chat applies a key once and answers a repeat with the first result, so a retry after a 5xx or a lost answer
+never posts twice. A 4xx (other than 401/408/425/429) fails the effect at once, everything else is retried
+with backoff; a refused alert does not undo the line (`ref.alert: false`). Chat's rooms are Live's ids: the
+adapter reads the creator's Live user id from the Network (`GET /internal/identity/resolve`, kept ten
+minutes); a creator with no Live account fails at once. The body is rebuilt on each attempt from the
+interaction's current public view, so a word-filter change between attempts makes Chat answer 409, which
+fails the effect. A job carries the interaction's public view and `privacy`: "Anonymous" and no subject
+for an anonymous supporter, `amount: null` and a line without the amount when it is hidden, no private
+message. `test` records jobs in memory (development; simulations always use it). `none` (the production
+default) creates no chat effects, and it is what production runs. To enable delivery: grant the `tips`
+principal the three capabilities above, then set `TIPS_CHAT_ADAPTER=chat` (and `TIPS_CHAT_URL` when Chat
+is not on `127.0.0.1:4400`).
## Pages (server-rendered, useful without JavaScript)
## Pages (server-rendered, useful without JavaScript)
@@ -317,7 +329,7 @@ references to Billing transaction ids, never a balance. People sign in with Netw
@@ -317,7 +329,7 @@ references to Billing transaction ids, never a balance. People sign in with Netw
client-credentials tokens for audience `openvibe.tips`, one capability per route. Overlays use revocable
client-credentials tokens for audience `openvibe.tips`, one capability per route. Overlays use revocable
scoped tokens, hashed at rest, never creator cookies. Billing's events arrive signed
scoped tokens, hashed at rest, never creator cookies. Billing's events arrive signed
(`TIPS_EVENTS_SECRET`) through an inbox that dedupes them; `/internal/` and `/metrics` are never public.
(`TIPS_EVENTS_SECRET`) through an inbox that dedupes them; `/internal/` and `/metrics` are never public.
-Tips calls only its configured Network, Billing, Events and Live hosts. Secrets (`OV_OAUTH_CLIENT_SECRET`,
+Tips calls only its configured Network, Billing, Events and Chat hosts. Secrets (`OV_OAUTH_CLIENT_SECRET`,
`TIPS_FORM_SECRET`, `TIPS_EVENTS_SECRET`) live in `/etc/openvibe/tips.env` (0600).
`TIPS_FORM_SECRET`, `TIPS_EVENTS_SECRET`) live in `/etc/openvibe/tips.env` (0600).
[docs/threat-review.md](docs/threat-review.md) is the written threat review: overlays (tokenised URLs),
[docs/threat-review.md](docs/threat-review.md) is the written threat review: overlays (tokenised URLs),
@@ -361,10 +373,10 @@ With `VALKEY_URL` every process counts one caller together (`test/integration.te
@@ -361,10 +373,10 @@ With `VALKEY_URL` every process counts one caller together (`test/integration.te
|---|---|
|---|---|
| a duplicate provider webhook yields one Billing transaction and one logical interaction | `test/settlement.test.js` (same event redelivered, same transaction under a new event id, foreign donation delivered three times) |
| a duplicate provider webhook yields one Billing transaction and one logical interaction | `test/settlement.test.js` (same event redelivered, same transaction under a new event id, foreign donation delivered three times) |
| overlay replay never charges again | `test/overlays.test.js` (three `Last-Event-ID` replays: transfers, contributions, goal total, payable, events unchanged) |
| overlay replay never charges again | `test/overlays.test.js` (three `Last-Event-ID` replays: transfers, contributions, goal total, payable, events unchanged) |
-| the creator can use openvibe.tips with Live offline | pages, API, overlays and settlement need no Live call (`test/pages.test.js`, `test/overlays.test.js` run with no Live); only the `live-chat` adapter talks to Live and its failure never touches payment (`test/delivery.test.js`) — **not yet demonstrated on the real host** |
+| the creator can use openvibe.tips with Live offline | pages, API, overlays and settlement need no Live call (`test/pages.test.js`, `test/overlays.test.js` run with no Live); chat delivery goes to OpenVibe.Chat and its failure never touches payment (`test/delivery.test.js`) — **not yet demonstrated on the real host** |
| creator totals reconcile exactly to Billing | `test/api.test.js` (totals = the stub Billing payable) |
| creator totals reconcile exactly to Billing | `test/api.test.js` (totals = the stub Billing payable) |
| simulation never counted; token revocation immediate; goals from settled only; reversal keeps the delivery record | `test/overlays.test.js`, `test/api.test.js`, `test/settlement.test.js` |
| simulation never counted; token revocation immediate; goals from settled only; reversal keeps the delivery record | `test/overlays.test.js`, `test/api.test.js`, `test/settlement.test.js` |
-| an EXTERNAL PowerChat tip Billing announced is celebrated once: Live chat line, overlay, goal; never Billing money | `test/external.test.js` (redelivery and republish, same key as `POST /interactions/external`, Tips goal ids, anonymous, test receipts, malformed payloads) |
+| an EXTERNAL PowerChat tip Billing announced is celebrated once: chat line, overlay, goal; never Billing money | `test/external.test.js` (redelivery and republish, same key as `POST /interactions/external`, Tips goal ids, anonymous, test receipts, malformed payloads) |
| paid messages are filtered before they are shown or read; the creator and their moderators hide and show them without touching the money | `test/moderation.test.js` (mask, hold and release, invisible/full-width evasion, TTS text, invitation links, hide cancels queued chat/TTS and retracts the overlay live/replay/state, restore, pending payments, who may moderate, event payloads) |
| paid messages are filtered before they are shown or read; the creator and their moderators hide and show them without touching the money | `test/moderation.test.js` (mask, hold and release, invisible/full-width evasion, TTS text, invitation links, hide cancels queued chat/TTS and retracts the overlay live/replay/state, restore, pending payments, who may moderate, event payloads) |
| the suite passes on PostgreSQL behind PgBouncer in transaction mode; one settlement or reversal raced across two processes counts once; the unpaid-checkout limit holds under concurrency; leased work runs once; flushing Valkey changes no money answer (ADR-007 2026-09-24, ADR-035) | `npm run test:pg`; `test/integration.test.js` (two app instances on one database and one Valkey) |
| the suite passes on PostgreSQL behind PgBouncer in transaction mode; one settlement or reversal raced across two processes counts once; the unpaid-checkout limit holds under concurrency; leased work runs once; flushing Valkey changes no money answer (ADR-007 2026-09-24, ADR-035) | `npm run test:pg`; `test/integration.test.js` (two app instances on one database and one Valkey) |
| a supporter's privacy holds everywhere; the creator chooses what public pages show; export and erasure keep the books reconciled | `test/privacy.test.js` (anonymous / hidden amount / private message across API, overlays, chat jobs, pages and events; goal and supporters page settings; export; erasure scrubs rows, overlay payloads, stored answers and unsent events, emits `tips.interaction.erased`, totals still equal Billing) |
| a supporter's privacy holds everywhere; the creator chooses what public pages show; export and erasure keep the books reconciled | `test/privacy.test.js` (anonymous / hidden amount / private message across API, overlays, chat jobs, pages and events; goal and supporters page settings; export; erasure scrubs rows, overlay payloads, stored answers and unsent events, emits `tips.interaction.erased`, totals still equal Billing) |
@@ -376,13 +388,13 @@ With `VALKEY_URL` every process counts one caller together (`test/integration.te
@@ -376,13 +388,13 @@ With `VALKEY_URL` every process counts one caller together (`test/integration.te
Billing transfers. For PowerChat checkout, Billing answers with a `checkout_ref` and no URL — Tips needs
Billing transfers. For PowerChat checkout, Billing answers with a `checkout_ref` and no URL — Tips needs
`TIPS_POWERCHAT_LINK_TEMPLATE` or Billing returning the link.
`TIPS_POWERCHAT_LINK_TEMPLATE` or Billing returning the link.
- **EXTERNAL PowerChat tips** arrive as `billing.receipt.external` once Billing is the authority. They need
- **EXTERNAL PowerChat tips** arrive as `billing.receipt.external` once Billing is the authority. They need
- the `billing.receipt.*` subscription (`npm run subscribe`) and `TIPS_CHAT_ADAPTER=live-chat`; Live's
- `/internal/tips/deliveries` posts the chat line and plays the alert but does not advance **Live's own**
+ the `billing.receipt.*` subscription (`npm run subscribe`) and `TIPS_CHAT_ADAPTER=chat`; the chat
+ adapter posts the chat line and plays the alert but does not advance **Live's own**
`donation_goals` or send its `goal-update`/`goal-reached` frames (Tips' goals and overlay do advance).
`donation_goals` or send its `goal-update`/`goal-reached` frames (Tips' goals and overlay do advance).
PowerChat follow/host/channel-points/subscription notices are not forwarded by anyone.
PowerChat follow/host/channel-points/subscription notices are not forwarded by anyone.
-- **Chat**: `live-chat` needs `TIPS_CHAT_ADAPTER=live-chat` after the cutover (Live's route is deployed); OpenVibe.Chat has no public
- paid-message/TTS capability yet. The Live patch's media-request path (yt-dlp/oEmbed) is not covered by
- its test.
+- **Chat**: `TIPS_CHAT_ADAPTER=chat` needs the Network to grant `tips` `chat.message.send` and
+ `chat.event.publish` (audience `openvibe.chat`) and `identity.subject.resolve`. Paid media requests have
+ no delivery route (Chat does not own the media queue): with `chat` they are recorded failed.
- **Refunds of paid media requests** that never played (Billing's `POST /transfers/:id/refund`) are not
- **Refunds of paid media requests** that never played (Billing's `POST /transfers/:id/refund`) are not
offered in Tips yet; a refund made elsewhere arrives as `billing.transaction.reversed` and is applied.
offered in Tips yet; a refund made elsewhere arrives as `billing.transaction.reversed` and is applied.
- Legal pages use the shared `ugc` profile, which does not describe payments.
- Legal pages use the shared `ugc` profile, which does not describe payments.
@@ -7,11 +7,11 @@
@@ -7,11 +7,11 @@
"adr": "ADR-012",
"adr": "ADR-012",
"authoritative": false,
"authoritative": false,
"deployed": true,
"deployed": true,
- "deployment": "Deployed internally, not launched: deployed with `sudo ovhost deploy tips`, /opt/openvibe.tips, unit openvibe-tips, 127.0.0.1:4610 loopback only; https://openvibe.tips still serves the OpenVibe.Sites placeholder. Principal tips; Events subscriptions billing.transaction.* and billing.receipt.* -> /internal/events (secret rotated 2026-09-24); TIPS_CHAT_ADAPTER is empty (no chat side effects).",
+ "deployment": "Deployed internally, not launched: deployed with `sudo ovhost deploy tips`, /opt/openvibe.tips, unit openvibe-tips, 127.0.0.1:4610 loopback only; https://openvibe.tips still serves the OpenVibe.Sites placeholder. Principal tips; Events subscriptions billing.transaction.* and billing.receipt.* -> /internal/events (secret rotated 2026-09-24); TIPS_CHAT_ADAPTER is empty (no chat side effects); TIPS_CHAT_ADAPTER=chat delivers through OpenVibe.Chat's ingress once tips is granted chat.message.send, chat.event.publish and identity.subject.resolve.",
"database": "PostgreSQL 18 through PgBouncer (transaction mode) as the system of record, Valkey 9 for shared state (per-actor limits, overlay fan-out, overlay stream slots), ADR-035: database ov_tips from OpenVibe.Host's data role, schema in migrations/ (0001_initial expand, 0002_drop_legacy_import contract), async on openvibe-sdk/db v0.25.0, money paths serializable with row locks, PostgreSQL outbox and inbox.",
"database": "PostgreSQL 18 through PgBouncer (transaction mode) as the system of record, Valkey 9 for shared state (per-actor limits, overlay fan-out, overlay stream slots), ADR-035: database ov_tips from OpenVibe.Host's data role, schema in migrations/ (0001_initial expand, 0002_drop_legacy_import contract), async on openvibe-sdk/db v0.25.0, money paths serializable with row locks, PostgreSQL outbox and inbox.",
"contracts": "openvibe-contracts v0.79.0",
"contracts": "openvibe-contracts v0.79.0",
"notes": "Runtime, API, SSR pages, overlays, the Billing settlement consumer and the events outbox are built, tested and deployed, together with /metrics (loopback only), truthful /api/ready (Billing and Events checks), supporter privacy (anonymous, hidden amount, private message), goal and supporter-page settings, export and erasure (tips.interaction.erased), moderation (word filter, hide/show, invited moderators, tips.interaction.moderated) and per-actor limits. Capabilities (18 tips.*) and payload schemas are in openvibe-contracts. No billing.* event had arrived: Billing runs in shadow. A written internal threat review (docs/threat-review.md) with its fixes and test/security.test.js. Not done: an independent security review; the openvibe.tips launch. Live stays the place tips happen until the Billing cutover (blocked on the owner re-pointing the PowerChat webhook) and the launch rule in README.md holds.",
"notes": "Runtime, API, SSR pages, overlays, the Billing settlement consumer and the events outbox are built, tested and deployed, together with /metrics (loopback only), truthful /api/ready (Billing and Events checks), supporter privacy (anonymous, hidden amount, private message), goal and supporter-page settings, export and erasure (tips.interaction.erased), moderation (word filter, hide/show, invited moderators, tips.interaction.moderated) and per-actor limits. Capabilities (18 tips.*) and payload schemas are in openvibe-contracts. No billing.* event had arrived: Billing runs in shadow. A written internal threat review (docs/threat-review.md) with its fixes and test/security.test.js. Not done: an independent security review; the openvibe.tips launch. Live stays the place tips happen until the Billing cutover (blocked on the owner re-pointing the PowerChat webhook) and the launch rule in README.md holds.",
- "updated": "2026-09-29",
+ "updated": "2026-10-04",
"features": [
"features": [
"tips, paid messages, TTS and media requests settled by OpenVibe.Billing (never Tips' own money)",
"tips, paid messages, TTS and media requests settled by OpenVibe.Billing (never Tips' own money)",
"goals counted from settled payments only",
"goals counted from settled payments only",
@@ -19,7 +19,7 @@
@@ -19,7 +19,7 @@
"EXTERNAL PowerChat tips from billing.receipt.external",
"EXTERNAL PowerChat tips from billing.receipt.external",
"supporter privacy, export and erasure",
"supporter privacy, export and erasure",
"moderation: word filter, hide and show, invited moderators",
"moderation: word filter, hide and show, invited moderators",
- "Live chat delivery adapter",
+ "OpenVibe.Chat delivery adapter (typed ingress)",
"server-rendered creator pages useful without JavaScript",
"server-rendered creator pages useful without JavaScript",
"/api/v1 by capability",
"/api/v1 by capability",
"per-actor limits (openvibe-sdk/limits)",
"per-actor limits (openvibe-sdk/limits)",
deleted file mode 100644
deleted file mode 100644
@@ -1,19 +0,0 @@
@@ -1,19 +0,0 @@
-{
- "id": "live.tips_delivery.write",
- "version": "1.0",
- "owner": "live",
- "status": "active",
- "visibility": "internal",
- "description": "OpenVibe.Tips' live-chat adapter: announce a settled tip in the creator's Live chat (donation line, paid message, TTS, media request at no charge). Owned by Live (docs/live-patch.diff adds the route); proposed here for Live's manifest.",
- "permissions": [
- "live:chat"
- ],
- "resourceConstraints": [
- "none"
- ],
- "quotaClass": "standard",
- "events": [],
- "implementedBy": [
- "POST /internal/tips/deliveries"
- ]
-}
deleted file mode 100644
deleted file mode 100644
@@ -1,232 +0,0 @@
@@ -1,232 +0,0 @@
-diff --git a/server/index.js b/server/index.js
-index 4bba504..63c0969 100644
-+++ b/server/index.js
-@@ -564,6 +564,8 @@ app.get('/internal/analytics-summary', (req, res) => {
- const chatLiveContext = require('./chat/live-context-routes');
- app.use('/internal/chat-context', chatLiveContext.contextRouter);
- app.use('/internal/chat-effects', chatLiveContext.effectsRouter);
-+// OpenVibe.Tips announces settled tips in the creator's chat here (service token, live.tips_delivery.write).
-+app.use('/internal/tips', require('./tips/delivery-routes'));
- app.use('/internal', require('./internal/routes'));
-
-
-diff --git a/server/tips/delivery-routes.js b/server/tips/delivery-routes.js
-new file mode 100644
-index 0000000..ff08b97
-+++ b/server/tips/delivery-routes.js
-@@ -0,0 +1,108 @@
-+'use strict';
-+/**
-+ * OpenVibe.Tips → Live: announce a settled tip in the creator's Live chat (Tips' live-chat
-+ * delivery adapter, roadmap Wave 9). The money already moved in OpenVibe.Billing; this route only
-+ * does what the tail of POST /api/funds/donate does after the balance changed.
-+ *
-+ * POST /internal/tips/deliveries capability live.tips_delivery.write (Network service token,
-+ * audience openvibe.live; loopback-only like every /internal route)
-+ * Idempotency-Key: <interaction id>:<effect> a repeat answers the first result and does nothing
-+ * body { delivery_id, effect, test, creator: { type, id: usr_… }, supporter: { name, subject? },
-+ * interaction: { id, kind, amount, currency, message }, text, tts?: { text, voice },
-+ * media?: { url }, highlight_seconds?, target?: { service: 'live', type: 'stream', id } }
-+ * → 200 { ok: true, ref } 404 creator has no Live channel (permanent) 409 not live (retried)
-+ *
-+ * chat_line | paid_message donation event to the channel room + global, saved as a 'donation'
-+ * chat message (metadata.source 'tips'), the donation alert sound
-+ * tts synthesizeAndBroadcastTTS on the live stream (or the offline channel room)
-+ * media_request into the media queue at cost 0 — Billing charged it, Tips holds the record
-+ */
-+const express = require('express');
-+const db = require('../db/database');
-+const { guard } = require('../net/service-guard');
-+
-+const router = express.Router();
-+const done = new Map(); // Idempotency-Key → response (Tips retries within minutes; bounded)
-+const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
-+
-+function channelUserId(subject) {
-+ if (!SUBJECT_RE.test(String(subject || ''))) return null;
-+ const row = db.getDb().prepare("SELECT user_id FROM linked_accounts WHERE service = 'network' AND subject_id = ? ORDER BY id DESC LIMIT 1").get(subject);
-+ return row ? row.user_id : null;
-+}
-+
-+function streamIdFor(target, userId) {
-+ if (target && target.service === 'live' && target.type === 'stream') {
-+ const s = db.getStreamById(Number(target.id));
-+ if (s && s.user_id === userId && s.is_live) return s.id;
-+ }
-+ const live = db.getStreamByUserId(userId);
-+ return live ? live.id : null;
-+}
-+
-+function remember(key, out) {
-+ done.set(key, out);
-+ if (done.size > 5000) done.delete(done.keys().next().value);
-+ return out;
-+}
-+
-+router.post('/deliveries', guard('live.tips_delivery.write'), express.json({ limit: '32kb' }), async (req, res) => {
-+ const key = String(req.get('idempotency-key') || '');
-+ if (!/^[A-Za-z0-9_:.-]{8,200}$/.test(key)) return res.status(400).json({ error: 'Idempotency-Key required' });
-+ if (done.has(key)) return res.json(done.get(key));
-+ const b = req.body || {};
-+ const i = b.interaction || {};
-+ const userId = channelUserId(b.creator && b.creator.id);
-+ if (!userId) return res.status(404).json({ error: 'this creator has no Live channel' });
-+ const chatServer = require('../chat/chat-server');
-+ const streamId = streamIdFor(b.target, userId);
-+ const name = String((b.supporter && b.supporter.name) || 'Someone').slice(0, 80);
-+ const amount = Math.max(0, Math.round(Number(i.amount) || 0));
-+ const message = String(i.message || '').slice(0, 500);
-+ try {
-+ if (b.effect === 'chat_line' || b.effect === 'paid_message') {
-+ const event = {
-+ type: 'donation', username: name, user_id: null, avatar_url: null, amount, message, timestamp: new Date().toISOString(),
-+ source: 'tips', paid_message: b.effect === 'paid_message', highlight_seconds: Number(b.highlight_seconds) || 0,
-+ };
-+ chatServer.broadcastToChannelRoom(userId, streamId, event);
-+ try { chatServer.broadcastGlobal({ ...event, global: true, channel_user_id: userId }); } catch { /* non-critical */ }
-+ const saved = db.saveChatMessage({
-+ stream_id: streamId, channel_user_id: userId, user_id: null, username: name,
-+ message: String(b.text || `${name} tipped ${amount.toLocaleString()} Vibes${message ? ': ' + message : ''}`).slice(0, 1000),
-+ message_type: 'donation',
-+ metadata: { kind: 'donation', amount, message, username: name, source: 'tips', interaction_id: i.id || null, paid_message: event.paid_message, highlight_seconds: event.highlight_seconds, test: !!b.test },
-+ });
-+ require('../monetization/alerts').playAlertSound(chatServer, userId, streamId, 'donation');
-+ return res.json(remember(key, { ok: true, ref: { chat_message_id: saved && saved.lastInsertRowid != null ? Number(saved.lastInsertRowid) : null } }));
-+ }
-+ if (b.effect === 'tts') {
-+ const text = String((b.tts && b.tts.text) || '').slice(0, 1200);
-+ if (!text) return res.status(422).json({ error: 'no text' });
-+ await chatServer.synthesizeAndBroadcastTTS(streamId, name, text, null, 'tips', `tips:${i.id}`, userId, `tips-${i.id}`);
-+ return res.json(remember(key, { ok: true, ref: { stream_id: streamId } }));
-+ }
-+ if (b.effect === 'media_request') {
-+ const mq = require('../media/media-queue');
-+ const settings = mq.getSettings(userId);
-+ const normalized = await mq.normalizeInput(String((b.media && b.media.url) || ''), settings);
-+ const max = Number(settings.max_duration_seconds) || 600;
-+ if (Number.isFinite(normalized.duration_seconds) && normalized.duration_seconds > max) return res.status(422).json({ error: `longer than ${max} seconds` });
-+ const r = db.createMediaRequest({
-+ streamer_id: userId, stream_id: streamId, user_id: null, username: name, input: String(b.media.url),
-+ canonical_url: normalized.canonical_url, embed_url: normalized.embed_url, provider: normalized.provider, title: normalized.title,
-+ thumbnail_url: normalized.thumbnail_url, duration_seconds: normalized.duration_seconds,
-+ // Paid through OpenVibe.Billing and recorded by OpenVibe.Tips: Live charges nothing here.
-+ cost: 0, currency: 'free', queue_position: db.getMediaRequestMaxQueuePosition(userId) + 1,
-+ });
-+ mq.broadcastQueueUpdate(userId);
-+ return res.json(remember(key, { ok: true, ref: { media_request_id: Number(r.lastInsertRowid) } }));
-+ }
-+ return res.status(422).json({ error: `unknown effect ${b.effect}` });
-+ } catch (err) {
-+ console.warn('[Tips delivery]', b.effect, err.message);
-+ return res.status(422).json({ error: err.message });
-+ }
-+});
-+
-+module.exports = router;
-diff --git a/test/tips-delivery.test.js b/test/tips-delivery.test.js
-new file mode 100644
-index 0000000..2c3730a
-+++ b/test/tips-delivery.test.js
-@@ -0,0 +1,99 @@
-+'use strict';
-+
-+// OpenVibe.Tips → Live (roadmap Wave 9): POST /internal/tips/deliveries announces a settled tip in the
-+// creator's chat — service token with live.tips_delivery.write, idempotent per delivery id, the
-+// creator found by Network subject, no Live balance touched.
-+
-+const assert = require('assert');
-+const fs = require('fs');
-+const os = require('os');
-+const path = require('path');
-+const http = require('http');
-+const crypto = require('crypto');
-+const express = require('express');
-+const { serviceAuth } = require('openvibe-contracts');
-+
-+const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-tips-delivery-'));
-+const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
-+fs.writeFileSync(path.join(tmp, 'network.pem'), keys.publicKey);
-+fs.mkdirSync(path.join(tmp, 'sounds'));
-+process.env.DB_PATH = path.join(tmp, 'live.db');
-+process.env.OV_NETWORK_PUBLIC_KEY = path.join(tmp, 'network.pem');
-+process.env.OV_NETWORK_URL = 'https://openvibe.network';
-+process.env.SOUNDS_PATH = path.join(tmp, 'sounds');
-+console.log = () => {};
-+console.warn = () => {};
-+
-+const ISS = 'https://openvibe.network';
-+const SUBJECT = 'usr_01J0000000000000000000000Z';
-+const now = () => Math.floor(Date.now() / 1000);
-+let jti = 0;
-+const token = (cap, aud = 'openvibe.live') => serviceAuth.signServiceToken({ iss: ISS, sub: 'svc:tips', actor_type: 'service', aud: [aud], cap, iat: now(), exp: now() + 300, jti: `tok_tips_${++jti}` }, keys.privateKey);
-+
-+(async () => {
-+ const db = require('../server/db/database');
-+ db.initDb();
-+ const d = db.getDb();
-+ d.prepare("INSERT INTO users (id, username, display_name, password_hash, openvibe_bucks_balance, openvibe_bucks_cashout_balance) VALUES (501, 'alex', 'Alex', 'x', 0, 0)").run();
-+ d.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (501, 'network', '77', ?)").run(SUBJECT);
-+
-+ const chatServer = require('../server/chat/chat-server');
-+ const broadcasts = [];
-+ chatServer.broadcastToChannelRoom = (uid, sid, ev) => broadcasts.push({ uid, sid, ev });
-+ chatServer.broadcastGlobal = () => {};
-+ const tts = [];
-+ chatServer.synthesizeAndBroadcastTTS = async (...a) => { tts.push(a); };
-+
-+ const app = express();
-+ app.use('/internal/tips', require('../server/tips/delivery-routes'));
-+ const server = await new Promise((r) => { const s = http.createServer(app); s.listen(0, '127.0.0.1', () => r(s)); });
-+ const base = `http://127.0.0.1:${server.address().port}`;
-+ const post = (body, { cap = ['live.tips_delivery.write'], key = 'tint_1:chat_line', headers = {} } = {}) => fetch(`${base}/internal/tips/deliveries`, {
-+ method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token(cap)}`, 'Idempotency-Key': key, ...headers }, body: JSON.stringify(body),
-+ }).then(async (r) => ({ status: r.status, json: await r.json().catch(() => null) }));
-+
-+ const job = {
-+ delivery_id: 'tint_1:chat_line', effect: 'chat_line', test: false, creator: { type: 'user', id: SUBJECT },
-+ supporter: { name: 'Viewer' }, interaction: { id: 'tint_1', kind: 'tip', amount: 250, currency: 'vibes-bits', message: 'gg' },
-+ text: 'Viewer tipped 250 Vibes: gg',
-+ };
-+
-+ assert.strictEqual((await post(job, { cap: ['live.chat_effects.write'] })).status, 403, 'needs live.tips_delivery.write');
-+ assert.strictEqual((await post(job, { headers: { 'X-Forwarded-For': '1.2.3.4' } })).status, 403, 'loopback only');
-+
-+ const a = await post(job);
-+ assert.strictEqual(a.status, 200);
-+ assert.ok(a.json.ref.chat_message_id > 0);
-+ assert.strictEqual(broadcasts.length, 1);
-+ assert.strictEqual(broadcasts[0].uid, 501);
-+ assert.strictEqual(broadcasts[0].ev.type, 'donation');
-+ assert.strictEqual(broadcasts[0].ev.amount, 250);
-+ const saved = d.prepare("SELECT * FROM chat_messages WHERE message_type = 'donation'").get();
-+ assert.strictEqual(saved.message, 'Viewer tipped 250 Vibes: gg');
-+ assert.strictEqual(JSON.parse(saved.metadata).source, 'tips');
-+
-+ // A retry of the same delivery changes nothing.
-+ const b = await post(job);
-+ assert.deepStrictEqual(b.json, a.json);
-+ assert.strictEqual(broadcasts.length, 1);
-+ assert.strictEqual(d.prepare("SELECT COUNT(*) AS n FROM chat_messages WHERE message_type = 'donation'").get().n, 1);
-+
-+ // TTS on the (offline) channel room.
-+ const t = await post({ ...job, effect: 'tts', tts: { text: 'read me', voice: 'gary' } }, { key: 'tint_1:tts' });
-+ assert.strictEqual(t.status, 200);
-+ assert.strictEqual(tts[0][2], 'read me');
-+ assert.strictEqual(tts[0][6], 501);
-+
-+ // Unknown creator: permanent refusal.
-+ const u = await post({ ...job, creator: { type: 'user', id: 'usr_01J0000000000000000000000Y' } }, { key: 'tint_2:chat_line' });
-+ assert.strictEqual(u.status, 404);
-+
-+ // No Live balance moved.
-+ const user = d.prepare('SELECT openvibe_bucks_balance, openvibe_bucks_cashout_balance FROM users WHERE id = 501').get();
-+ assert.deepStrictEqual(user, { openvibe_bucks_balance: 0, openvibe_bucks_cashout_balance: 0 });
-+ assert.strictEqual(d.prepare("SELECT COUNT(*) AS n FROM transactions").get().n, 0);
-+
-+ server.close();
-+ process.stdout.write('tips-delivery: all passed\n');
-+ process.exit(0);
-+})().catch((e) => { process.stderr.write(`${e.stack}\n`); process.exit(1); });
@@ -6,7 +6,8 @@ This is a written internal review, not an independent audit. The roadmap's gate
@@ -6,7 +6,8 @@ This is a written internal review, not an independent audit. The roadmap's gate
outside review before launch.
outside review before launch.
**Method:** we read the code paths end to end (`server/`, `public/js/overlay.js`,
**Method:** we read the code paths end to end (`server/`, `public/js/overlay.js`,
-`deploy/nginx/openvibe.tips.conf`), and Live's `/internal/tips/deliveries` where Tips depends on it.
+`deploy/nginx/openvibe.tips.conf`), and OpenVibe.Chat's `/internal/chat/messages` and `/internal/chat/events`
+where Tips depends on them.
Every claim below is backed by a test in `test/` or names the line that enforces it. Nothing was run
Every claim below is backed by a test in `test/` or names the line that enforces it. Nothing was run
against production. Production runs Tips on loopback only, with an empty database, so none of these
against production. Production runs Tips on loopback only, with an empty database, so none of these
issues was ever reachable from the internet.
issues was ever reachable from the internet.
@@ -22,7 +23,7 @@ issues was ever reachable from the internet.
@@ -22,7 +23,7 @@ issues was ever reachable from the internet.
| OBS / an overlay client | an overlay token in the URL | `/overlay/<token>`, `/events` (SSE), `/state` |
| OBS / an overlay client | an overlay token in the URL | `/overlay/<token>`, `/events` (SSE), `/state` |
| First-party service | a Network service token with `tips.*` capabilities | `/api/v1` routes, one capability each |
| First-party service | a Network service token with `tips.*` capabilities | `/api/v1` routes, one capability each |
| OpenVibe.Billing (via Events) | the webhook signing secret | `POST /internal/events` on loopback |
| OpenVibe.Billing (via Events) | the webhook signing secret | `POST /internal/events` on loopback |
-| OpenVibe.Live | Tips' service token (`live.tips_delivery.write`) | Tips calls Live; Live never calls Tips |
+| OpenVibe.Chat | Tips' service token (`chat.message.send`, `chat.event.publish`) | Tips calls Chat; Chat never calls Tips |
**What we protect:**
**What we protect:**
@@ -52,7 +53,7 @@ documented), **elsewhere** (the fix belongs to another repository).
@@ -52,7 +53,7 @@ documented), **elsewhere** (the fix belongs to another repository).
| 12 | Replay | Webhook, API, form, overlay and event replays. | **holds** | See [Replay](#replay). `test/settlement.test.js`, `test/overlays.test.js`, `test/api.test.js`, `test/pages.test.js` |
| 12 | Replay | Webhook, API, form, overlay and event replays. | **holds** | See [Replay](#replay). `test/settlement.test.js`, `test/overlays.test.js`, `test/api.test.js`, `test/pages.test.js` |
| 13 | Privacy | A hidden amount still moves a public goal bar by that amount. | **residual** | Stated on the tip form. The goal update of such a tip names nobody. |
| 13 | Privacy | A hidden amount still moves a public goal bar by that amount. | **residual** | Stated on the tip form. The goal update of such a tip names nobody. |
| 14 | Privacy | Internal events carry the amount, and the supporter's subject when the tip is not anonymous. | **residual** | The events are `internal` visibility. Goals and reconciliation need the amount. Erasure redacts them in Events. |
| 14 | Privacy | Internal events carry the amount, and the supporter's subject when the tip is not anonymous. | **residual** | The events are `internal` visibility. Goals and reconciliation need the amount. Erasure redacts them in Events. |
-| 15 | Privacy, chat | Live's delivery route turns a hidden amount (`null`) into a donation event of 0 Vibes, and Live's chat renders it as "donated 0 Vibes". Live also remembers delivery ids in memory only. | **elsewhere** (Live) | The job carries `privacy.hide_amount`. Live should render a line with no amount. |
+| 15 | Privacy, chat | A tip with a hidden amount is posted to Chat as a donation line whose text has no amount and whose `metadata.amount` is `null`. Chat persists the delivery key. | **elsewhere** (Chat) | Chat should render the line's text, never `metadata.amount` as 0. |
| 16 | Overlays | Overlay tokens never expire. | **residual** | The creator can revoke a token, and revocation closes open streams at once. Every token records when it was last used. |
| 16 | Overlays | Overlay tokens never expire. | **residual** | The creator can revoke a token, and revocation closes open streams at once. Every token records when it was last used. |
| 17 | Rate limits | Tips has no rate limiting of its own. | **residual** | The nginx reference config limits `/api/`, `/auth/` and the form posts. Money limits paid actions, and fixes 3 and 4 bound the rest. |
| 17 | Rate limits | Tips has no rate limiting of its own. | **residual** | The nginx reference config limits `/api/`, `/auth/` and the form posts. Money limits paid actions, and fixes 3 and 4 bound the rest. |
| 18 | Paid-message abuse | Supporter names are still free text: only an exact copy of the creator's name is refused. Donor names that Billing relays from PowerChat are not checked. | **residual** | The word filter and moderation cover names. |
| 18 | Paid-message abuse | Supporter names are still free text: only an exact copy of the creator's name is refused. Donor names that Billing relays from PowerChat are not checked. | **residual** | The word filter and moderation cover names. |
@@ -101,7 +102,7 @@ documented), **elsewhere** (the fix belongs to another repository).
@@ -101,7 +102,7 @@ documented), **elsewhere** (the fix belongs to another repository).
- **Injection.**
- **Injection.**
- Pages escape everything with `esc()`, and JSON-LD escapes `<`.
- Pages escape everything with `esc()`, and JSON-LD escapes `<`.
- The overlay uses `textContent`.
- The overlay uses `textContent`.
- - Chat lines are plain text that Tips builds. Live renders them with its own escaping, which is Live's
+ - Chat lines are plain text that Tips builds. Chat renders them with its own escaping, which is Chat's
responsibility.
responsibility.
- **Impersonation and Unicode tricks:** fixed (#5 and #6). Long runs of combining marks are cut at display.
- **Impersonation and Unicode tricks:** fixed (#5 and #6). Long runs of combining marks are cut at display.
- **Media requests** cannot be private and go through moderation like paid messages. The URL is shown
- **Media requests** cannot be private and go through moderation like paid messages. The URL is shown
@@ -109,8 +110,8 @@ documented), **elsewhere** (the fix belongs to another repository).
@@ -109,8 +110,8 @@ documented), **elsewhere** (the fix belongs to another repository).
## TTS
## TTS
-- Tips never calls a speech provider. It hands text to Live's `synthesizeAndBroadcastTTS`, and to
- OpenVibe.Chat when an adapter exists.
+- Tips never calls a speech provider. It hands text to OpenVibe.Chat (a `tts` line with `tts: { voice }`),
+ which synthesizes and broadcasts it.
- The text passes through `cleanTts`, which removes control and invisible characters, turns links into
- The text passes through `cleanTts`, which removes control and invisible characters, turns links into
"link" and strips markup (#7). The creator's filter then drops blocked words from it rather than
"link" and strips markup (#7). The creator's filter then drops blocked words from it rather than
starring them. Voices come from an allowlist.
starring them. Voices come from an allowlist.
@@ -204,8 +205,7 @@ tests validate them against the drafts.
@@ -204,8 +205,7 @@ tests validate them against the drafts.
## Follow-ups
## Follow-ups
-1. **Live:** render a tip whose amount is hidden (`privacy.hide_amount`, `interaction.amount: null`)
- without an amount, and persist the delivery idempotency key (#15).
+1. **Chat:** render a donation line whose `metadata.amount` is `null` without an amount (#15).
2. **Contracts:** add the three items above, then register them in `manifests/services/tips.json`.
2. **Contracts:** add the three items above, then register them in `manifests/services/tips.json`.
3. **Before launch:** get an independent review of this document and the code. Consider optional overlay
3. **Before launch:** get an independent review of this document and the code. Consider optional overlay
token expiry (#16).
token expiry (#16).
@@ -49,10 +49,12 @@ function loadConfig(env = process.env) {
@@ -49,10 +49,12 @@ function loadConfig(env = process.env) {
internalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
internalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
issuer: trim(env.OV_NETWORK_ISSUER || networkUrl),
issuer: trim(env.OV_NETWORK_ISSUER || networkUrl),
publicKey: env.OV_NETWORK_PUBLIC_KEY ? env.OV_NETWORK_PUBLIC_KEY.replace(/\\n/g, '\n') : null,
publicKey: env.OV_NETWORK_PUBLIC_KEY ? env.OV_NETWORK_PUBLIC_KEY.replace(/\\n/g, '\n') : null,
+ // Tips' service tokens for the Network's own API (identity.subject.resolve: a creator's Chat room).
+ audience: env.OV_NETWORK_AUDIENCE || 'openvibe.network',
},
},
audience: env.TIPS_AUDIENCE || 'openvibe.tips',
audience: env.TIPS_AUDIENCE || 'openvibe.tips',
// Tips' own client credentials (client `tips` in the Network): service tokens for Billing,
// Tips' own client credentials (client `tips` in the Network): service tokens for Billing,
- // Events and Live (chat delivery); and the OAuth code flow for browser sign-in.
+ // Events, Chat (delivery) and the Network; and the OAuth code flow for browser sign-in.
oauth: {
oauth: {
clientId: env.OV_OAUTH_CLIENT_ID || 'tips',
clientId: env.OV_OAUTH_CLIENT_ID || 'tips',
clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
@@ -88,13 +90,13 @@ function loadConfig(env = process.env) {
@@ -88,13 +90,13 @@ function loadConfig(env = process.env) {
},
},
// Chat delivery (paid messages, TTS, media requests, the tip line):
// Chat delivery (paid messages, TTS, media requests, the tip line):
- // live-chat OpenVibe.Live's /internal/tips/deliveries (docs/live-patch.diff)
+ // chat OpenVibe.Chat's /internal/chat/messages and /internal/chat/events (server/delivery/chat.js)
// test recorded here, nothing leaves the process (development, tests)
// test recorded here, nothing leaves the process (development, tests)
// none no chat effects are created
// none no chat effects are created
chat: {
chat: {
- adapter: ['live-chat', 'test', 'none'].includes(chatAdapter) ? chatAdapter : 'none',
- liveUrl: trim(env.LIVE_INTERNAL_URL || 'http://127.0.0.1:3000'),
- liveAudience: env.LIVE_AUDIENCE || 'openvibe.live',
+ adapter: ['chat', 'test', 'none'].includes(chatAdapter) ? chatAdapter : 'none',
+ url: trim(env.TIPS_CHAT_URL || 'http://127.0.0.1:4400'),
+ audience: env.CHAT_AUDIENCE || 'openvibe.chat',
maxAttempts: int(env.TIPS_DELIVERY_MAX_ATTEMPTS, 6),
maxAttempts: int(env.TIPS_DELIVERY_MAX_ATTEMPTS, 6),
backoffMs: list(env.TIPS_DELIVERY_BACKOFF_MS || '2000,10000,60000,300000,900000').map(Number),
backoffMs: list(env.TIPS_DELIVERY_BACKOFF_MS || '2000,10000,60000,300000,900000').map(Number),
},
},
new file mode 100644
new file mode 100644
@@ -0,0 +1,130 @@
@@ -0,0 +1,130 @@
+'use strict';
+
+/**
+ * chat adapter: posts a paid effect into the creator's chat room through OpenVibe.Chat's typed
+ * ingress (OpenVibe.Chat docs/chat-ingress.md), with Tips' service token (audience openvibe.chat).
+ *
+ * chat_line, paid_message POST ${TIPS_CHAT_URL}/internal/chat/messages (chat.message.send)
+ * a `donation` line in the creator's channel, mirrored to global chat
+ * POST ${TIPS_CHAT_URL}/internal/chat/events (chat.event.publish)
+ * the channel's donation alert sound (`alert` frame, kind donation)
+ * tts POST /internal/chat/messages with tts: { voice }: Chat saves a `tts` line
+ * and reads it aloud
+ * media_request none: the media queue is not Chat's, so the effect fails at once
+ *
+ * Every body carries `key` = the effect's delivery id (`<interaction>:<effect>`; the alert
+ * `<delivery id>:alert`). Chat applies a key once per principal and answers a repeat with the first
+ * result, so the effects worker's retry after a lost answer or a 5xx never posts twice.
+ *
+ * Chat's rooms are Live's ids: the creator's channel is their Live user id, read once from the
+ * Network's identity map (GET /internal/identity/resolve, identity.subject.resolve) and kept for
+ * ROOM_TTL_MS. A creator with no Live account has no room: the effect fails at once.
+ *
+ * Chat refuses a bad body with a 4xx and asks for a retry with the same key with a 5xx (503 while
+ * another Chat process holds the delivery). 4xx answers other than 401/408/425/429 are permanent;
+ * everything else is retried by the effects worker. A refused alert does not undo the line.
+ */
+const { serviceAuth } = require('openvibe-contracts');
+
+const ROOM_TTL_MS = 10 * 60_000;
+const RETRYABLE = [401, 408, 425, 429];
+
+function createChatAdapter(config, { fetchImpl = globalThis.fetch, tokenClients = {} } = {}) {
+ const tokens = { ...tokenClients };
+ const client = (audience, scope) => {
+ if (!tokens[audience]) {
+ tokens[audience] = serviceAuth.createTokenClient({
+ tokenUrl: `${config.network.internalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
+ audience, scope, fetchImpl,
+ });
+ }
+ return tokens[audience];
+ };
+ const chatTokens = () => client(config.chat.audience, 'chat.message.send chat.event.publish');
+ const networkTokens = () => client(config.network.audience, 'identity.subject.resolve');
+ const rooms = new Map();
+
+ function refused(who, status, body) {
+ const e = new Error(`${who} ${status}: ${(body && (body.error || body.detail || body.code)) || 'refused'}`);
+ e.permanent = status >= 400 && status < 500 && !RETRYABLE.includes(status);
+ return e;
+ }
+
+ async function call(who, tokenClient, url, init, retried = false) {
+ let res;
+ try {
+ res = await fetchImpl(url, { ...init, headers: { Accept: 'application/json', ...init.headers, ...(await tokenClient.authHeaders()) }, signal: AbortSignal.timeout(10000) });
+ } catch (e) {
+ throw new Error(`${who} unreachable: ${e.message}`);
+ }
+ if (res.status === 401 && !retried) { tokenClient.invalidate(); return await call(who, tokenClient, url, init, true); }
+ const body = await res.json().catch(() => null);
+ return { status: res.status, ok: res.ok, body };
+ }
+
+ /** The creator's Chat room: their Live user id, from the Network's identity map. */
+ async function roomOf(subject) {
+ const hit = rooms.get(subject);
+ if (hit && hit.until > Date.now()) return hit.id;
+ const r = await call('Network', networkTokens(), `${config.network.internalUrl}/internal/identity/resolve?subject_id=${encodeURIComponent(subject)}`, { method: 'GET' });
+ if (!r.ok && r.status !== 404) throw refused('Network', r.status, r.body);
+ const live = r.ok && (r.body.legacy_ids || []).find((x) => x.source_system === 'live' && x.source_type === 'user');
+ const id = live ? Number(live.source_id) : 0;
+ if (!Number.isSafeInteger(id) || id <= 0) throw Object.assign(new Error('this creator has no Live channel for Chat to post in'), { permanent: true });
+ rooms.set(subject, { id, until: Date.now() + ROOM_TTL_MS });
+ return id;
+ }
+
+ async function post(family, body) {
+ const r = await call('Chat', chatTokens(), `${config.chat.url}/internal/chat/${family}`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
+ });
+ if (!r.ok) throw refused('Chat', r.status, r.body);
+ return r.body || {};
+ }
+
+ /** The R1 body of a job (the same on every attempt, so Chat's key replay matches it). */
+ function message(job, channel) {
+ const i = job.interaction;
+ const name = job.supporter.name;
+ if (job.effect === 'tts') {
+ return {
+ key: job.delivery_id, channel_user_id: channel, username: name, message: job.tts.text, message_type: 'tts', source_platform: 'tips',
+ tts: { voice: job.tts.voice || null, identity_key: `tips:${i.id}`, key: `tips-${i.id}` },
+ metadata: { kind: 'tts', source: 'tips', interaction_id: i.id, test: job.test },
+ };
+ }
+ const paid = job.effect === 'paid_message';
+ return {
+ key: job.delivery_id, channel_user_id: channel, username: name, message: job.text, message_type: 'donation', source_platform: 'tips', mirror: true,
+ metadata: {
+ kind: 'donation', source: 'tips', amount: i.amount, message: i.message || '', username: name, interaction_id: i.id,
+ paid_message: paid, highlight_seconds: paid ? job.highlight_seconds || 0 : 0, test: job.test,
+ },
+ };
+ }
+
+ async function deliver(job) {
+ if (!['chat_line', 'paid_message', 'tts'].includes(job.effect)) {
+ throw Object.assign(new Error(`OpenVibe.Chat has no ${job.effect} route`), { permanent: true });
+ }
+ if (job.effect === 'tts' && !(job.tts && job.tts.text)) throw Object.assign(new Error('no TTS text'), { permanent: true });
+ const channel = await roomOf(job.creator.id);
+ const sent = await post('messages', message(job, channel));
+ const ref = { chat_message_id: sent.id != null ? sent.id : null };
+ if (job.effect !== 'tts') {
+ try {
+ await post('events', { key: `${job.delivery_id}:alert`, target: { kind: 'channel', id: channel }, frame: { type: 'alert', streamerId: channel, kind: 'donation' } });
+ ref.alert = true;
+ } catch (e) {
+ if (!e.permanent) throw e; // retried: the line replays under its key, the alert is sent
+ ref.alert = false;
+ }
+ }
+ return { ref };
+ }
+
+ return { name: 'chat', deliver };
+}
+
+module.exports = { createChatAdapter };
@@ -10,9 +10,9 @@
@@ -10,9 +10,9 @@
* text, tts?: { text, voice }, media?: { url }, highlight_seconds?, target?: EntityRef }
* text, tts?: { text, voice }, media?: { url }, highlight_seconds?, target?: EntityRef }
*
*
* Adapters (TIPS_CHAT_ADAPTER):
* Adapters (TIPS_CHAT_ADAPTER):
- * live-chat OpenVibe.Live's /internal/tips/deliveries — the existing donation path (broadcast to the
- * channel room + global, saved as a 'donation' chat message, the alert sound); see
- * docs/live-patch.diff. Used until OpenVibe.Chat exposes a public paid-message API.
+ * chat OpenVibe.Chat's typed ingress (/internal/chat/messages and /internal/chat/events):
+ * a 'donation' line in the creator's channel and global chat, the alert sound, TTS read
+ * aloud; see ./chat.js
* test records jobs in memory (development, tests, and simulations when no chat target should
* test records jobs in memory (development, tests, and simulations when no chat target should
* hear a test)
* hear a test)
* none no chat effects are created at all
* none no chat effects are created at all
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
* A delivery failure is retried with backoff and finally recorded as failed on the effect; it never
* A delivery failure is retried with backoff and finally recorded as failed on the effect; it never
* touches the interaction's payment state (the money is Billing's and already settled).
* touches the interaction's payment state (the money is Billing's and already settled).
*/
*/
-const { createLiveChatAdapter } = require('./live-chat');
+const { createChatAdapter } = require('./chat');
function createTestAdapter({ name = 'test' } = {}) {
function createTestAdapter({ name = 'test' } = {}) {
const jobs = [];
const jobs = [];
@@ -43,7 +43,7 @@ function createTestAdapter({ name = 'test' } = {}) {
@@ -43,7 +43,7 @@ function createTestAdapter({ name = 'test' } = {}) {
function createAdapters(config, { fetchImpl } = {}) {
function createAdapters(config, { fetchImpl } = {}) {
const adapters = { test: createTestAdapter() };
const adapters = { test: createTestAdapter() };
- if (config.chat.adapter === 'live-chat') adapters['live-chat'] = createLiveChatAdapter(config, { fetchImpl });
+ if (config.chat.adapter === 'chat') adapters.chat = createChatAdapter(config, { fetchImpl });
return adapters;
return adapters;
}
}
deleted file mode 100644
deleted file mode 100644
@@ -1,59 +0,0 @@
@@ -1,59 +0,0 @@
-'use strict';
-
-/**
- * live-chat adapter: posts a paid effect into a creator's OpenVibe.Live chat through Live's
- * existing donation path.
- *
- * POST ${LIVE_INTERNAL_URL}/internal/tips/deliveries
- * Authorization: Bearer <Tips service token, audience openvibe.live, capability live.tips_delivery.write>
- * Idempotency-Key: <delivery_id> (Live answers a repeat with the first result)
- * body: the job (server/delivery/index.js)
- * → 200 { ok: true, ref: { chat_message_id?, media_request_id? } }
- *
- * Live has no such route today: docs/live-patch.diff adds it (server/tips/delivery-routes.js,
- * loopback-only like every /internal route, guarded by Live's service-guard). Live maps the creator
- * subject to its channel through linked_accounts and then does exactly what POST /api/funds/donate
- * does after the money moved: broadcastToChannelRoom + broadcastGlobal of the donation event,
- * saveChatMessage(message_type 'donation'), the donation alert sound; TTS through
- * synthesizeAndBroadcastTTS; media requests into its media queue at no charge (Billing already
- * charged). When OpenVibe.Chat publishes a paid-message capability, a `chat` adapter replaces this.
- *
- * 4xx answers other than 408/409/425/429 are permanent (the effect fails at once); everything
- * else is retried by the effects worker.
- */
-const { serviceAuth } = require('openvibe-contracts');
-
-function createLiveChatAdapter(config, { fetchImpl = globalThis.fetch, tokenClient } = {}) {
- let tokens = tokenClient || null;
- const client = () => {
- if (!tokens) {
- tokens = serviceAuth.createTokenClient({
- tokenUrl: `${config.network.internalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
- audience: config.chat.liveAudience, scope: 'live.tips_delivery.write', fetchImpl,
- });
- }
- return tokens;
- };
-
- async function deliver(job, retried = false) {
- const headers = { 'Content-Type': 'application/json', Accept: 'application/json', 'Idempotency-Key': job.delivery_id, ...(await client().authHeaders()) };
- let res;
- try {
- res = await fetchImpl(`${config.chat.liveUrl}/internal/tips/deliveries`, { method: 'POST', headers, body: JSON.stringify(job), signal: AbortSignal.timeout(10000) });
- } catch (e) {
- throw new Error(`Live unreachable: ${e.message}`);
- }
- if (res.status === 401 && !retried) { client().invalidate(); return await deliver(job, true); }
- const body = await res.json().catch(() => null);
- if (!res.ok) {
- const e = new Error(`Live ${res.status}: ${(body && (body.detail || body.error || body.code)) || 'refused'}`);
- e.permanent = res.status >= 400 && res.status < 500 && ![401, 408, 409, 425, 429].includes(res.status);
- throw e;
- }
- return { ref: (body && body.ref) || null };
- }
-
- return { name: 'live-chat', deliver };
-}
-
-module.exports = { createLiveChatAdapter };
@@ -8,9 +8,9 @@ const { boot, check, done } = require('./helpers/app');
@@ -8,9 +8,9 @@ const { boot, check, done } = require('./helpers/app');
const TYPES = ['tips.interaction.ready', 'tips.interaction.failed', 'tips.interaction.cancelled', 'tips.goal.updated', 'tips.overlay.delivered', 'tips.overlay.failed'];
const TYPES = ['tips.interaction.ready', 'tips.interaction.failed', 'tips.interaction.cancelled', 'tips.goal.updated', 'tips.overlay.delivered', 'tips.overlay.failed'];
(async () => {
(async () => {
- const t = await boot({ live: true });
- const { domain, billing, live } = t;
- const alex = await t.creator('alex', { settings: { tts_enabled: true } });
+ const t = await boot({ chat: true });
+ const { domain, billing, chat } = t;
+ const alex = await t.creator('alex', { liveId: 501, settings: { tts_enabled: true } });
const viewer = t.network.newUser('viewer');
const viewer = t.network.newUser('viewer');
billing.fund(viewer.subject, 10_000);
billing.fund(viewer.subject, 10_000);
@@ -26,11 +26,11 @@ const TYPES = ['tips.interaction.ready', 'tips.interaction.failed', 'tips.intera
@@ -26,11 +26,11 @@ const TYPES = ['tips.interaction.ready', 'tips.interaction.failed', 'tips.intera
await s.waitFor((e) => e.event === 'alert' && e.data.interaction_id === tip.json.interaction.id);
await s.waitFor((e) => e.event === 'alert' && e.data.interaction_id === tip.json.interaction.id);
s.close();
s.close();
await domain.effects.drain();
await domain.effects.drain();
- // A delivery Live refuses (tips.interaction.failed), an overlay nobody showed (tips.overlay.failed).
- live.state.fail = 422;
+ // A delivery Chat refuses (tips.interaction.failed), an overlay nobody showed (tips.overlay.failed).
+ chat.state.fail = 400;
await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: 'alex', amount: 5 } });
await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: 'alex', amount: 5 } });
await domain.effects.drain();
await domain.effects.drain();
- live.state.fail = null;
+ chat.state.fail = null;
t.clock.offset += 11 * 60 * 1000;
t.clock.offset += 11 * 60 * 1000;
await domain.overlays.sweepFailed();
await domain.overlays.sweepFailed();
t.clock.offset = 0;
t.clock.offset = 0;
@@ -1,78 +1,160 @@
@@ -1,78 +1,160 @@
'use strict';
'use strict';
-// The live-chat delivery adapter: Tips' service token for Live, one delivery id per effect, retries
-// for outages, an immediate stop for refusals — and never a change to the payment.
+// The chat delivery adapter: OpenVibe.Chat's typed ingress with Tips' service token, the creator's room
+// from the Network's identity map, one key per effect (the delivery id) so a retry never posts twice,
+// retries for outages, an immediate stop for refusals — and never a change to the payment.
const assert = require('assert');
const assert = require('assert');
const { boot, check, done } = require('./helpers/app');
const { boot, check, done } = require('./helpers/app');
(async () => {
(async () => {
- const t = await boot({ live: true });
- const { domain, billing, live } = t;
- await t.creator('alex', { settings: { tts_enabled: true } });
+ const t = await boot({ chat: true });
+ const { domain, billing, chat } = t;
+ const alex = await t.creator('alex', { liveId: 501, settings: { tts_enabled: true, media_requests_enabled: true } });
const viewer = t.network.newUser('viewer');
const viewer = t.network.newUser('viewer');
- billing.fund(viewer.subject, 5000);
+ billing.fund(viewer.subject, 20_000);
+ const effectOf = (id, effect) => t.db.maybe('SELECT * FROM interaction_effects WHERE interaction_id = $1 AND effect = $2', [id, effect]);
+ const messagesFor = (id) => chat.messages.filter((m) => m.body.metadata.interaction_id === id);
- await check('a settled tip is posted to Live with a service token (openvibe.live, live.tips_delivery.write)', async () => {
- const r = await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: 'alex', amount: 120, message: 'gg', target: { service: 'live', type: 'stream', id: '42' } } });
+ await check('a settled tip: a donation line (R1) and the alert (R2) in the creator\'s Live channel, keyed by the delivery id', async () => {
+ const r = await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: 'alex', amount: 1200, message: 'gg' } });
+ const id = r.json.interaction.id;
await domain.effects.drain();
await domain.effects.drain();
- const d = live.deliveries.find((x) => x.job.interaction.id === r.json.interaction.id);
- assert.ok(d, 'delivered to Live');
- assert.strictEqual(d.key, `${r.json.interaction.id}:chat_line`);
- assert.strictEqual(d.job.effect, 'chat_line');
- assert.strictEqual(d.job.text, 'Viewer tipped 120 Vibes: gg');
- assert.deepStrictEqual(d.job.target, { service: 'live', type: 'stream', id: '42' });
- assert.strictEqual(d.job.creator.handle, 'alex');
- assert.strictEqual(d.job.test, false);
- const grant = t.network.grants.find((g) => g.audience === 'openvibe.live');
- assert.strictEqual(grant.scope, 'live.tips_delivery.write');
- const e = await t.db.maybe("SELECT * FROM interaction_effects WHERE interaction_id = $1 AND effect = 'chat_line'", [r.json.interaction.id]);
- assert.deepStrictEqual(e.result.ref, { chat_message_id: 1 }, 'jsonb comes back parsed');
+ const [m] = messagesFor(id);
+ assert.ok(m, 'posted to Chat');
+ assert.deepStrictEqual(m.body, {
+ key: `${id}:chat_line`, channel_user_id: 501, username: 'Viewer', message: 'Viewer tipped 1,200 Vibes: gg', message_type: 'donation',
+ source_platform: 'tips', mirror: true,
+ metadata: { kind: 'donation', source: 'tips', amount: 1200, message: 'gg', username: 'Viewer', interaction_id: id, paid_message: false, highlight_seconds: 0, test: false },
+ });
+ const alert = chat.events.find((e) => e.key === `${id}:chat_line:alert`);
+ assert.deepStrictEqual(alert.body, { key: `${id}:chat_line:alert`, target: { kind: 'channel', id: 501 }, frame: { type: 'alert', streamerId: 501, kind: 'donation' } });
+ assert.deepStrictEqual((await effectOf(id, 'chat_line')).result.ref, { chat_message_id: chat.messages.indexOf(m) + 1, alert: true });
+ assert.strictEqual(t.network.grants.find((g) => g.audience === 'openvibe.chat').scope, 'chat.message.send chat.event.publish');
+ assert.strictEqual(t.network.grants.find((g) => g.audience === 'openvibe.network').scope, 'identity.subject.resolve');
+ assert.strictEqual(t.network.resolves.filter((u) => u.includes(encodeURIComponent(alex.subject))).length, 1);
});
});
- await check('TTS goes out as its own effect with the text and voice', async () => {
+ await check('a paid message is a donation line flagged paid_message with its highlight', async () => {
+ const r = await t.call('POST', '/api/v1/paid-messages', { user: viewer, body: { creator: 'alex', amount: 500, message: 'hello chat' } });
+ assert.strictEqual(r.status, 201, r.text);
+ const id = r.json.interaction.id;
+ await domain.effects.drain();
+ const [m] = messagesFor(id);
+ const pm = await t.db.maybe('SELECT * FROM paid_messages WHERE interaction_id = $1', [id]);
+ assert.deepStrictEqual([m.key, m.body.message_type, m.body.mirror, m.body.metadata.paid_message, m.body.metadata.highlight_seconds, m.body.metadata.message],
+ [`${id}:paid_message`, 'donation', true, true, pm.highlight_seconds, 'hello chat']);
+ assert.strictEqual(pm.status, 'delivered');
+ assert.ok(chat.events.some((e) => e.key === `${id}:paid_message:alert`));
+ assert.strictEqual(t.network.resolves.length, 1, 'the room is remembered');
+ });
+
+ await check('TTS: the donation line, then a tts line Chat reads aloud with the filtered text and the voice', async () => {
const r = await t.call('POST', '/api/v1/tts-requests', { user: viewer, body: { creator: 'alex', amount: 150, tts: { text: 'hello https://x.example there' } } });
const r = await t.call('POST', '/api/v1/tts-requests', { user: viewer, body: { creator: 'alex', amount: 150, tts: { text: 'hello https://x.example there' } } });
assert.strictEqual(r.status, 201, r.text);
assert.strictEqual(r.status, 201, r.text);
+ const id = r.json.interaction.id;
await domain.effects.drain();
await domain.effects.drain();
- const d = live.deliveries.find((x) => x.job.interaction.id === r.json.interaction.id && x.job.effect === 'tts');
- assert.deepStrictEqual(d.job.tts, { text: 'hello link there', voice: 'gary' });
+ const tts = messagesFor(id).find((m) => m.key === `${id}:tts`);
+ assert.deepStrictEqual(tts.body, {
+ key: `${id}:tts`, channel_user_id: 501, username: 'Viewer', message: 'hello link there', message_type: 'tts', source_platform: 'tips',
+ tts: { voice: 'gary', identity_key: `tips:${id}`, key: `tips-${id}` }, metadata: { kind: 'tts', source: 'tips', interaction_id: id, test: false },
+ });
+ assert.ok(messagesFor(id).some((m) => m.key === `${id}:chat_line`));
+ assert.ok(!chat.events.some((e) => e.key === `${id}:tts:alert`), 'one alert per tip, on its line');
});
});
- await check('Live down (503): retried with backoff; the payment stays settled throughout', async () => {
- live.state.fail = 503;
+ await check('Chat down (503): retried later with the same key, posted once; the payment stays settled throughout', async () => {
+ chat.state.fail = 503;
const r = await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: 'alex', amount: 7 } });
const r = await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: 'alex', amount: 7 } });
const id = r.json.interaction.id;
const id = r.json.interaction.id;
await domain.effects.drain();
await domain.effects.drain();
- let e = await t.db.maybe("SELECT * FROM interaction_effects WHERE interaction_id = $1 AND effect = 'chat_line'", [id]);
+ let e = await effectOf(id, 'chat_line');
assert.strictEqual(e.state, 'queued');
assert.strictEqual(e.state, 'queued');
- assert.match(e.last_error, /Live 503/);
+ assert.match(e.last_error, /Chat 503/);
assert.strictEqual((await domain.interactions.get(t.db, id)).payment_state, 'settled');
assert.strictEqual((await domain.interactions.get(t.db, id)).payment_state, 'settled');
- live.state.fail = null;
+ chat.state.fail = null;
t.clock.offset += 5000;
t.clock.offset += 5000;
await domain.effects.drain();
await domain.effects.drain();
t.clock.offset = 0;
t.clock.offset = 0;
- e = await t.db.maybe("SELECT * FROM interaction_effects WHERE interaction_id = $1 AND effect = 'chat_line'", [id]);
+ e = await effectOf(id, 'chat_line');
assert.strictEqual(e.state, 'delivered');
assert.strictEqual(e.state, 'delivered');
+ assert.strictEqual(e.attempts, 2);
+ assert.deepStrictEqual(chat.calls.filter((c) => c.route === '/internal/chat/messages' && c.key === `${id}:chat_line`).length, 2);
+ assert.strictEqual(messagesFor(id).length, 1);
assert.strictEqual((await domain.interactions.get(t.db, id)).delivery_state, 'delivered');
assert.strictEqual((await domain.interactions.get(t.db, id)).delivery_state, 'delivered');
});
});
- await check('Live refuses (422): the effect fails at once, tips.interaction.failed, payment untouched', async () => {
- live.state.fail = 422;
+ await check('the line posted but the alert hit a 503: the retry replays the line under its key (no second line) and sends the alert', async () => {
+ chat.state.failEvents = 503;
+ const r = await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: 'alex', amount: 9 } });
+ const id = r.json.interaction.id;
+ await domain.effects.drain();
+ assert.strictEqual((await effectOf(id, 'chat_line')).state, 'queued');
+ assert.strictEqual(messagesFor(id).length, 1);
+ chat.state.failEvents = null;
+ t.clock.offset += 5000;
+ await domain.effects.drain();
+ t.clock.offset = 0;
+ assert.strictEqual(messagesFor(id).length, 1, 'Chat answered the repeat with the first result');
+ assert.strictEqual(chat.events.filter((ev) => ev.key === `${id}:chat_line:alert`).length, 1);
+ assert.deepStrictEqual((await effectOf(id, 'chat_line')).result.ref.alert, true);
+ });
+
+ await check('a refused alert (4xx) does not undo the line: delivered, alert false', async () => {
+ chat.state.failEvents = 400;
+ const r = await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: 'alex', amount: 11 } });
+ await domain.effects.drain();
+ chat.state.failEvents = null;
+ const e = await effectOf(r.json.interaction.id, 'chat_line');
+ assert.strictEqual(e.state, 'delivered');
+ assert.strictEqual(e.result.ref.alert, false);
+ });
+
+ await check('Chat refuses (400): the effect fails at once without a retry, tips.interaction.failed, payment untouched', async () => {
+ chat.state.fail = 400;
const r = await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: 'alex', amount: 8 } });
const r = await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: 'alex', amount: 8 } });
const id = r.json.interaction.id;
const id = r.json.interaction.id;
await domain.effects.drain();
await domain.effects.drain();
- live.state.fail = null;
+ chat.state.fail = null;
+ const e = await effectOf(id, 'chat_line');
+ assert.deepStrictEqual([e.state, e.attempts], ['failed', 1]);
+ assert.match(e.last_error, /Chat 400/);
const i = await domain.interactions.get(t.db, id);
const i = await domain.interactions.get(t.db, id);
- assert.strictEqual(i.delivery_state, 'failed');
- assert.strictEqual(i.payment_state, 'settled');
- assert.strictEqual(billing.payable.get((await t.domain.profiles.byHandle(t.db, 'alex')).creator_subject) >= 8, true);
+ assert.deepStrictEqual([i.delivery_state, i.payment_state], ['failed', 'settled']);
+ assert.ok(billing.payable.get(alex.subject) >= 8);
assert.strictEqual((await t.outboxRows('tips.interaction.failed')).filter((ev) => ev.subject.id === id).length, 1);
assert.strictEqual((await t.outboxRows('tips.interaction.failed')).filter((ev) => ev.subject.id === id).length, 1);
});
});
- await check('simulations never reach Live: their chat effects stay in the test adapter', async () => {
- const alex = await t.domain.profiles.byHandle(t.db, 'alex');
- const before = live.deliveries.length;
- const sim = await domain.interactions.simulate(alex, { kind: 'tip', amount: 100 }, { by: alex.creator_subject });
+ await check('a creator with no Live account has no Chat room: failed at once, payment untouched, nothing posted', async () => {
+ const nolive = await t.creator('nolive');
+ const before = chat.calls.length;
+ const r = await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: 'nolive', amount: 10 } });
+ await domain.effects.drain();
+ const e = await effectOf(r.json.interaction.id, 'chat_line');
+ assert.deepStrictEqual([e.state, e.attempts], ['failed', 1]);
+ assert.match(e.last_error, /no Live channel/);
+ assert.strictEqual((await domain.interactions.get(t.db, r.json.interaction.id)).payment_state, 'settled');
+ assert.strictEqual(chat.calls.length, before);
+ assert.ok(nolive.subject);
+ });
+
+ await check('a media request has no Chat route: failed at once, the request recorded failed, payment untouched', async () => {
+ const r = await t.call('POST', '/api/v1/media-requests', { user: viewer, body: { creator: 'alex', amount: 150, media: { url: 'https://www.youtube.com/watch?v=abc' } } });
+ assert.strictEqual(r.status, 201, r.text);
+ const id = r.json.interaction.id;
+ await domain.effects.drain();
+ const e = await effectOf(id, 'media_request');
+ assert.strictEqual(e.state, 'failed');
+ assert.match(e.last_error, /no media_request route/);
+ assert.strictEqual(await t.db.value('SELECT status FROM paid_media_requests WHERE interaction_id = $1', [id]), 'failed');
+ assert.strictEqual((await domain.interactions.get(t.db, id)).payment_state, 'settled');
+ assert.strictEqual(messagesFor(id).length, 0);
+ });
+
+ await check('simulations never reach Chat: their chat effects stay in the test adapter', async () => {
+ const profile = await t.domain.profiles.byHandle(t.db, 'alex');
+ const before = chat.calls.length;
+ const sim = await domain.interactions.simulate(profile, { kind: 'tip', amount: 100 }, { by: profile.creator_subject });
await domain.effects.drain();
await domain.effects.drain();
- assert.strictEqual(live.deliveries.length, before);
+ assert.strictEqual(chat.calls.length, before);
assert.ok(t.adapters.test.jobs.some((j) => j.interaction.id === sim.id && j.test));
assert.ok(t.adapters.test.jobs.some((j) => j.interaction.id === sim.id && j.test));
});
});
@@ -1,16 +1,17 @@
@@ -1,16 +1,17 @@
'use strict';
'use strict';
// EXTERNAL PowerChat tips announced by Billing (billing.receipt.external): once the PowerChat webhook
// EXTERNAL PowerChat tips announced by Billing (billing.receipt.external): once the PowerChat webhook
// points at Billing, Live no longer celebrates a tip on a streamer's own PowerChat, so Tips does —
// points at Billing, Live no longer celebrates a tip on a streamer's own PowerChat, so Tips does —
-// the chat line through Live's /internal/tips/deliveries (as Live's webhook wrote it), the overlay
+// the chat line through OpenVibe.Chat's ingress (as Live's webhook wrote it), the overlay
// alert and the goal — exactly once per provider payment, and never counted as Billing money.
// alert and the goal — exactly once per provider payment, and never counted as Billing money.
const assert = require('assert');
const assert = require('assert');
const { ids } = require('openvibe-contracts');
const { ids } = require('openvibe-contracts');
const { boot, check, done } = require('./helpers/app');
const { boot, check, done } = require('./helpers/app');
(async () => {
(async () => {
- const t = await boot({ live: true });
- const { domain, live } = t;
- const alex = await t.creator('alex');
+ const t = await boot({ chat: true });
+ const { domain, chat } = t;
+ const linesFor = (id) => chat.messages.filter((m) => m.body.metadata.interaction_id === id);
+ const alex = await t.creator('alex', { liveId: 501 });
console.log('external receipts');
console.log('external receipts');
/** billing.receipt.external as Billing's ops/external.js writes it. */
/** billing.receipt.external as Billing's ops/external.js writes it. */
@@ -30,7 +31,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -30,7 +31,7 @@ const { boot, check, done } = require('./helpers/app');
const byRef = (ref) => t.db.maybe("SELECT * FROM tip_interactions WHERE provider = 'powerchat' AND provider_ref = $1", [ref]);
const byRef = (ref) => t.db.maybe("SELECT * FROM tip_interactions WHERE provider = 'powerchat' AND provider_ref = $1", [ref]);
let first;
let first;
- await check('an EXTERNAL receipt is recorded, settled and announced in Live chat as Live\'s webhook wrote it', async () => {
+ await check('an EXTERNAL receipt is recorded, settled and announced in the creator\'s chat as Live\'s webhook wrote it', async () => {
const g = await t.call('POST', '/api/v1/goals', { user: alex, body: { title: 'New camera', target_amount: 5000 } });
const g = await t.call('POST', '/api/v1/goals', { user: alex, body: { title: 'New camera', target_amount: 5000 } });
assert.strictEqual(g.status, 201, g.text);
assert.strictEqual(g.status, 201, g.text);
first = receipt({ provider_event_id: 'ext-1', amount_cents: 1234, value_bits: 1234, donor_name: 'Generous Fan', message: 'love the stream' });
first = receipt({ provider_event_id: 'ext-1', amount_cents: 1234, value_bits: 1234, donor_name: 'Generous Fan', message: 'love the stream' });
@@ -41,11 +42,10 @@ const { boot, check, done } = require('./helpers/app');
@@ -41,11 +42,10 @@ const { boot, check, done } = require('./helpers/app');
assert.deepStrictEqual([i.creator_subject, i.kind, i.amount, i.amount_cents, i.supporter_name, i.message], [alex.subject, 'tip', 1234, 1234, 'Generous Fan', 'love the stream']);
assert.deepStrictEqual([i.creator_subject, i.kind, i.amount, i.amount_cents, i.supporter_name, i.message], [alex.subject, 'tip', 1234, 1234, 'Generous Fan', 'love the stream']);
assert.deepStrictEqual([i.funding, i.settlement, i.origin, i.payment_state, i.billing_txn_id], ['external', 'external', 'billing-external', 'settled', null]);
assert.deepStrictEqual([i.funding, i.settlement, i.origin, i.payment_state, i.billing_txn_id], ['external', 'external', 'billing-external', 'settled', null]);
await domain.effects.drain();
await domain.effects.drain();
- const d = live.deliveries.filter((x) => x.job.interaction.id === i.id);
+ const d = linesFor(i.id);
assert.strictEqual(d.length, 1);
assert.strictEqual(d.length, 1);
- assert.deepStrictEqual([d[0].key, d[0].job.effect, d[0].job.test], [`${i.id}:chat_line`, 'chat_line', false]);
- assert.strictEqual(d[0].job.text, 'Generous Fan tipped 1,234 Vibes: love the stream (PowerChat)');
- assert.deepStrictEqual(d[0].job.creator, { type: 'user', id: alex.subject, handle: 'alex' });
+ assert.deepStrictEqual([d[0].key, d[0].body.channel_user_id, d[0].body.message_type, d[0].body.metadata.test], [`${i.id}:chat_line`, 501, 'donation', false]);
+ assert.strictEqual(d[0].body.message, 'Generous Fan tipped 1,234 Vibes: love the stream (PowerChat)');
const effects = await t.db.many('SELECT effect, state FROM interaction_effects WHERE interaction_id = $1 ORDER BY id', [i.id]);
const effects = await t.db.many('SELECT effect, state FROM interaction_effects WHERE interaction_id = $1 ORDER BY id', [i.id]);
assert.deepStrictEqual(effects, [{ effect: 'overlay_alert', state: 'delivered' }, { effect: 'chat_line', state: 'delivered' }]);
assert.deepStrictEqual(effects, [{ effect: 'overlay_alert', state: 'delivered' }, { effect: 'chat_line', state: 'delivered' }]);
const goal = await domain.goals.present(t.db, await domain.goals.get(t.db, g.json.goal.id));
const goal = await domain.goals.present(t.db, await domain.goals.get(t.db, g.json.goal.id));
@@ -63,7 +63,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -63,7 +63,7 @@ const { boot, check, done } = require('./helpers/app');
await domain.effects.drain();
await domain.effects.drain();
assert.strictEqual(await t.db.value("SELECT count(*) FROM tip_interactions WHERE provider_ref = 'ext-1'"), 1);
assert.strictEqual(await t.db.value("SELECT count(*) FROM tip_interactions WHERE provider_ref = 'ext-1'"), 1);
const one = await byRef('ext-1');
const one = await byRef('ext-1');
- assert.strictEqual(live.deliveries.filter((x) => x.job.interaction.id === one.id).length, 1);
+ assert.strictEqual(linesFor(one.id).length, 1);
});
});
await check('EXTERNAL money stays out of the Billing total', async () => {
await check('EXTERNAL money stays out of the Billing total', async () => {
@@ -84,23 +84,22 @@ const { boot, check, done } = require('./helpers/app');
@@ -84,23 +84,22 @@ const { boot, check, done } = require('./helpers/app');
});
});
await check('anonymous: no donor name; a creator with no tip page is still announced', async () => {
await check('anonymous: no donor name; a creator with no tip page is still announced', async () => {
- const bob = t.network.newUser('bob');
+ const bob = t.network.newUser('bob', 502);
await t.deliver(receipt({ provider_event_id: 'ext-anon', donor_name: null, anonymous: true, message: 'hi' }, { streamer: bob.subject }));
await t.deliver(receipt({ provider_event_id: 'ext-anon', donor_name: null, anonymous: true, message: 'hi' }, { streamer: bob.subject }));
const i = await byRef('ext-anon');
const i = await byRef('ext-anon');
assert.deepStrictEqual([i.creator_subject, i.supporter_name], [bob.subject, 'Anonymous']);
assert.deepStrictEqual([i.creator_subject, i.supporter_name], [bob.subject, 'Anonymous']);
await domain.effects.drain();
await domain.effects.drain();
- const d = live.deliveries.find((x) => x.job.interaction.id === i.id);
- assert.strictEqual(d.job.text, 'Anonymous tipped 500 Vibes: hi (PowerChat)');
- assert.strictEqual(d.job.creator.handle, null);
+ const [d] = linesFor(i.id);
+ assert.deepStrictEqual([d.body.message, d.body.username, d.body.channel_user_id], ['Anonymous tipped 500 Vibes: hi (PowerChat)', 'Anonymous', 502]);
});
});
- await check('a test receipt never reaches Live chat or a goal', async () => {
- const before = live.deliveries.length;
+ await check('a test receipt never reaches Chat or a goal', async () => {
+ const before = chat.calls.length;
await t.deliver(receipt({ provider_event_id: 'ext-test', test: true }));
await t.deliver(receipt({ provider_event_id: 'ext-test', test: true }));
await domain.effects.drain();
await domain.effects.drain();
const i = await byRef('ext-test');
const i = await byRef('ext-test');
assert.strictEqual(i.test, true);
assert.strictEqual(i.test, true);
- assert.strictEqual(live.deliveries.length, before);
+ assert.strictEqual(chat.calls.length, before);
assert.strictEqual(t.adapters.test.jobs.filter((j) => j.interaction.id === i.id).length, 1);
assert.strictEqual(t.adapters.test.jobs.filter((j) => j.interaction.id === i.id).length, 1);
assert.strictEqual(await t.db.value('SELECT count(*) FROM tip_goal_contributions WHERE interaction_id = $1', [i.id]), 0);
assert.strictEqual(await t.db.value('SELECT count(*) FROM tip_goal_contributions WHERE interaction_id = $1', [i.id]), 0);
});
});
@@ -7,7 +7,8 @@
@@ -7,7 +7,8 @@
* opts.valkey an openvibe-sdk/valkey handle (default: the containers' Valkey with TIPS_TEST_STORE=pg,
* opts.valkey an openvibe-sdk/valkey handle (default: the containers' Valkey with TIPS_TEST_STORE=pg,
* else none: everything shared stays in the process);
* else none: everything shared stays in the process);
* opts.db an existing handle (a second process on the same database); opts.share another instance
* opts.db an existing handle (a second process on the same database); opts.share another instance
- * whose stubs (Network, Billing, Events, Live) this one uses.
+ * whose stubs (Network, Billing, Events, Chat) this one uses; opts.chat starts the Chat stub and the
+ * chat adapter (else the test adapter).
*
*
* t.call(method, path, { body, user, cap, sub, key }) user: a stub user → Bearer user JWT;
* t.call(method, path, { body, user, cap, sub, key }) user: a stub user → Bearer user JWT;
* otherwise a service token with `cap`
* otherwise a service token with `cap`
@@ -20,7 +21,7 @@ const path = require('path');
@@ -20,7 +21,7 @@ const path = require('path');
const http = require('http');
const http = require('http');
const crypto = require('crypto');
const crypto = require('crypto');
const { signDelivery, signDeliveryHeaders } = require('openvibe-sdk/events');
const { signDelivery, signDeliveryHeaders } = require('openvibe-sdk/events');
-const { startNetwork, startBilling, startEvents, startLive } = require('./stubs');
+const { startNetwork, startBilling, startEvents, startChat } = require('./stubs');
const { testDb, testValkey } = require('./db');
const { testDb, testValkey } = require('./db');
const EVENTS_SECRET = 'e'.repeat(48);
const EVENTS_SECRET = 'e'.repeat(48);
@@ -31,7 +32,7 @@ async function boot(opts = {}) {
@@ -31,7 +32,7 @@ async function boot(opts = {}) {
const network = shared ? shared.network : await startNetwork();
const network = shared ? shared.network : await startNetwork();
const billing = shared ? shared.billing : await startBilling(network);
const billing = shared ? shared.billing : await startBilling(network);
const events = shared ? shared.events : await startEvents();
const events = shared ? shared.events : await startEvents();
- const live = shared ? shared.live : (opts.live ? await startLive(network) : null);
+ const chat = shared ? shared.chat : (opts.chat ? await startChat(network) : null);
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tips-test-'));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tips-test-'));
const env = {
const env = {
NODE_ENV: 'test',
NODE_ENV: 'test',
@@ -48,8 +49,8 @@ async function boot(opts = {}) {
@@ -48,8 +49,8 @@ async function boot(opts = {}) {
EVENTS_URL: events.url,
EVENTS_URL: events.url,
EVENTS_RELAY_INTERVAL_MS: '50',
EVENTS_RELAY_INTERVAL_MS: '50',
TIPS_JOBS: 'off',
TIPS_JOBS: 'off',
- TIPS_CHAT_ADAPTER: live ? 'live-chat' : 'test',
- LIVE_INTERNAL_URL: live ? live.url : 'http://127.0.0.1:9',
+ TIPS_CHAT_ADAPTER: chat ? 'chat' : 'test',
+ TIPS_CHAT_URL: chat ? chat.url : 'http://127.0.0.1:9',
TIPS_OVERLAY_HEARTBEAT_MS: '60000',
TIPS_OVERLAY_HEARTBEAT_MS: '60000',
...(opts.env || {}),
...(opts.env || {}),
};
};
@@ -149,14 +150,14 @@ async function boot(opts = {}) {
@@ -149,14 +150,14 @@ async function boot(opts = {}) {
const outboxRows = async (type) => (await domain.db.many('SELECT envelope FROM tips_event_outbox ORDER BY id')).map((r) => r.envelope).filter((e) => !type || e.event_type === type);
const outboxRows = async (type) => (await domain.db.many('SELECT envelope FROM tips_event_outbox ORDER BY id')).map((r) => r.envelope).filter((e) => !type || e.event_type === type);
return {
return {
- app, base, call, deliver, sse, creator, domain, db: domain.db, store, valkey, config, clock, network, billing, events, live, logs, dir, outboxRows,
+ app, base, call, deliver, sse, creator, domain, db: domain.db, store, valkey, config, clock, network, billing, events, chat, logs, dir, outboxRows,
adapters: app.locals.adapters,
adapters: app.locals.adapters,
close: async () => {
close: async () => {
await domain.overlays.close();
await domain.overlays.close();
server.closeAllConnections();
server.closeAllConnections();
await new Promise((r) => server.close(r));
await new Promise((r) => server.close(r));
await app.locals.outbox.stop();
await app.locals.outbox.stop();
- if (!shared) await Promise.all([network.close(), billing.close(), events.close(), live && live.close()]);
+ if (!shared) await Promise.all([network.close(), billing.close(), events.close(), chat && chat.close()]);
await store.close();
await store.close();
if (ownValkey) await ownValkey.close();
if (ownValkey) await ownValkey.close();
fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(dir, { recursive: true, force: true });
@@ -2,13 +2,16 @@
@@ -2,13 +2,16 @@
/**
/**
* Stand-ins for the services Tips talks to, each on a random port.
* Stand-ins for the services Tips talks to, each on a random port.
*
*
- * startNetwork() JWKS, client-credentials token endpoint (scope → cap), user JWTs (signUser)
+ * startNetwork() JWKS, client-credentials token endpoint (scope → cap), user JWTs (signUser), the
+ * identity map (newUser(name, liveId) → GET /internal/identity/resolve legacy_ids)
* startBilling() /api/v1/intents, /transfers, /transfers/:id/refund with Billing's rules that
* startBilling() /api/v1/intents, /transfers, /transfers/:id/refund with Billing's rules that
* matter here (idempotency keys, funds, self-dealing, capability + audience checks),
* matter here (idempotency keys, funds, self-dealing, capability + audience checks),
* and the billing.transaction.* envelopes it would publish (billing.events)
* and the billing.transaction.* envelopes it would publish (billing.events)
* startEvents() POST /api/v1/events recording what Tips' outbox relays
* startEvents() POST /api/v1/events recording what Tips' outbox relays
* (Billing and Events also answer GET /api/health, which /api/ready probes)
* (Billing and Events also answer GET /api/health, which /api/ready probes)
- * startLive() POST /internal/tips/deliveries recording chat deliveries (the live-chat adapter)
+ * startChat() POST /internal/chat/messages (R1) and /internal/chat/events (R2) recording what the
+ * chat adapter sends, with Chat's rules: capability per route, one result per key,
+ * 409 for a key reused with another body
*/
*/
const http = require('http');
const http = require('http');
const crypto = require('crypto');
const crypto = require('crypto');
@@ -29,6 +32,7 @@ async function startNetwork() {
@@ -29,6 +32,7 @@ async function startNetwork() {
const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });
const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });
const grants = [];
const grants = [];
const legacy = {};
const legacy = {};
+ const resolves = [];
let issuer = 'http://network.test';
let issuer = 'http://network.test';
let n = 100;
let n = 100;
@@ -57,6 +61,16 @@ async function startNetwork() {
@@ -57,6 +61,16 @@ async function startNetwork() {
const cap = String(body.scope || '').split(/\s+/).filter(Boolean);
const cap = String(body.scope || '').split(/\s+/).filter(Boolean);
return send(res, 200, { access_token: signService({ sub: `svc:${body.client_id}`, aud: [body.audience], cap }), token_type: 'Bearer', expires_in: 300 });
return send(res, 200, { access_token: signService({ sub: `svc:${body.client_id}`, aud: [body.audience], cap }), token_type: 'Bearer', expires_in: 300 });
}
}
+ if (req.url.startsWith('/internal/identity/resolve?') && req.method === 'GET') {
+ const v = serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: publicPem, issuer, audience: 'openvibe.network' });
+ if (!v.ok) return send(res, 401, { code: v.code });
+ if (!(v.claims.cap || []).includes('identity.subject.resolve')) return send(res, 403, { code: 'capability.denied' });
+ resolves.push(req.url);
+ const subject = new URL(req.url, 'http://x').searchParams.get('subject_id');
+ const liveIds = Object.keys(legacy).filter((id) => legacy[id].subject === subject);
+ if (!liveIds.length) return send(res, 404, { code: 'identity.subject_not_found' });
+ return send(res, 200, { subject: { type: 'user', id: subject }, legacy_ids: liveIds.map((id) => ({ source_system: 'live', source_type: 'user', source_id: id })) });
+ }
if (req.url === '/internal/identity/resolve-batch' && req.method === 'POST') {
if (req.url === '/internal/identity/resolve-batch' && req.method === 'POST') {
const v = serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: publicPem, issuer, audience: 'openvibe.network' });
const v = serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: publicPem, issuer, audience: 'openvibe.network' });
if (!v.ok) return send(res, 401, { code: v.code });
if (!v.ok) return send(res, 401, { code: v.code });
@@ -73,7 +87,7 @@ async function startNetwork() {
@@ -73,7 +87,7 @@ async function startNetwork() {
});
});
const url = await listen(server);
const url = await listen(server);
issuer = url;
issuer = url;
- return { url, publicPem, signService, signUser, newUser, legacy, grants, close: () => new Promise((r) => server.close(r)) };
+ return { url, publicPem, signService, signUser, newUser, legacy, grants, resolves, close: () => new Promise((r) => server.close(r)) };
}
}
/** Billing as Tips sees it. */
/** Billing as Tips sees it. */
@@ -194,27 +208,39 @@ async function startEvents() {
@@ -194,27 +208,39 @@ async function startEvents() {
return { url, published, tokens, close: () => new Promise((r) => server.close(r)) };
return { url, published, tokens, close: () => new Promise((r) => server.close(r)) };
}
}
-async function startLive(network) {
- const deliveries = [];
- const state = { fail: null };
+/** OpenVibe.Chat's typed ingress as Tips' chat adapter uses it (OpenVibe.Chat docs/chat-ingress.md). */
+async function startChat(network) {
+ const messages = []; // { key, body } per R1 call Chat applied
+ const events = []; // { key, body } per R2 call Chat applied
+ const calls = []; // every authorized POST: { route, key }
+ const state = { fail: null, failEvents: null };
const seen = new Map();
const seen = new Map();
+ const CAP = { '/internal/chat/messages': 'chat.message.send', '/internal/chat/events': 'chat.event.publish' };
const server = http.createServer(async (req, res) => {
const server = http.createServer(async (req, res) => {
const raw = await readBody(req);
const raw = await readBody(req);
- if (req.url !== '/internal/tips/deliveries' || req.method !== 'POST') return send(res, 404, {});
- const v = serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: network.publicPem, issuer: network.url, audience: 'openvibe.live' });
- if (!v.ok) return send(res, 401, { code: v.code });
- if (!(v.claims.cap || []).includes('live.tips_delivery.write')) return send(res, 403, { code: 'capability.denied' });
- if (state.fail) return send(res, state.fail, { code: 'live.refused', detail: 'stub refusal' });
- const key = req.headers['idempotency-key'];
- if (seen.has(key)) return send(res, 200, seen.get(key));
- const job = JSON.parse(raw);
- deliveries.push({ key, job });
- const out = { ok: true, ref: { chat_message_id: deliveries.length } };
- seen.set(key, out);
+ const cap = CAP[req.url];
+ if (!cap || req.method !== 'POST') return send(res, 404, {});
+ const v = serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: network.publicPem, issuer: network.url, audience: 'openvibe.chat' });
+ if (!v.ok) return send(res, 401, { ok: false, error: v.code });
+ if (!(v.claims.cap || []).includes(cap)) return send(res, 403, { ok: false, error: 'capability.denied' });
+ const body = JSON.parse(raw || '{}');
+ calls.push({ route: req.url, key: body.key });
+ const fail = req.url === '/internal/chat/events' ? state.failEvents || state.fail : state.fail;
+ if (fail) return send(res, fail, { ok: false, error: 'stub refusal' });
+ if (!body.key || !/^[A-Za-z0-9:._-]{1,160}$/.test(body.key)) return send(res, 400, { ok: false, error: 'A valid idempotency key is required' });
+ const id = `${v.claims.sub}|${req.url}|${body.key}`;
+ if (seen.has(id)) {
+ const old = seen.get(id);
+ if (old.raw !== JSON.stringify(body)) return send(res, 409, { ok: false, error: 'Idempotency key reused with a different body' });
+ return send(res, 200, old.out);
+ }
+ let out;
+ if (req.url === '/internal/chat/messages') { messages.push({ key: body.key, body }); out = { ok: true, id: messages.length }; } else { events.push({ key: body.key, body }); out = { ok: true }; }
+ seen.set(id, { raw: JSON.stringify(body), out });
return send(res, 200, out);
return send(res, 200, out);
});
});
const url = await listen(server);
const url = await listen(server);
- return { url, deliveries, state, close: () => new Promise((r) => server.close(r)) };
+ return { url, messages, events, calls, state, close: () => new Promise((r) => server.close(r)) };
}
}
-module.exports = { startNetwork, startBilling, startEvents, startLive };
+module.exports = { startNetwork, startBilling, startEvents, startChat };