openvibers/openvibe.tips README.md

README.md

main 40.7 KB · 515 lines · markdown

1# OpenVibe.Tips
2
3> Creator support: tips, goals, paid messages, TTS, media requests and paid sounds, overlays.
4
5**Live site:** https://openvibe.tips · **API docs:** https://openvibe.services · **For AI:** https://openvibe.tips/llms.txt
6**Run it:** `npm ci && npm run dev` (Node 22 or newer) · **Tests:** `npm test`
7
8**Status:** alpha — **launched 2026-10-17** at [openvibe.tips](https://openvibe.tips) (openvibe-contracts 0.200.0), right
9after the Billing cutover made OpenVibe.Billing the money ledger. Tips posts its chat lines and alerts through
10OpenVibe.Chat (`TIPS_CHAT_ADAPTER=chat`), records a streamer's own PowerChat tips from Billing, and OpenVibe.Live
11moves its channel goal and tip history from `tips.interaction.ready`. Paid sounds since the same day. On launch day
12production held the 139 interactions imported from Live and no creator profile yet.
13Data: PostgreSQL through PgBouncer, and Valkey for shared state ([ADR-035](https://github.com/OpenVibers/OpenVibe.Contracts/blob/main/docs/adr/ADR-035-postgresql-and-valkey.md));
14**Domain:** `openvibe.tips`
15**Port / service id:** 4610 / `tips`
16**Decision:** [ADR-012](https://github.com/OpenVibers/OpenVibe.Contracts/blob/main/docs/adr/ADR-012-economic-classification.md) — Tips never moves money; Billing does.
17**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §11.2; roadmap Wave 9, §15.11, §29.
18**License:** AGPL-3.0.
19
20## Purpose
21
22A creator product that orchestrates Billing, Chat, Live and Media. Settlement is Billing's; Tips owns
23the interaction, goals, overlays and creator configuration, and always distinguishes *payment settled*
24from *interaction delivered*.
25
26## Owns
27
28- `tip_interactions`, `creator_tip_profiles`, `tip_goals`, `tip_goal_contributions`, `paid_messages`,
29 `paid_media_requests`, `creator_sounds`, `overlay_configs`, `overlay_deliveries`, `interaction_effects`
30 (its own PostgreSQL database `ov_tips`, schema in [migrations/](migrations/), access in `server/db.js`) —
31 interaction state and **references** to Billing transaction ids, never a mutable cash balance
32- revocable scoped overlay tokens (`overlay_tokens`, hashed at rest; never creator cookies)
33
34## Does not own
35
36- monetary accounting, prices, balances, refunds, payouts (OpenVibe.Billing)
37- chat rooms, TTS synthesis and queues (OpenVibe.Chat; Live's chat server until Chat's cutover)
38- media bytes and the media queue (OpenVibe.Media / Live)
39- identity (OpenVibe.Network)
40
41## Depends on
42
43- **PostgreSQL 18 through PgBouncer (transaction mode) and Valkey 9** — OpenVibe.Host's data role
44 (`roles/data/add-service.sh tips`: database `ov_tips`, owner and runtime roles, a Valkey user on
45 `ov:tips:*`). PostgreSQL is required (the system of record); Valkey holds only what the processes share
46 (per-actor limit counters, overlay fan-out, overlay stream slots) and its loss changes no answer about money
47- **OpenVibe.Billing** — `POST /api/v1/intents` (checkout), `POST /api/v1/transfers` (a tip from credit),
48 `billing.transaction.settled|reversed` events, and `billing.receipt.external` (a tip on a creator's own
49 PowerChat, once Billing receives the PowerChat webhook)
50- **OpenVibe.Events** — Billing's events in (signed webhook + openvibe-sdk inbox), Tips' events out
51 (openvibe-sdk transactional outbox)
52- **OpenVibe.Network** — SSO for people, service tokens, JWKS, identity resolve (a creator's Chat room)
53- **OpenVibe.Chat** — chat delivery through its typed ingress (`/internal/chat/messages`, `/internal/chat/events`)
54- **OpenVibe.VIP** — the gate of the members-only posts: `POST /api/v1/entitlements/check`
55 (`vip.entitlement.check`, the entitlement answer for member → creator; Billing holds the truth) and the creator's
56 public VIP page (`GET /api/v1/creators/<subject>`: their tiers and the plan a join button points at). Without the
57 Network grant `tips vip.entitlement.check openvibe.vip` every answer is a denial: no post text, no tiers, no join link
58- **OpenVibe.Live** — overlay consumer; its public stream list (`GET <LIVE_URL>/api/streams`) feeds the home's "Live on
59 OpenVibe now" ([server/clients/live-now.js](server/clients/live-now.js): once a minute, 2 s timeout, never Live's AI
60 text, nothing when Live does not answer; `TIPS_LIVE_NOW=on|off`, off in tests by default)
61- **OpenVibe.Shared** v2.2.0 (app icon, SSR footer, noscript nav, release manifest, legal pages, boost page moves) and
62 the Network's `navbar.js`; **openvibe-contracts** v0.79.0 and **openvibe-sdk** v0.28.0 (service tokens,
63 the async data layer `openvibe-sdk/db`, the PostgreSQL outbox and inbox — the events outbox is the SDK's
64 `createServiceOutbox`, plan T1 — Valkey, pub/sub and the shared limit store), pinned by release tarball;
65 `pg` and `iovalkey` (drivers), PGlite for tests
66
67## Capabilities
68
69Implemented here (the service manifest's `capabilities`, 19 `tips.*` ids, audience `openvibe.tips`):
70`tips.profile.get|update`, `tips.checkout.create`, `tips.superchat.create`, `tips.tts.request`,
71`tips.media_request.create`, `tips.sound.request`, `tips.interaction.get|list|record|moderate`, `tips.goal.create|update|close`,
72`tips.overlay.token.create|revoke`, `tips.overlay.config.get|update`, `tips.post.list|create|moderate` and
73`tips.simulation.run`.
74
75Called elsewhere, as the service principal `tips`:
76
77| Service | Grant | Why |
78|---|---|---|
79| OpenVibe.Billing | `billing.intent.create`, `billing.transfer.create` | checkout, and a tip from credit |
80| OpenVibe.Events | `events.event.publish`; `events.subscription.manage` (`npm run subscribe`, and at boot) | the outbox relay; the `billing.transaction.*` and `billing.receipt.*` subscriptions; the two account subscriptions (created at boot when missing) |
81| OpenVibe.Network | `network.account.export.contribute`, `network.account.deletion.confirm` (granted last, once this release is live) | account export and deletion (ADR-033) |
82| 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` |
83| OpenVibe.Network | `identity.subject.resolve` (only with `TIPS_CHAT_ADAPTER=chat`) | the creator's Live user id (Chat's room) from `/internal/identity/resolve` |
84| OpenVibe.VIP | `vip.entitlement.check` | the entitlement answer that opens a members-only post |
85
86## Run it
87
88```bash
89fnm exec --using=22.22.1 npm install
90cp .env.example .env # OV_OAUTH_CLIENT_SECRET, TIPS_FORM_SECRET, TIPS_EVENTS_SECRET, …
91npm run dev # http://localhost:4610 (without DATABASE_URL: an embedded PGlite database in data/pglite)
92npm test # every test/*.test.js: stub Network/Billing/Events/Chat, PGlite, random ports
93eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)" # PostgreSQL 18 + PgBouncer + Valkey 9 containers
94npm test # … now also test/integration.test.js on the containers
95npm run test:pg # every test file through PgBouncer and Valkey
96npm run subscribe # create the billing.transaction.* and billing.receipt.* subscriptions in OpenVibe.Events
97```
98
99Production: `/opt/openvibe.tips`, env `/etc/openvibe/tips.env`, unit
100[deploy/systemd/openvibe-tips.service](deploy/systemd/openvibe-tips.service) (state in `/var/lib/openvibe-tips`),
101vhost [deploy/nginx/openvibe.tips.conf](deploy/nginx/openvibe.tips.conf) (installed 2026-10-17: `openvibe.tips` serves Tips).
102
103## Design
104
105**Two state machines per interaction** (`server/domain/interactions.js`):
106
107```
108payment_state pending ──► settled ──► reversed mirrored from Billing, keyed by billing_txn_id (UNIQUE)
109 └──► failed
110delivery_state awaiting_payment ──► queued ──► delivered | failed | cancelled
111```
112
113A delivery failure (Chat down, chat refused) is retried with backoff and finally recorded on the
114effect; it never touches `payment_state`. A reversal after delivery flips `payment_state` and keeps the
115delivery record; a reversal before delivery cancels the queued effects (`tips.interaction.cancelled`).
116
117**How money moves** (it never moves in Tips):
118
119| Funding | What Tips asks Billing | Settles when |
120|---|---|---|
121| `credit` | `POST /transfers {from: supporter, to: creator, kind: tip\|paid_interaction, target: {service: tips, type: interaction, id}}`, `Idempotency-Key: tips:transfer:<id>` | the response (the settled event for that transaction is then a no-op) |
122| `checkout` | `POST /intents {kind: purchase, subject: supporter, bits: amount}`, key `tips:intent:<id>` → checkout URL | the purchase's `billing.transaction.settled` (metadata.intent_id) triggers the transfer above — CREDIT becomes MONEY only by giving it (ADR-012 rule 3) |
123| `provider` / origin `billing` | nothing — a donation settled in Billing that Tips did not start (Live's donate flow, a site-routed PowerChat tip) | recorded once by its Billing transaction id; overlay + goals, no second chat line |
124| `external` | nothing — a tip on the creator's own PowerChat (ADR-012 EXTERNAL) | `POST /api/v1/interactions/external`, or Billing's `billing.receipt.external` (origin `billing-external`: Tips posts the chat line — `… tipped N Vibes: msg (PowerChat)` — plus overlay alert and goal, as Live's webhook did); once per (provider, provider_ref); never in Billing totals |
125| `none` (simulation) | nothing | at once, `test = 1`: full effect path, no goal contribution, no durable event, excluded from totals |
126
127PowerChat: Billing answers a PowerChat intent with a `checkout_ref` (`pcorder:<intent>`) and no URL, and Tips builds
128the link from `TIPS_POWERCHAT_LINK_TEMPLATE` (`{ref}`, `{cents}`, `{bits}`). Production sets it since 2026-10-17 to
129PowerChat's canonical tip link on the site's PowerChat account, the same link OpenVibe.Live builds for Vibes purchases
130(`https://<powerchat>/<site account>/tip?app_client_id=…&app_ref={ref}&app_purpose=vibes&app_amount_cents={cents}`):
131the payment's webhook reaches Billing through openvibe.live, Billing credits the supporter, and Tips gives the tip.
132
133**Exactly one logical interaction:** the inbox claims `(consumer, event_id)` in the same serializable
134transaction as the change, so a redelivered event does nothing; `billing_txn_id` is UNIQUE, so the same
135Billing transaction under another event id (republish, replay) is still one interaction; API calls carry
136`Idempotency-Key` (stored responses + the interaction's own unique key); the no-JS form carries a nonce.
137
138**Settlement** writes, in one transaction: the `paid_messages` / `paid_media_requests` row (these never
139exist before settlement), the goal contribution (goal the supporter picked, else the creator's only active
140goal — Live's rule), the overlay alert, the `interaction_effects`, and `tips.interaction.ready`.
141
142**Goals** are derived: `current = Σ(contribution − reversed)`, plus `opening_amount` for goals imported
143from Live (whose `current_amount` cannot be traced to individual donations).
144
145**Totals** (`GET /api/v1/profiles/:creator/totals`): `settled_via_billing` (settled or imported, non-test,
146minus what Billing took back) — the number that must equal Billing's books — with `external` and
147`simulations` reported apart.
148
149**Data** (ADR-035; `server/db.js`, [migrations/](migrations/)):
150- Everything is async on `openvibe-sdk/db`: a function that touches the database takes the query handle
151 first (`db`, or the transaction's `t`), and after-commit work (overlay pushes, worker kicks) hangs on
152 the transaction (`t.after`). The schema is `migrations/NNNN_*.sql` (expand/migrate/contract), applied
153 at boot with the owner role; the service then serves through PgBouncer with no session state.
154- **Money paths are SERIALIZABLE with row locks** (ADR-007 amendment 2026-09-24, rule 3): a request and
155 its unpaid-checkout limit, settlement (paid message, media request, goal contribution), payment failure,
156 funding, reversal, the Billing inbox, and what cancels or releases what a payment bought (moderation's
157 hide and restore, the effects worker's writes). Locks are taken in one order: a creator's profile row
158 (settings changes and per-creator limits), the interaction row, its goals by id, the creator's overlay
159 stream (an advisory lock, so a creator's deliveries commit in seq order), then the rest. Serialization
160 failures and deadlocks are retried by the data layer.
161- Text is made storable on the way in: PostgreSQL keeps no NUL character, and jsonb no unpaired surrogate,
162 so they are dropped or replaced (U+FFFD) in what people and Billing's events send.
163- **Background work runs in every process** and claims its rows: due effects and due transfers with a
164 lease (`FOR UPDATE SKIP LOCKED`, the lease in `next_attempt_at` / `next_transfer_at`), the overlay
165 window sweep with `SKIP LOCKED`, the outbox relay by the SDK's lease; prunes are idempotent deletes.
166- **Shared state in Valkey** (none of it authoritative): per-actor limit counters (the SDK's shared limit
167 store), overlay fan-out (`overlay:<creator>` on pub/sub: a stream in one process hears a tip, a config
168 change, a retraction or a revocation made in another), overlay stream slots (a sorted set per creator,
169 refreshed by heartbeats). Without `VALKEY_URL` each process keeps its own, which is right for one process.
170- Every query shape has an index (`test/migrations.test.js` checks the plans), lists page by keyset, and a
171 list costs a fixed number of queries whatever its size (`test/query-budget.test.js`).
172
173## API
174
175`/api/v1`, problem+json errors, `Idempotency-Key` on every POST/PATCH (claimed before the handler runs:
176the same key twice at once runs once, the other answered `409 idempotency.in_progress` with `Retry-After`
177until the first answer is stored and replayed). Services present a Network
178service token (audience `openvibe.tips`, one capability per route); people present their Network user
179token as a Bearer and act on their own things only. The API never reads cookies.
180
181| Method & path | Capability (services) | People |
182|---|---|---|
183| `GET /profiles/:creator` | `tips.profile.get` (switched-off pages) | anyone when the page is on; the owner |
184| `PATCH /profiles/:creator` (`me` creates it) | `tips.profile.update` | owner |
185| `GET /profiles/:creator/totals` | `tips.interaction.list` | owner |
186| `POST /checkout` `{creator, amount, message?, goal_id?, pay_with: credit\|checkout, provider?, supporter (services), target?, privacy?}` | `tips.checkout.create` | the supporter |
187| `POST /paid-messages` | `tips.superchat.create` | the supporter |
188| `POST /tts-requests` `{…, tts: {text, voice}}` | `tips.tts.request` | the supporter |
189| `POST /media-requests` `{…, media: {url}}` | `tips.media_request.create` | the supporter |
190| `POST /sound-requests` `{…, sound: {id}}` | `tips.sound.request` | the supporter |
191| `GET /profiles/:creator/sounds` | `tips.profile.get` (switched-off pages) | anyone when the page is on; the owner |
192| `GET /interactions/:id` | `tips.interaction.get` | its creator or supporter |
193| `GET /interactions?creator=\|supporter=&cursor=` | `tips.interaction.list` | own receipts; `?as=creator` own tips |
194| `POST /interactions/external` `{creator, provider, provider_ref, amount_cents, supporter_name?, message?, announce?}` | `tips.interaction.record` | — |
195| `GET /goals?creator=`, `GET /goals/:id` | `tips.goal.update` (private; full view) | public shape when the page is on (the creator's page settings); owner sees contributions |
196| `GET /profiles/:creator/supporters` | `tips.profile.get` (while not public) | public when the creator shows the supporters page; the owner |
197| `GET /me/export`, `POST /me/erase` | — (people only) | the supporter's own tips: download; erase their data from them |
198| `GET /moderation?creator=&state=held\|hidden\|visible\|all` | `tips.interaction.moderate` | the creator and their moderators |
199| `POST /interactions/:id/hide`, `POST /interactions/:id/restore` `{reason?}` | `tips.interaction.moderate` | the creator and their moderators |
200| `GET /moderation/log?creator=` | `tips.interaction.moderate` | owner |
201| `GET\|POST /moderators`, `POST /moderators/:subject/remove` | `tips.profile.update` | owner |
202| `POST /goals`, `PATCH /goals/:id`, `POST /goals/:id/close` | `tips.goal.create` / `.update` / `.close` | owner |
203| `GET\|POST /overlay-tokens`, `POST /overlay-tokens/:id/revoke` | `tips.overlay.token.create` / `.revoke` | owner |
204| `GET /overlay-configs[/:id]`, `POST /overlay-configs`, `PATCH /overlay-configs/:id` | `tips.overlay.config.get` / `.update` | owner |
205| `POST /simulate` | `tips.simulation.run` | owner |
206| `POST /internal/events` | Events webhook signature (`TIPS_EVENTS_SECRET`), loopback only | — |
207
208`tips.interaction.moderate` was proposed in [docs/capabilities-proposal/](docs/capabilities-proposal/)
209and is registered in openvibe-contracts v0.32.0.
210The capabilities and the service manifest were released in openvibe-contracts v0.15.0 (3-segment ids:
211the charter's `tips.simulate` is `tips.simulation.run`; `tips.interaction.record` is new, for EXTERNAL
212tips); the drafts stay in [docs/capabilities-proposal/](docs/capabilities-proposal/) and
213[docs/service-manifest-proposal.json](docs/service-manifest-proposal.json). Grants are matched with
214contracts' `capabilities.grants()` (exact id or a `.*` family). Tips pins openvibe-contracts v0.33.0,
215which also carries the payload schemas of the six `tips.*` events below (v0.30.2) and of
216`tips.interaction.moderated|erased` (v0.32.0); `test/contracts.test.js` validates every envelope and
217payload Tips produces against them.
218
219## Privacy
220
221What a supporter lets the public see is their choice, per tip (`privacy: { anonymous, hide_amount,
222private_message }` on the API, three boxes on the tip form), stored on the interaction
223([server/domain/privacy.js](server/domain/privacy.js)):
224
225| Choice | Overlays, chat lines, public pages, other products | The creator | Events (internal) |
226|---|---|---|---|
227| `anonymous` | "Anonymous", no subject | "Anonymous", no subject | `supporter: null`, `supporter_name: "Anonymous"` |
228| `hide_amount` | no amount (`amount: null`, the line reads "sent a tip"); off the leaderboard | the amount (their money) | the amount (goals and reconciliation need it) |
229| `private_message` (tips only) | no message | the message | no message is ever in an event |
230
231Only the supporter (their receipts, `GET /me/export`) and Billing (which moved the money) can link an
232anonymous tip to them. `publicView()` is the one shape that leaves Tips for the public: overlay alert
233payloads, chat jobs (which also carry `privacy`), the supporters and goals pages, and the `public` block
234of every API answer. A goal bar still moves by a hidden amount, so the goal update of such a tip names
235nobody.
236
237**What the creator's public pages show** is theirs to choose (`page` on the profile, the dashboard's
238*Public pages*): goal amounts or the percentage only, each goal's latest supporters, and a supporters
239page (`/<handle>/supporters`, off by default) with the top supporters, their totals and recent public
240messages. The leaderboard counts only tips whose supporter kept both name and amount public.
241
242**Export and erasure.** `GET /me/export` (and `/receipts/export`) downloads everything Tips holds about a
243supporter's tips. `POST /me/erase` (and `/receipts/erase`, with a confirmation) removes the person from
244every settled, failed or reversed tip — subject, name, message, TTS text, media link, checkout reference,
245stored API answers, overlay payloads, unsent events — and keeps the money record (amount, creator, date,
246Billing transaction, goal contribution), so Billing's books and the creators' totals still reconcile.
247Tips still waiting for their payment are kept until it settles or fails. Each erased interaction emits
248`tips.interaction.erased`, whose `redacts` has OpenVibe.Events tombstone Tips' earlier events about it.
249Stored API answers (Idempotency-Key replays) are pruned after a week.
250
251**Account export and deletion (ADR-033).** `network.account.export_requested` and `network.account.deleted`
252arrive at `POST /internal/events` and are answered by `server/domain/account-data.js` (`openvibe-sdk/account-data`),
253outside the money inbox, with one receipt per export and deletion in `account_data_events`.
254
255- **As a supporter:** the export is `/me/export`'s, and the deletion is the erasure above.
256- **As a creator:** the export adds their settings, overlays (never a token hash), goals, the tips they received
257 (without the supporters) and their moderators. The deletion removes their settings and page, overlays, overlay
258 tokens and alert history, moderator list and invitations, and the stored answers that name them. The tips they
259 received, the paid messages and media requests on them, their goals and their moderation log are kept and counted
260 as retained.
261- **As a moderator:** their moderator rows go; the moderation log keeps naming them.
262
263## Moderation
264
265[server/domain/moderation.js](server/domain/moderation.js), [server/domain/filter.js](server/domain/filter.js).
266The money is never touched: hiding a paid message does not refund it, and a hidden tip still counts
267toward its goal and the creator's totals.
268
269- **Word filter** (`filter: { words, action, links }` on the profile, the dashboard's *Moderation* card;
270 the list is the creator's only). Blocked words or phrases match whole, in any letter case, after
271 Unicode NFKC folding and with invisible and direction-override characters removed, in the name and in
272 what is shown or read. `mask` (default) stars them out of overlays, chat lines and pages and leaves
273 them out of what text-to-speech reads; `hold` holds a paid message that matches before anything is
274 shown, posted or read, until the creator or a moderator shows it (then it is released as settlement
275 would have released it, still masked). Links in paid messages read `[link]` unless the creator turns
276 that off.
277- **Hide / show.** The creator, their moderators and services with `tips.interaction.moderate` hide a
278 paid message: its queued chat, TTS and media deliveries are cancelled (never delivered), its overlay
279 alert is retracted (connected overlays get a `retract` event and drop it, replays and `/state` skip
280 it) and public pages leave it out. Showing it again puts it back on pages and in overlay state; what
281 the hide cancelled stays cancelled. `/moderate/<handle>` is the review page (held, shown, hidden).
282- **Moderators** are added by the creator: a show-once invitation link from the dashboard (hashed at rest,
283 single use, 7 days, never in nginx's log), accepted signed in; or `POST /moderators` by the creator or
284 a service. Moderators see what was written but never private messages, hidden amounts or payments.
285- Every outcome is in `tip_moderation_log` (with the moderator and their note) and is published as
286 `tips.interaction.moderated` (below), which carries no supporter, message, note or moderator.
287
288## Events
289
290Produced through the openvibe-sdk outbox (table `tips_event_outbox`, source `tips`, same transaction as
291the change; relayed by any process, rows claimed with a lease, when `EVENTS_URL` and the client secret are set): `tips.interaction.ready`,
292`tips.interaction.failed`, `tips.interaction.cancelled`, `tips.goal.updated`, `tips.overlay.delivered`,
293`tips.overlay.failed`, and two whose schemas were proposed in [docs/events-proposal/](docs/events-proposal/)
294and are in openvibe-contracts since v0.32.0: `tips.interaction.erased` (a supporter's erasure; `{ interaction_id,
295creator, erased_at, redacts }`) and `tips.interaction.moderated` (`{ interaction_id, creator, action:
296filtered|held|hidden|restored, by: filter|creator|moderator|service, moderation_state, cancelled_effects }`).
297Simulations and Billing test money produce none. An anonymous supporter is never named in an event.
298Consumed:
299`billing.transaction.settled`, `billing.transaction.reversed`, `billing.receipt.external` (only from source
300`billing`). `billing.receipt.external` (payload: `streamer` SubjectRef, `amount_cents`, `value_bits`,
301`donor_name` — null when `anonymous` — `message`, `provider`, `provider_event_id`, `app_purpose`/`app_ref`,
302`test`) is sent by Billing only once it is the money authority (`BILLING_AUTHORITY=billing` in
303billing.env); before that Live's own PowerChat webhook announces those tips, so nothing is announced
304twice. A `goal:<id>` in `app_purpose`/`app_ref` picks the goal (a Tips goal id); otherwise the creator's
305only active goal.
306
307## Overlays
308
309`GET /overlay/<token>` is an OBS Browser Source page; `/overlay/<token>/events` is the SSE stream
310(`hello` with config and active goals, then `alert` and `goal`, `config` on changes, `retract` when a
311moderator hides an alert, `revoked`), and
312`/overlay/<token>/state` a read-only JSON for other overlay clients (e.g. Live). Tokens are scoped
313(`alerts`, `goals`), shown once, stored as SHA-256, revocable (open streams close at once) and never a
314cookie. Every alert and goal change is one `overlay_deliveries` row with a monotonic `seq` (the SSE id):
315the first write to an overlay marks it `delivered` (`tips.overlay.delivered`), a row no overlay showed
316within `TIPS_OVERLAY_TTL_MS` becomes `failed` (`tips.overlay.failed`). A reconnect with `Last-Event-ID`
317**replays** — it writes bytes to a socket and nothing else: no charge, no goal count, no event.
318
319## Chat delivery
320
321`server/delivery/` — an adapter is `{ name, deliver(job) → { ref } }` (throw to fail; `permanent` stops
322retries). `chat` (`server/delivery/chat.js`) posts to OpenVibe.Chat's typed ingress at `TIPS_CHAT_URL`
323with Tips' service token (audience `openvibe.chat`; OpenVibe.Chat `docs/chat-ingress.md`):
324
325| Effect | Chat call | Body |
326|---|---|---|
327| `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 }` |
328| | then `POST /internal/chat/events` (`chat.event.publish`) | `target { kind: channel, id }`, `frame { type: alert, streamerId, kind: donation }` (the alert sound) |
329| `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 |
330| `media_request` | none | fails at once: the media queue is not Chat's |
331
332**Paid sounds** (`kind: sound`, `server/domain/sounds.js`): a creator keeps up to 12 sounds (MP3, OGG or WAV of
333512 KB at most, recognised by their bytes), uploaded from the dashboard with a plain multipart form
334(`server/web/multipart.js`) and served from `/sounds/<id>` as their own audio type (`nosniff`, a sandboxing CSP,
335cached for good: a file never changes). With paid sounds on (`sounds_enabled`, minimum `sound_min`, 50 Vibes on a
336new page), a supporter picks one on the tip page or through `POST /api/v1/sound-requests`. After settlement the
337overlay alert carries the sound and the overlay plays it in place of the alert sound; the chat line reads
338"<name> played “<sound>” for N Vibes".
339
340Every body's `key` is the effect's delivery id (`<interaction>:<effect>`; the alert `<delivery id>:alert`):
341Chat applies a key once and answers a repeat with the first result, so a retry after a 5xx or a lost answer
342never posts twice. A 4xx (other than 401/408/425/429) fails the effect at once, everything else is retried
343with backoff; a refused alert does not undo the line (`ref.alert: false`). Chat's rooms are Live's ids: the
344adapter reads the creator's Live user id from the Network (`GET /internal/identity/resolve`, kept ten
345minutes); a creator with no Live account fails at once. The body is rebuilt on each attempt from the
346interaction's current public view, so a word-filter change between attempts makes Chat answer 409, which
347fails the effect. A job carries the interaction's public view and `privacy`: "Anonymous" and no subject
348for an anonymous supporter, `amount: null` and a line without the amount when it is hidden, no private
349message. `test` records jobs in memory (development; simulations always use it). `none` (the default) creates
350no chat effects. Production runs `chat` since the launch (2026-10-17): the `tips` principal holds the three
351capabilities above (`TIPS_CHAT_URL` only when Chat is not on `127.0.0.1:4400`).
352
353## Members-only posts
354
355A creator writes posts for the members of their OpenVibe.VIP tiers (the Billing tiers of their published VIP
356plans). The title and the tiers are public; the text is served only to a member of a tier that reads it.
357
358- **The gate is server-side.** The body never leaves the server for anyone else: not in the page, not in JSON-LD,
359 not in `/llms-full.txt`, not in the sitemap, not in a preview, and not in the API list
360 ([server/domain/posts.js](server/domain/posts.js), [server/vip/client.js](server/vip/client.js)). The check is
361 OpenVibe.VIP's entitlement answer for member → creator (Billing holds the truth, ADR-012), behind the shared
362 product cache (`openvibe-sdk/vip`): a "yes" lives at most `TIPS_VIP_CACHE_TTL_MS` — 30 s by default and never
363 more than 60 s — and a failure is a denial. A membership that ends is announced by OpenVibe.VIP
364 (`vip.membership.changed`, subscribed in `scripts/subscribe.js`); the cached grant for that pair is dropped
365 at once, so a cancelled member loses access without waiting for the TTL.
366- **Everyone else gets a card that says so**: "Members only", the tiers it is written for and a "Become a member"
367 button pointing at the creator's VIP plan. A tier nobody can join yet shows no button — a button that leads
368 nowhere is worse than none.
369- **Every HTML answer is private.** A shared cache (Cloudflare) stores by URL and ignores `Vary`, `Cookie`
370 and `Authorization` (the 10-15 Media leak), and these pages carry the viewer's own state: the footer
371 shows who is signed in, and a member's creator page carries the text of the posts they may read. So
372 every page answers `Cache-Control: private, no-cache` with `Vary: Cookie`. The machine-readable pages
373 (`robots.txt`, `sitemap.xml`, `llms.txt`, `llms-full.txt`) and the agent twins carry no viewer's state
374 and stay public; the API answers `no-store`.
375- **The creator** writes and hides their posts in the dashboard (`/dashboard`, `POST /dashboard/posts`) and always
376 reads their own text. A hidden post is not served at all (neither its title nor its text).
377- **The API**: `GET /api/v1/profiles/:creator/posts`, `GET /api/v1/profiles/:creator/posts/:id`,
378 `GET /api/v1/profiles/:creator/posts/:id/body` (403 `tips.member_only` for anyone else),
379 `POST /api/v1/profiles/:creator/posts`, `POST /api/v1/posts/:id/hide|unhide`.
380
381## Pages (server-rendered, useful without JavaScript)
382
383`/` (who is live on OpenVibe, the creators who switched their page on with their first active goal, what a supporter can
384send, what a creator gets, the services a creator can link, how it works, what it costs, six questions with their
385FAQPage JSON-LD; a section with nothing to show is left out) · `/<handle>` (goals + tip form posting to checkout; `index,follow` only when the creator switched the
386page on, otherwise 404 for everyone else and a `noindex` preview for the creator) · `/<handle>/goals` ·
387`/<handle>/supporters` (when the creator shows it) · `/receipts`, `/receipts/:id` (payment and delivery
388shown separately, with the privacy chosen), `/receipts/export`, `/receipts/erase` · `/moderate/<handle>`
389(the creator and their moderators), `/moderate/invite/<secret>` · `/dashboard` (page settings, public pages, moderation,
390goals, overlay links shown once, alert settings, simulation, totals, recent tips, overlay deliveries) ·
391`/robots.txt`, `/sitemap.xml` (switched-on pages only) · `/release.json` · `/terms`, `/privacy`, `/dmca`.
392Signed-in forms carry an HMAC anti-forgery token and a per-render nonce. OpenVibe Frame: Network
393`navbar.js`, `openvibe-shared` app icon, SSR footer and `<noscript>` navigation.
394
395**Other ways to support** (since 2026-10-17, openvibe-contracts 0.202.0 `support_links`; owner 10-08: Billing is provider
396agnostic, creators bring their own links): a creator lists where else people pay them (Ko-fi, PayPal.me, Patreon, Buy Me
397a Coffee, Cash App, Venmo, GitHub Sponsors, Liberapay, Open Collective, Streamlabs and StreamElements tip pages; at most
3988) from the dashboard or `PATCH /api/v1/profiles/:creator {support_links: [{provider, handle}]}`. The tip page shows them
399as paid outside OpenVibe (never held, never counted toward goals), `rel="nofollow noopener ugc"`. A link is a known
400service plus a handle, and Tips builds the URL ([server/domain/support-links.js](server/domain/support-links.js)): a
401pasted link counts only on that service's own host, so a profile cannot carry a look-alike payment page.
402
403## Security
404
405Reporting a vulnerability: [SECURITY.md](SECURITY.md). Tips never moves money (ADR-012): it holds
406references to Billing transaction ids, never a balance. People sign in with Network SSO; services use
407client-credentials tokens for audience `openvibe.tips`, one capability per route. Overlays use revocable
408scoped tokens, hashed at rest, never creator cookies. Billing's events arrive signed
409(`TIPS_EVENTS_SECRET`) through an inbox that dedupes them; `/internal/` and `/metrics` are never public.
410Tips calls only its configured Network, Billing, Events and Chat hosts. Secrets (`OV_OAUTH_CLIENT_SECRET`,
411`TIPS_FORM_SECRET`, `TIPS_EVENTS_SECRET`) live in `/etc/openvibe/tips.env` (0600).
412
413[docs/threat-review.md](docs/threat-review.md) is the written threat review: overlays (tokenised URLs),
414paid-message abuse, TTS, amount spoofing, replay and privacy, with each finding's status and the test
415that shows it. The fixes it made:
416- a Billing donation settles an interaction only when amount, creator and supporter match;
417- a replayed `POST /overlay-tokens` answer carries no secret, and none is stored;
418- open streams per overlay token (`TIPS_OVERLAY_MAX_STREAMS`) and unpaid checkouts per supporter
419 (`TIPS_MAX_PENDING_CHECKOUTS`) are bounded;
420- the creator's own name is refused as a supporter name;
421- invisible and direction-override characters are dropped, and TTS text carries no markup.
422
423The regressions are in `test/security.test.js`. It is an internal review; an independent one is still due
424before launch.
425
426### Per-actor limits
427
428`/api/v1` also limits who calls it, once the credential is checked and before any work (the idempotency
429store, Billing, chat delivery): `server/api/actor-limits.js`, openvibe-sdk/limits, roadmap WS-R task 4. A
430service counts as its principal (`svc:live`), a person as `user:usr_…`, anyone else by address. Past a limit:
431`429` problem+json `rate_limited` with `Retry-After`, one log line and `tips_rate_limited_total{limit,window}`.
432
433| Routes | Per caller |
434|---|---|
435| Every read | `TIPS_LIMITS_MINUTE` / `TIPS_LIMITS_HOUR` (120 a minute, 3000 an hour) |
436| `POST /checkout`, `/paid-messages`, `/tts-requests`, `/media-requests` (one budget) | a person 20 / 200; a service 60 / 1200 |
437| `POST /interactions/external` | 60 / 1200 |
438| Profile, goal and overlay config changes, token revoke | 30 / 300 |
439| `POST /overlay-tokens` | 10 / 60 |
440| Hide and restore | 60 / 600 |
441| Moderators add and remove; `POST /simulate` | 10 / 100 |
442| `POST /me/erase` | 3 / 10 |
443
444Never limited: `/api/health`, `/api/ready`, `/release.json`, `/metrics`, the overlays (bounded by
445`TIPS_OVERLAY_MAX_STREAMS`) and the signed Events deliveries at `/internal/events`. `test/actor-limits.test.js`.
446With `VALKEY_URL` every process counts one caller together (`test/integration.test.js`).
447
448## Acceptance (must be true before "done")
449
450| Criterion | Evidence |
451|---|---|
452| 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) |
453| overlay replay never charges again | `test/overlays.test.js` (three `Last-Event-ID` replays: transfers, contributions, goal total, payable, events unchanged) |
454| 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** |
455| creator totals reconcile exactly to Billing | `test/api.test.js` (totals = the stub Billing payable) |
456| 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` |
457| 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) |
458| 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) |
459| 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) |
460| 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) |
461
462## What waits
463
464- **EXTERNAL PowerChat tips** arrive as `billing.receipt.external` since the Billing cutover (2026-10-17), through
465 the `billing.receipt.*` subscription; the chat adapter posts the chat line and plays the alert, and Live advances
466 its own channel goal and tip history from `tips.interaction.ready` (OpenVibe.Live `server/tips/tips-events.js`).
467 PowerChat follow/host/channel-points/subscription notices are not forwarded by anyone.
468- **Paid media requests** have no delivery route (Chat does not own the media queue): with `chat` they are
469 recorded failed.
470- **Refunds of paid media requests** that never played (Billing's `POST /transfers/:id/refund`) are not
471 offered in Tips yet; a refund made elsewhere arrives as `billing.transaction.reversed` and is applied.
472- Legal pages use the shared `ugc` profile, which does not describe payments.
473
474## Deploy
475
476Production deploys with `sudo ovhost deploy tips` on the host (strategy `git-checkout`: fetch,
477fast-forward `/opt/openvibe.tips`, install on a lockfile change, restart, wait for `/api/ready`).
478The unit is `openvibe-tips.service` on `127.0.0.1:4610`, the env file `/etc/openvibe/tips.env`. State lives in
479`/var/lib/openvibe-tips`. The vhost [deploy/nginx/openvibe.tips.conf](deploy/nginx/openvibe.tips.conf)
480serves `openvibe.tips` since the launch (2026-10-17).
481Rollback: ovhost puts the previous sha back by itself when `/api/ready` does not answer 2xx after the
482restart; afterwards `sudo ovhost rollback tips --to <sha>`. Migrations are expand-only until a
483`contract` migration, which waits out the 7-day N-1 window.
484
485## Launch rule
486
487Held on 2026-10-17, when `openvibe.tips` launched (openvibe-contracts 0.200.0 made Tips `live`; Network stopped
488parking the domain). The rule, as written before the launch (plan §12.12):
489
4901. an owning runtime with health/readiness endpoints and observability — ✔ `/api/health`, `/api/ready`
491 (openvibe-shared/ready: the database required and reporting the store that answered; Valkey, the
492 Network key, Billing and Events optional, so their loss degrades rather than fails), `/metrics` for direct loopback callers (golden signals by
493 route template, pending effects and overlay deliveries, outbox backlog);
4942. canonical identity/auth integration (Network subjects, scoped service principals) — ✔; the `tips`
495 principal is provisioned on the host;
4963. server-rendered public routes useful without JavaScript — ✔;
4974. real persistence and end-to-end workflows — ✔, against the real Billing and Events since the cutover
498 (2026-10-17);
4995. capability and event registration against OpenVibe.Contracts — ✔ v0.15.0, payload schemas in v0.30.2 and v0.32.0 (v0.79.0 pinned);
5006. a migration/seed strategy ✔, a written threat review ✔ ([docs/threat-review.md](docs/threat-review.md),
501 internal; an independent review is still due), and sitemap/robots ✔ (no feed);
5027. acceptance tests proving the advertised functionality — ✔ (table above).
503
504A placeholder is never counted as an implemented service.
505
506---
507
508Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
509
510<!-- versions:start -->
511- openvibe-contracts: v0.419.0
512- openvibe-sdk: v0.161.0
513- openvibe-shared: v4.241.0
514<!-- versions:end -->
515