openvibers/openvibe.tips README.md
README.md
| 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 |
| 9 | after the Billing cutover made OpenVibe.Billing the money ledger. Tips posts its chat lines and alerts through |
| 10 | OpenVibe.Chat (`TIPS_CHAT_ADAPTER=chat`), records a streamer's own PowerChat tips from Billing, and OpenVibe.Live |
| 11 | moves its channel goal and tip history from `tips.interaction.ready`. Paid sounds since the same day. On launch day |
| 12 | production held the 139 interactions imported from Live and no creator profile yet. |
| 13 | Data: 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 | |
| 22 | A creator product that orchestrates Billing, Chat, Live and Media. Settlement is Billing's; Tips owns |
| 23 | the interaction, goals, overlays and creator configuration, and always distinguishes *payment settled* |
| 24 | from *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 | |
| 69 | Implemented 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 | |
| 75 | Called 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 |
| 89 | fnm exec --using=22.22.1 npm install |
| 90 | cp .env.example .env # OV_OAUTH_CLIENT_SECRET, TIPS_FORM_SECRET, TIPS_EVENTS_SECRET, … |
| 91 | npm run dev # http://localhost:4610 (without DATABASE_URL: an embedded PGlite database in data/pglite) |
| 92 | npm test # every test/*.test.js: stub Network/Billing/Events/Chat, PGlite, random ports |
| 93 | eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)" # PostgreSQL 18 + PgBouncer + Valkey 9 containers |
| 94 | npm test # … now also test/integration.test.js on the containers |
| 95 | npm run test:pg # every test file through PgBouncer and Valkey |
| 96 | npm run subscribe # create the billing.transaction.* and billing.receipt.* subscriptions in OpenVibe.Events |
| 97 | ``` |
| 98 | |
| 99 | Production: `/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`), |
| 101 | vhost [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 | ``` |
| 108 | payment_state pending ──► settled ──► reversed mirrored from Billing, keyed by billing_txn_id (UNIQUE) |
| 109 | └──► failed |
| 110 | delivery_state awaiting_payment ──► queued ──► delivered | failed | cancelled |
| 111 | ``` |
| 112 | |
| 113 | A delivery failure (Chat down, chat refused) is retried with backoff and finally recorded on the |
| 114 | effect; it never touches `payment_state`. A reversal after delivery flips `payment_state` and keeps the |
| 115 | delivery 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 | |
| 127 | PowerChat: Billing answers a PowerChat intent with a `checkout_ref` (`pcorder:<intent>`) and no URL, and Tips builds |
| 128 | the link from `TIPS_POWERCHAT_LINK_TEMPLATE` (`{ref}`, `{cents}`, `{bits}`). Production sets it since 2026-10-17 to |
| 129 | PowerChat'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}`): |
| 131 | the 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 |
| 134 | transaction as the change, so a redelivered event does nothing; `billing_txn_id` is UNIQUE, so the same |
| 135 | Billing 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 |
| 139 | exist before settlement), the goal contribution (goal the supporter picked, else the creator's only active |
| 140 | goal — 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 |
| 143 | from 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, |
| 146 | minus 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: |
| 176 | the same key twice at once runs once, the other answered `409 idempotency.in_progress` with `Retry-After` |
| 177 | until the first answer is stored and replayed). Services present a Network |
| 178 | service token (audience `openvibe.tips`, one capability per route); people present their Network user |
| 179 | token 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/) |
| 209 | and is registered in openvibe-contracts v0.32.0. |
| 210 | The capabilities and the service manifest were released in openvibe-contracts v0.15.0 (3-segment ids: |
| 211 | the charter's `tips.simulate` is `tips.simulation.run`; `tips.interaction.record` is new, for EXTERNAL |
| 212 | tips); 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 |
| 214 | contracts' `capabilities.grants()` (exact id or a `.*` family). Tips pins openvibe-contracts v0.33.0, |
| 215 | which 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 |
| 217 | payload Tips produces against them. |
| 218 | |
| 219 | ## Privacy |
| 220 | |
| 221 | What a supporter lets the public see is their choice, per tip (`privacy: { anonymous, hide_amount, |
| 222 | private_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 | |
| 231 | Only the supporter (their receipts, `GET /me/export`) and Billing (which moved the money) can link an |
| 232 | anonymous tip to them. `publicView()` is the one shape that leaves Tips for the public: overlay alert |
| 233 | payloads, chat jobs (which also carry `privacy`), the supporters and goals pages, and the `public` block |
| 234 | of every API answer. A goal bar still moves by a hidden amount, so the goal update of such a tip names |
| 235 | nobody. |
| 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 |
| 239 | page (`/<handle>/supporters`, off by default) with the top supporters, their totals and recent public |
| 240 | messages. 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 |
| 243 | supporter's tips. `POST /me/erase` (and `/receipts/erase`, with a confirmation) removes the person from |
| 244 | every settled, failed or reversed tip — subject, name, message, TTS text, media link, checkout reference, |
| 245 | stored API answers, overlay payloads, unsent events — and keeps the money record (amount, creator, date, |
| 246 | Billing transaction, goal contribution), so Billing's books and the creators' totals still reconcile. |
| 247 | Tips 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. |
| 249 | Stored 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` |
| 252 | arrive at `POST /internal/events` and are answered by `server/domain/account-data.js` (`openvibe-sdk/account-data`), |
| 253 | outside 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). |
| 266 | The money is never touched: hiding a paid message does not refund it, and a hidden tip still counts |
| 267 | toward 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 | |
| 290 | Produced through the openvibe-sdk outbox (table `tips_event_outbox`, source `tips`, same transaction as |
| 291 | the 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/) |
| 294 | and are in openvibe-contracts since v0.32.0: `tips.interaction.erased` (a supporter's erasure; `{ interaction_id, |
| 295 | creator, erased_at, redacts }`) and `tips.interaction.moderated` (`{ interaction_id, creator, action: |
| 296 | filtered|held|hidden|restored, by: filter|creator|moderator|service, moderation_state, cancelled_effects }`). |
| 297 | Simulations and Billing test money produce none. An anonymous supporter is never named in an event. |
| 298 | Consumed: |
| 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 |
| 303 | billing.env); before that Live's own PowerChat webhook announces those tips, so nothing is announced |
| 304 | twice. A `goal:<id>` in `app_purpose`/`app_ref` picks the goal (a Tips goal id); otherwise the creator's |
| 305 | only 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 |
| 311 | moderator 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 |
| 314 | cookie. Every alert and goal change is one `overlay_deliveries` row with a monotonic `seq` (the SSE id): |
| 315 | the first write to an overlay marks it `delivered` (`tips.overlay.delivered`), a row no overlay showed |
| 316 | within `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 |
| 322 | retries). `chat` (`server/delivery/chat.js`) posts to OpenVibe.Chat's typed ingress at `TIPS_CHAT_URL` |
| 323 | with 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 |
| 333 | 512 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, |
| 335 | cached for good: a file never changes). With paid sounds on (`sounds_enabled`, minimum `sound_min`, 50 Vibes on a |
| 336 | new page), a supporter picks one on the tip page or through `POST /api/v1/sound-requests`. After settlement the |
| 337 | overlay 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 | |
| 340 | Every body's `key` is the effect's delivery id (`<interaction>:<effect>`; the alert `<delivery id>:alert`): |
| 341 | Chat applies a key once and answers a repeat with the first result, so a retry after a 5xx or a lost answer |
| 342 | never posts twice. A 4xx (other than 401/408/425/429) fails the effect at once, everything else is retried |
| 343 | with backoff; a refused alert does not undo the line (`ref.alert: false`). Chat's rooms are Live's ids: the |
| 344 | adapter reads the creator's Live user id from the Network (`GET /internal/identity/resolve`, kept ten |
| 345 | minutes); a creator with no Live account fails at once. The body is rebuilt on each attempt from the |
| 346 | interaction's current public view, so a word-filter change between attempts makes Chat answer 409, which |
| 347 | fails the effect. A job carries the interaction's public view and `privacy`: "Anonymous" and no subject |
| 348 | for an anonymous supporter, `amount: null` and a line without the amount when it is hidden, no private |
| 349 | message. `test` records jobs in memory (development; simulations always use it). `none` (the default) creates |
| 350 | no chat effects. Production runs `chat` since the launch (2026-10-17): the `tips` principal holds the three |
| 351 | capabilities above (`TIPS_CHAT_URL` only when Chat is not on `127.0.0.1:4400`). |
| 352 | |
| 353 | ## Members-only posts |
| 354 | |
| 355 | A creator writes posts for the members of their OpenVibe.VIP tiers (the Billing tiers of their published VIP |
| 356 | plans). 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 |
| 384 | send, what a creator gets, the services a creator can link, how it works, what it costs, six questions with their |
| 385 | FAQPage 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 |
| 386 | page 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 |
| 388 | shown 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, |
| 390 | goals, 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`. |
| 392 | Signed-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 |
| 396 | agnostic, creators bring their own links): a creator lists where else people pay them (Ko-fi, PayPal.me, Patreon, Buy Me |
| 397 | a Coffee, Cash App, Venmo, GitHub Sponsors, Liberapay, Open Collective, Streamlabs and StreamElements tip pages; at most |
| 398 | 8) from the dashboard or `PATCH /api/v1/profiles/:creator {support_links: [{provider, handle}]}`. The tip page shows them |
| 399 | as paid outside OpenVibe (never held, never counted toward goals), `rel="nofollow noopener ugc"`. A link is a known |
| 400 | service plus a handle, and Tips builds the URL ([server/domain/support-links.js](server/domain/support-links.js)): a |
| 401 | pasted link counts only on that service's own host, so a profile cannot carry a look-alike payment page. |
| 402 | |
| 403 | ## Security |
| 404 | |
| 405 | Reporting a vulnerability: [SECURITY.md](SECURITY.md). Tips never moves money (ADR-012): it holds |
| 406 | references to Billing transaction ids, never a balance. People sign in with Network SSO; services use |
| 407 | client-credentials tokens for audience `openvibe.tips`, one capability per route. Overlays use revocable |
| 408 | scoped 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. |
| 410 | Tips 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), |
| 414 | paid-message abuse, TTS, amount spoofing, replay and privacy, with each finding's status and the test |
| 415 | that 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 | |
| 423 | The regressions are in `test/security.test.js`. It is an internal review; an independent one is still due |
| 424 | before 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 |
| 429 | store, Billing, chat delivery): `server/api/actor-limits.js`, openvibe-sdk/limits, roadmap WS-R task 4. A |
| 430 | service 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 | |
| 444 | Never 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`. |
| 446 | With `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 | |
| 476 | Production deploys with `sudo ovhost deploy tips` on the host (strategy `git-checkout`: fetch, |
| 477 | fast-forward `/opt/openvibe.tips`, install on a lockfile change, restart, wait for `/api/ready`). |
| 478 | The 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) |
| 480 | serves `openvibe.tips` since the launch (2026-10-17). |
| 481 | Rollback: ovhost puts the previous sha back by itself when `/api/ready` does not answer 2xx after the |
| 482 | restart; 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 | |
| 487 | Held on 2026-10-17, when `openvibe.tips` launched (openvibe-contracts 0.200.0 made Tips `live`; Network stopped |
| 488 | parking the domain). The rule, as written before the launch (plan §12.12): |
| 489 | |
| 490 | 1. 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); |
| 494 | 2. canonical identity/auth integration (Network subjects, scoped service principals) — ✔; the `tips` |
| 495 | principal is provisioned on the host; |
| 496 | 3. server-rendered public routes useful without JavaScript — ✔; |
| 497 | 4. real persistence and end-to-end workflows — ✔, against the real Billing and Events since the cutover |
| 498 | (2026-10-17); |
| 499 | 5. capability and event registration against OpenVibe.Contracts — ✔ v0.15.0, payload schemas in v0.30.2 and v0.32.0 (v0.79.0 pinned); |
| 500 | 6. 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); |
| 502 | 7. acceptance tests proving the advertised functionality — ✔ (table above). |
| 503 | |
| 504 | A placeholder is never counted as an implemented service. |
| 505 | |
| 506 | --- |
| 507 | |
| 508 | Part 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 |