Mirror of github.com/OpenVibers/OpenVibe.Tips
  • JavaScript 98.4%
  • CSS 1.6%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
OpenVibers a781094418
openvibe-contracts v0.215.0 (was v0.214.1) (#98)
Opened by scripts/pin-bump.js in OpenVibers/OpenVibe.Shared after the v0.215.0 release.
2026-10-27 04:00:44 -07:00
.github/workflows Pin Shared workflows to aa570e4 and enable the docs-status CI check (#49) 2026-10-14 09:43:24 -07:00
deploy nginx: GET /auth/me on the API zone (it runs on every page load; the sign-in flow's 10/min showed a visitor opening a dozen pages as signed out, the browser check's 429s) (#68) 2026-10-17 00:41:27 -07:00
docs Deliver paid chat effects through OpenVibe.Chat's ingress 2026-10-04 11:04:12 -07:00
migrations Other ways to support: a creator links Ko-fi, PayPal.me, Patreon and eight more services (Tips builds each link from a handle); the tip page shows them as paid outside OpenVibe (#67) 2026-10-16 23:20:22 -07:00
public Phones: list grids size their columns with minmax(0, 1fr) (or a min(…, 100%) minimum), so one long line can never widen a page past the screen (#88) 2026-10-25 03:36:49 -07:00
scripts Tips keeps no Live-import or SQLite code (the import is long done; no compatibility kept): server/importer/live.js, scripts/import-live.js and migrate-to-postgres.js, the import and SQLite-move tests and fixture, the better-sqlite3 dependency, sqlitePath/TIPS_DB_PATH, and the legacy_source columns and migration_maps table (dropped by the new migration 0002); goal:<id> takes Tips goal ids only. The earlier await fixes (13 files: async store calls inside transactions and routes) ship with it. Reviewed independently; its findings (untracked migration, lockfile, two stale README lines) are resolved here. npm test 16/16 test files passed. 2026-09-28 20:02:58 -07:00
server Home: the "Part of OpenVibe" section (Shared 4.13 showcase.network) (#91) 2026-10-25 18:40:06 -07:00
test openvibe.tips/start: a creator's tip page in one step, made from the account (Live's dashboard links here) (#76) 2026-10-22 14:18:42 -07:00
.env.example Add IndexNow key file and ping creator pages on change 2026-10-04 15:24:27 -07:00
.gitignore OpenVibe.Tips W9: tip interactions on top of Billing, goals, paid messages/TTS/media requests, overlays, SSR pages, events, Live import 2026-09-22 19:27:25 -07:00
LICENSE Charter: OpenVibe.Tips as defined in the OpenVibe realignment plan (placeholder, no code yet) 2026-09-21 09:19:48 -07:00
package-lock.json openvibe-contracts v0.215.0 (was v0.214.1) (#98) 2026-10-27 04:00:44 -07:00
package.json openvibe-contracts v0.215.0 (was v0.214.1) (#98) 2026-10-27 04:00:44 -07:00
README.md openvibe-contracts v0.215.0 (was v0.214.1) (#98) 2026-10-27 04:00:44 -07:00
SECURITY.md SECURITY.md: how to report a vulnerability ([email protected], 7-day reply, scope, supported versions) 2026-09-23 19:27:53 -07:00
STATUS.json openvibe-contracts v0.215.0 (was v0.214.1) (#98) 2026-10-27 04:00:44 -07:00

OpenVibe.Tips

Creator support: tips, goals, paid messages, TTS, media requests and paid sounds, overlays.

Status: alpha — launched 2026-10-17 at openvibe.tips (openvibe-contracts 0.200.0), right after the Billing cutover made OpenVibe.Billing the money ledger. Tips posts its chat lines and alerts through OpenVibe.Chat (TIPS_CHAT_ADAPTER=chat), records a streamer's own PowerChat tips from Billing, and OpenVibe.Live moves its channel goal and tip history from tips.interaction.ready. Paid sounds since the same day. On launch day production held the 139 interactions imported from Live and no creator profile yet. Data: PostgreSQL through PgBouncer, and Valkey for shared state (ADR-035);
Domain: openvibe.tips
Port / service id: 4610 / tips
Decision: ADR-012 — Tips never moves money; Billing does.
Plan: OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §11.2; roadmap Wave 9, §15.11, §29.
License: AGPL-3.0.

Purpose

A creator product that orchestrates Billing, Chat, Live and Media. Settlement is Billing's; Tips owns the interaction, goals, overlays and creator configuration, and always distinguishes payment settled from interaction delivered.

Owns

  • tip_interactions, creator_tip_profiles, tip_goals, tip_goal_contributions, paid_messages, paid_media_requests, creator_sounds, overlay_configs, overlay_deliveries, interaction_effects (its own PostgreSQL database ov_tips, schema in migrations/, access in server/db.js) — interaction state and references to Billing transaction ids, never a mutable cash balance
  • revocable scoped overlay tokens (overlay_tokens, hashed at rest; never creator cookies)

Does not own

  • monetary accounting, prices, balances, refunds, payouts (OpenVibe.Billing)
  • chat rooms, TTS synthesis and queues (OpenVibe.Chat; Live's chat server until Chat's cutover)
  • media bytes and the media queue (OpenVibe.Media / Live)
  • identity (OpenVibe.Network)

Depends on

  • PostgreSQL 18 through PgBouncer (transaction mode) and Valkey 9 — OpenVibe.Host's data role (roles/data/add-service.sh tips: database ov_tips, owner and runtime roles, a Valkey user on ov:tips:*). PostgreSQL is required (the system of record); Valkey holds only what the processes share (per-actor limit counters, overlay fan-out, overlay stream slots) and its loss changes no answer about money
  • OpenVibe.Billing — POST /api/v1/intents (checkout), POST /api/v1/transfers (a tip from credit), billing.transaction.settled|reversed events, and billing.receipt.external (a tip on a creator's own 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 (a creator's Chat room)
  • OpenVibe.Chat — chat delivery through its typed ingress (/internal/chat/messages, /internal/chat/events)
  • OpenVibe.Live — overlay consumer; its public stream list (GET <LIVE_URL>/api/streams) feeds the home's "Live on OpenVibe now" (server/clients/live-now.js: once a minute, 2 s timeout, never Live's AI text, nothing when Live does not answer; TIPS_LIVE_NOW=on|off, off in tests by default)
  • 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.28.0 (service tokens, the async data layer openvibe-sdk/db, the PostgreSQL outbox and inbox — the events outbox is the SDK's createServiceOutbox, plan T1 — Valkey, pub/sub and the shared limit store), pinned by release tarball; pg and iovalkey (drivers), PGlite for tests

Capabilities

Implemented here (the service manifest's capabilities, 19 tips.* ids, audience openvibe.tips): tips.profile.get|update, tips.checkout.create, tips.superchat.create, tips.tts.request, tips.media_request.create, tips.sound.request, tips.interaction.get|list|record|moderate, tips.goal.create|update|close, tips.overlay.token.create|revoke, tips.overlay.config.get|update and tips.simulation.run.

Called elsewhere, as the service principal tips:

Service Grant Why
OpenVibe.Billing billing.intent.create, billing.transfer.create checkout, and a tip from credit
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)
OpenVibe.Network network.account.export.contribute, network.account.deletion.confirm (granted last, once this release is live) account export and deletion (ADR-033)
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

fnm exec --using=22.22.1 npm install
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 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
npm test                        # … now also test/integration.test.js on the containers
npm run test:pg                 # every test file through PgBouncer and Valkey
npm run subscribe               # create the billing.transaction.* and billing.receipt.* subscriptions in OpenVibe.Events

Production: /opt/openvibe.tips, env /etc/openvibe/tips.env, unit deploy/systemd/openvibe-tips.service (state in /var/lib/openvibe-tips), vhost deploy/nginx/openvibe.tips.conf (installed 2026-10-17: openvibe.tips serves Tips).

Design

Two state machines per interaction (server/domain/interactions.js):

payment_state   pending ──► settled ──► reversed        mirrored from Billing, keyed by billing_txn_id (UNIQUE)
                       └──► failed
delivery_state  awaiting_payment ──► queued ──► delivered | failed | cancelled

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 delivery record; a reversal before delivery cancels the queued effects (tips.interaction.cancelled).

How money moves (it never moves in Tips):

Funding What Tips asks Billing Settles when
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)
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)
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
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
none (simulation) nothing at once, test = 1: full effect path, no goal contribution, no durable event, excluded from totals

PowerChat: Billing answers a PowerChat intent with a checkout_ref (pcorder:<intent>) and no URL, and Tips builds the link from TIPS_POWERCHAT_LINK_TEMPLATE ({ref}, {cents}, {bits}). Production sets it since 2026-10-17 to PowerChat's canonical tip link on the site's PowerChat account, the same link OpenVibe.Live builds for Vibes purchases (https://<powerchat>/<site account>/tip?app_client_id=…&app_ref={ref}&app_purpose=vibes&app_amount_cents={cents}): the payment's webhook reaches Billing through openvibe.live, Billing credits the supporter, and Tips gives the tip.

Exactly one logical interaction: the inbox claims (consumer, event_id) in the same serializable transaction as the change, so a redelivered event does nothing; billing_txn_id is UNIQUE, so the same Billing transaction under another event id (republish, replay) is still one interaction; API calls carry Idempotency-Key (stored responses + the interaction's own unique key); the no-JS form carries a nonce.

Settlement writes, in one transaction: the paid_messages / paid_media_requests row (these never exist before settlement), the goal contribution (goal the supporter picked, else the creator's only active goal — Live's rule), the overlay alert, the interaction_effects, and tips.interaction.ready.

Goals are derived: current = Σ(contribution − reversed), plus opening_amount for goals imported from Live (whose current_amount cannot be traced to individual donations).

Totals (GET /api/v1/profiles/:creator/totals): settled_via_billing (settled or imported, non-test, minus what Billing took back) — the number that must equal Billing's books — with external and simulations reported apart.

Data (ADR-035; server/db.js, migrations/):

  • Everything is async on openvibe-sdk/db: a function that touches the database takes the query handle first (db, or the transaction's t), and after-commit work (overlay pushes, worker kicks) hangs on the transaction (t.after). The schema is migrations/NNNN_*.sql (expand/migrate/contract), applied at boot with the owner role; the service then serves through PgBouncer with no session state.
  • Money paths are SERIALIZABLE with row locks (ADR-007 amendment 2026-09-24, rule 3): a request and its unpaid-checkout limit, settlement (paid message, media request, goal contribution), payment failure, funding, reversal, the Billing inbox, and what cancels or releases what a payment bought (moderation's hide and restore, the effects worker's writes). Locks are taken in one order: a creator's profile row (settings changes and per-creator limits), the interaction row, its goals by id, the creator's overlay stream (an advisory lock, so a creator's deliveries commit in seq order), then the rest. Serialization failures and deadlocks are retried by the data layer.
  • Text is made storable on the way in: PostgreSQL keeps no NUL character, and jsonb no unpaired surrogate, so they are dropped or replaced (U+FFFD) in what people and Billing's events send.
  • Background work runs in every process and claims its rows: due effects and due transfers with a lease (FOR UPDATE SKIP LOCKED, the lease in next_attempt_at / next_transfer_at), the overlay window sweep with SKIP LOCKED, the outbox relay by the SDK's lease; prunes are idempotent deletes.
  • Shared state in Valkey (none of it authoritative): per-actor limit counters (the SDK's shared limit store), overlay fan-out (overlay:<creator> on pub/sub: a stream in one process hears a tip, a config change, a retraction or a revocation made in another), overlay stream slots (a sorted set per creator, refreshed by heartbeats). Without VALKEY_URL each process keeps its own, which is right for one process.
  • Every query shape has an index (test/migrations.test.js checks the plans), lists page by keyset, and a list costs a fixed number of queries whatever its size (test/query-budget.test.js).

API

/api/v1, problem+json errors, Idempotency-Key on every POST/PATCH (claimed before the handler runs: the same key twice at once runs once, the other answered 409 idempotency.in_progress with Retry-After until the first answer is stored and replayed). Services present a Network service token (audience openvibe.tips, one capability per route); people present their Network user token as a Bearer and act on their own things only. The API never reads cookies.

Method & path Capability (services) People
GET /profiles/:creator tips.profile.get (switched-off pages) anyone when the page is on; the owner
PATCH /profiles/:creator (me creates it) tips.profile.update owner
GET /profiles/:creator/totals tips.interaction.list owner
POST /checkout {creator, amount, message?, goal_id?, pay_with: credit|checkout, provider?, supporter (services), target?, privacy?} tips.checkout.create the supporter
POST /paid-messages tips.superchat.create the supporter
POST /tts-requests {…, tts: {text, voice}} tips.tts.request the supporter
POST /media-requests {…, media: {url}} tips.media_request.create the supporter
POST /sound-requests {…, sound: {id}} tips.sound.request the supporter
GET /profiles/:creator/sounds tips.profile.get (switched-off pages) anyone when the page is on; the owner
GET /interactions/:id tips.interaction.get its creator or supporter
GET /interactions?creator=|supporter=&cursor= tips.interaction.list own receipts; ?as=creator own tips
POST /interactions/external {creator, provider, provider_ref, amount_cents, supporter_name?, message?, announce?} tips.interaction.record —
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
GET /profiles/:creator/supporters tips.profile.get (while not public) public when the creator shows the supporters page; the owner
GET /me/export, POST /me/erase — (people only) the supporter's own tips: download; erase their data from them
GET /moderation?creator=&state=held|hidden|visible|all tips.interaction.moderate the creator and their moderators
POST /interactions/:id/hide, POST /interactions/:id/restore {reason?} tips.interaction.moderate the creator and their moderators
GET /moderation/log?creator= tips.interaction.moderate owner
GET|POST /moderators, POST /moderators/:subject/remove tips.profile.update owner
POST /goals, PATCH /goals/:id, POST /goals/:id/close tips.goal.create / .update / .close owner
GET|POST /overlay-tokens, POST /overlay-tokens/:id/revoke tips.overlay.token.create / .revoke owner
GET /overlay-configs[/:id], POST /overlay-configs, PATCH /overlay-configs/:id tips.overlay.config.get / .update owner
POST /simulate tips.simulation.run owner
POST /internal/events Events webhook signature (TIPS_EVENTS_SECRET), loopback only —

tips.interaction.moderate was proposed in docs/capabilities-proposal/ and is registered in openvibe-contracts v0.32.0. The capabilities and the service manifest were released in openvibe-contracts v0.15.0 (3-segment ids: the charter's tips.simulate is tips.simulation.run; tips.interaction.record is new, for EXTERNAL tips); the drafts stay in docs/capabilities-proposal/ and docs/service-manifest-proposal.json. Grants are matched with contracts' capabilities.grants() (exact id or a .* family). Tips pins openvibe-contracts v0.33.0, which also carries the payload schemas of the six tips.* events below (v0.30.2) and of tips.interaction.moderated|erased (v0.32.0); test/contracts.test.js validates every envelope and payload Tips produces against them.

Privacy

What a supporter lets the public see is their choice, per tip (privacy: { anonymous, hide_amount, private_message } on the API, three boxes on the tip form), stored on the interaction (server/domain/privacy.js):

Choice Overlays, chat lines, public pages, other products The creator Events (internal)
anonymous "Anonymous", no subject "Anonymous", no subject supporter: null, supporter_name: "Anonymous"
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)
private_message (tips only) no message the message no message is ever in an event

Only the supporter (their receipts, GET /me/export) and Billing (which moved the money) can link an anonymous tip to them. publicView() is the one shape that leaves Tips for the public: overlay alert payloads, chat jobs (which also carry privacy), the supporters and goals pages, and the public block of every API answer. A goal bar still moves by a hidden amount, so the goal update of such a tip names nobody.

What the creator's public pages show is theirs to choose (page on the profile, the dashboard's Public pages): goal amounts or the percentage only, each goal's latest supporters, and a supporters page (/<handle>/supporters, off by default) with the top supporters, their totals and recent public messages. The leaderboard counts only tips whose supporter kept both name and amount public.

Export and erasure. GET /me/export (and /receipts/export) downloads everything Tips holds about a supporter's tips. POST /me/erase (and /receipts/erase, with a confirmation) removes the person from every settled, failed or reversed tip — subject, name, message, TTS text, media link, checkout reference, stored API answers, overlay payloads, unsent events — and keeps the money record (amount, creator, date, Billing transaction, goal contribution), so Billing's books and the creators' totals still reconcile. Tips still waiting for their payment are kept until it settles or fails. Each erased interaction emits tips.interaction.erased, whose redacts has OpenVibe.Events tombstone Tips' earlier events about it. Stored API answers (Idempotency-Key replays) are pruned after a week.

Account export and deletion (ADR-033). network.account.export_requested and network.account.deleted arrive at POST /internal/events and are answered by server/domain/account-data.js (openvibe-sdk/account-data), outside the money inbox, with one receipt per export and deletion in account_data_events.

  • As a supporter: the export is /me/export's, and the deletion is the erasure above.
  • As a creator: the export adds their settings, overlays (never a token hash), goals, the tips they received (without the supporters) and their moderators. The deletion removes their settings and page, overlays, overlay tokens and alert history, moderator list and invitations, and the stored answers that name them. The tips they received, the paid messages and media requests on them, their goals and their moderation log are kept and counted as retained.
  • As a moderator: their moderator rows go; the moderation log keeps naming them.

Moderation

server/domain/moderation.js, server/domain/filter.js. The money is never touched: hiding a paid message does not refund it, and a hidden tip still counts toward its goal and the creator's totals.

  • Word filter (filter: { words, action, links } on the profile, the dashboard's Moderation card; the list is the creator's only). Blocked words or phrases match whole, in any letter case, after Unicode NFKC folding and with invisible and direction-override characters removed, in the name and in what is shown or read. mask (default) stars them out of overlays, chat lines and pages and leaves them out of what text-to-speech reads; hold holds a paid message that matches before anything is shown, posted or read, until the creator or a moderator shows it (then it is released as settlement would have released it, still masked). Links in paid messages read [link] unless the creator turns that off.
  • Hide / show. The creator, their moderators and services with tips.interaction.moderate hide a paid message: its queued chat, TTS and media deliveries are cancelled (never delivered), its overlay alert is retracted (connected overlays get a retract event and drop it, replays and /state skip it) and public pages leave it out. Showing it again puts it back on pages and in overlay state; what the hide cancelled stays cancelled. /moderate/<handle> is the review page (held, shown, hidden).
  • Moderators are added by the creator: a show-once invitation link from the dashboard (hashed at rest, single use, 7 days, never in nginx's log), accepted signed in; or POST /moderators by the creator or a service. Moderators see what was written but never private messages, hidden amounts or payments.
  • Every outcome is in tip_moderation_log (with the moderator and their note) and is published as tips.interaction.moderated (below), which carries no supporter, message, note or moderator.

Events

Produced through the openvibe-sdk outbox (table tips_event_outbox, source tips, same transaction as the change; relayed by any process, rows claimed with a lease, when EVENTS_URL and the client secret are set): tips.interaction.ready, tips.interaction.failed, tips.interaction.cancelled, tips.goal.updated, tips.overlay.delivered, tips.overlay.failed, and two whose schemas were proposed in docs/events-proposal/ and are in openvibe-contracts since v0.32.0: tips.interaction.erased (a supporter's erasure; { interaction_id, creator, erased_at, redacts }) and tips.interaction.moderated ({ interaction_id, creator, action: filtered|held|hidden|restored, by: filter|creator|moderator|service, moderation_state, cancelled_effects }). Simulations and Billing test money produce none. An anonymous supporter is never named in an event. Consumed: billing.transaction.settled, billing.transaction.reversed, billing.receipt.external (only from source billing). billing.receipt.external (payload: streamer SubjectRef, amount_cents, value_bits, donor_name — null when anonymous — message, provider, provider_event_id, app_purpose/app_ref, test) is sent by Billing only once it is the money authority (BILLING_AUTHORITY=billing in billing.env); before that Live's own PowerChat webhook announces those tips, so nothing is announced twice. A goal:<id> in app_purpose/app_ref picks the goal (a Tips goal id); otherwise the creator's only active goal.

Overlays

GET /overlay/<token> is an OBS Browser Source page; /overlay/<token>/events is the SSE stream (hello with config and active goals, then alert and goal, config on changes, retract when a moderator hides an alert, revoked), and /overlay/<token>/state a read-only JSON for other overlay clients (e.g. Live). Tokens are scoped (alerts, goals), shown once, stored as SHA-256, revocable (open streams close at once) and never a cookie. Every alert and goal change is one overlay_deliveries row with a monotonic seq (the SSE id): the first write to an overlay marks it delivered (tips.overlay.delivered), a row no overlay showed within TIPS_OVERLAY_TTL_MS becomes failed (tips.overlay.failed). A reconnect with Last-Event-ID replays — it writes bytes to a socket and nothing else: no charge, no goal count, no event.

Chat delivery

server/delivery/ — an adapter is { name, deliver(job) → { ref } } (throw to fail; permanent stops 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

Paid sounds (kind: sound, server/domain/sounds.js): a creator keeps up to 12 sounds (MP3, OGG or WAV of 512 KB at most, recognised by their bytes), uploaded from the dashboard with a plain multipart form (server/web/multipart.js) and served from /sounds/<id> as their own audio type (nosniff, a sandboxing CSP, cached for good: a file never changes). With paid sounds on (sounds_enabled, minimum sound_min, 50 Vibes on a new page), a supporter picks one on the tip page or through POST /api/v1/sound-requests. After settlement the overlay alert carries the sound and the overlay plays it in place of the alert sound; the chat line reads " played “” for N Vibes".

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 default) creates no chat effects. Production runs chat since the launch (2026-10-17): the tips principal holds the three capabilities above (TIPS_CHAT_URL only when Chat is not on 127.0.0.1:4400).

Pages (server-rendered, useful without JavaScript)

/ (who is live on OpenVibe, the creators who switched their page on with their first active goal, what a supporter can send, what a creator gets, the services a creator can link, how it works, what it costs, six questions with their 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 page on, otherwise 404 for everyone else and a noindex preview for the creator) · /<handle>/goals · /<handle>/supporters (when the creator shows it) · /receipts, /receipts/:id (payment and delivery shown separately, with the privacy chosen), /receipts/export, /receipts/erase · /moderate/<handle> (the creator and their moderators), /moderate/invite/<secret> · /dashboard (page settings, public pages, moderation, goals, overlay links shown once, alert settings, simulation, totals, recent tips, overlay deliveries) · /robots.txt, /sitemap.xml (switched-on pages only) · /release.json · /terms, /privacy, /dmca. Signed-in forms carry an HMAC anti-forgery token and a per-render nonce. OpenVibe Frame: Network navbar.js, openvibe-shared app icon, SSR footer and <noscript> navigation.

Other ways to support (since 2026-10-17, openvibe-contracts 0.202.0 support_links; owner 10-08: Billing is provider agnostic, creators bring their own links): a creator lists where else people pay them (Ko-fi, PayPal.me, Patreon, Buy Me a Coffee, Cash App, Venmo, GitHub Sponsors, Liberapay, Open Collective, Streamlabs and StreamElements tip pages; at most 8) from the dashboard or PATCH /api/v1/profiles/:creator {support_links: [{provider, handle}]}. The tip page shows them as paid outside OpenVibe (never held, never counted toward goals), rel="nofollow noopener ugc". A link is a known service plus a handle, and Tips builds the URL (server/domain/support-links.js): a pasted link counts only on that service's own host, so a profile cannot carry a look-alike payment page.

Security

Reporting a vulnerability: SECURITY.md. Tips never moves money (ADR-012): it holds references to Billing transaction ids, never a balance. People sign in with Network SSO; services use 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 (TIPS_EVENTS_SECRET) through an inbox that dedupes them; /internal/ and /metrics are never public. 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 is the written threat review: overlays (tokenised URLs), paid-message abuse, TTS, amount spoofing, replay and privacy, with each finding's status and the test that shows it. The fixes it made:

  • a Billing donation settles an interaction only when amount, creator and supporter match;
  • a replayed POST /overlay-tokens answer carries no secret, and none is stored;
  • open streams per overlay token (TIPS_OVERLAY_MAX_STREAMS) and unpaid checkouts per supporter (TIPS_MAX_PENDING_CHECKOUTS) are bounded;
  • the creator's own name is refused as a supporter name;
  • invisible and direction-override characters are dropped, and TTS text carries no markup.

The regressions are in test/security.test.js. It is an internal review; an independent one is still due before launch.

Per-actor limits

/api/v1 also limits who calls it, once the credential is checked and before any work (the idempotency store, Billing, chat delivery): server/api/actor-limits.js, openvibe-sdk/limits, roadmap WS-R task 4. A service counts as its principal (svc:live), a person as user:usr_…, anyone else by address. Past a limit: 429 problem+json rate_limited with Retry-After, one log line and tips_rate_limited_total{limit,window}.

Routes Per caller
Every read TIPS_LIMITS_MINUTE / TIPS_LIMITS_HOUR (120 a minute, 3000 an hour)
POST /checkout, /paid-messages, /tts-requests, /media-requests (one budget) a person 20 / 200; a service 60 / 1200
POST /interactions/external 60 / 1200
Profile, goal and overlay config changes, token revoke 30 / 300
POST /overlay-tokens 10 / 60
Hide and restore 60 / 600
Moderators add and remove; POST /simulate 10 / 100
POST /me/erase 3 / 10

Never limited: /api/health, /api/ready, /release.json, /metrics, the overlays (bounded by TIPS_OVERLAY_MAX_STREAMS) and the signed Events deliveries at /internal/events. test/actor-limits.test.js. With VALKEY_URL every process counts one caller together (test/integration.test.js).

Acceptance (must be true before "done")

Criterion Evidence
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); 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: 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)

What waits

  • EXTERNAL PowerChat tips arrive as billing.receipt.external since the Billing cutover (2026-10-17), through the billing.receipt.* subscription; the chat adapter posts the chat line and plays the alert, and Live advances its own channel goal and tip history from tips.interaction.ready (OpenVibe.Live server/tips/tips-events.js). PowerChat follow/host/channel-points/subscription notices are not forwarded by anyone.
  • 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 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.

Deploy

Production deploys with sudo ovhost deploy tips on the host (strategy git-checkout: fetch, fast-forward /opt/openvibe.tips, install on a lockfile change, restart, wait for /api/ready). The unit is openvibe-tips.service on 127.0.0.1:4610, the env file /etc/openvibe/tips.env. State lives in /var/lib/openvibe-tips. The vhost deploy/nginx/openvibe.tips.conf serves openvibe.tips since the launch (2026-10-17). Rollback: ovhost puts the previous sha back by itself when /api/ready does not answer 2xx after the restart; afterwards sudo ovhost rollback tips --to <sha>. Migrations are expand-only until a contract migration, which waits out the 7-day N-1 window.

Launch rule

Held on 2026-10-17, when openvibe.tips launched (openvibe-contracts 0.200.0 made Tips live; Network stopped parking the domain). The rule, as written before the launch (plan §12.12):

  1. an owning runtime with health/readiness endpoints and observability — ✔ /api/health, /api/ready (openvibe-shared/ready: the database required and reporting the store that answered; Valkey, the Network key, Billing and Events optional, so their loss degrades rather than fails), /metrics for direct loopback callers (golden signals by route template, pending effects and overlay deliveries, outbox backlog);
  2. canonical identity/auth integration (Network subjects, scoped service principals) — ✔; the tips principal is provisioned on the host;
  3. server-rendered public routes useful without JavaScript — ✔;
  4. real persistence and end-to-end workflows — ✔, against the real Billing and Events since the cutover (2026-10-17);
  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);
  6. a migration/seed strategy ✔, a written threat review ✔ (docs/threat-review.md, internal; an independent review is still due), and sitemap/robots ✔ (no feed);
  7. acceptance tests proving the advertised functionality — ✔ (table above).

A placeholder is never counted as an implemented service.


Part of the OpenVibe network. Built in the open by OpenVibers.

  • openvibe-contracts: v0.215.0
  • openvibe-sdk: v0.65.0
  • openvibe-shared: v4.15.2