openvibers/openvibe.tips Commits
OpenVibe.Tips W9: tip interactions on top of Billing, goals, paid messages/TTS/media requests, overlays, SSR pages, events, Live import
Changes
nnew file mode 100644@@@ -0,0 +1,75 @@++# ═══════════════════════════════════════════════════════════════++# openvibe.tips — Environment Configuration++# Copy to .env for development. In production this lives at /etc/openvibe/tips.env (0600).++# Names only here; never commit a value.++# ═══════════════════════════════════════════════════════════════++++# Server++PORT=4610++HOST=127.0.0.1++NODE_ENV=development++BASE_URL=http://localhost:4610++TRUST_PROXY=1++++# SQLite (one database per service). The systemd unit sets /var/lib/openvibe-tips/tips.db.++TIPS_DB_PATH=./data/tips.db++++# ── Identity: OpenVibe.Network ──────────────────────────────++OV_NETWORK_URL=https://openvibe.network++OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000++#OV_NETWORK_ISSUER=https://openvibe.network++# Optional: pin the Network's RS256 public key (PEM, \n escaped) instead of fetching the JWKS.++#OV_NETWORK_PUBLIC_KEY=++# Audience Tips accepts on incoming service tokens.++TIPS_AUDIENCE=openvibe.tips++# Tips' client in the Network (client `tips`): browser sign-in (redirect ${BASE_URL}/auth/callback)++# and client-credentials tokens for Billing, Events, Live and Network.++OV_OAUTH_CLIENT_ID=tips++OV_OAUTH_CLIENT_SECRET=++#OV_OAUTH_REDIRECT_URI=https://openvibe.tips/auth/callback++#COOKIE_SECURE=true++# Signs the anti-forgery tokens of the server-rendered forms (openssl rand -hex 32).++TIPS_FORM_SECRET=++++# ── OpenVibe.Billing (money moves only there) ───────────────++BILLING_URL=http://127.0.0.1:4600++#BILLING_AUDIENCE=openvibe.billing++# Checkout providers offered on tip pages (must be enabled on Billing).++TIPS_CHECKOUT_PROVIDERS=powerchat++# Billing's minimum purchase in bits; smaller tips can only be paid from a Vibes balance.++#TIPS_MIN_CHECKOUT_BITS=100++# Billing answers a PowerChat intent with a checkout_ref, not a URL. If set, Tips builds the link++# from it: {ref}, {cents} and {bits} are replaced. Without it the receipt says no link is available.++#TIPS_POWERCHAT_LINK_TEMPLATE=++++# ── OpenVibe.Events ─────────────────────────────────────────++# Relay of Tips' outbox (tips.*); off when unset (rows wait in event_outbox).++#EVENTS_URL=http://127.0.0.1:4300++#EVENTS_RELAY_INTERVAL_MS=2000++# Signing secret of Tips' subscription to billing.transaction.* (scripts/subscribe.js hands it to++# Events). Comma-separated during a rotation. Without it /internal/events answers 503.++TIPS_EVENTS_SECRET=++++# ── Chat delivery ───────────────────────────────────────────++# live-chat (Live's /internal/tips/deliveries, docs/live-patch.diff) | test | none (production default)++#TIPS_CHAT_ADAPTER=none++#LIVE_INTERNAL_URL=http://127.0.0.1:3000++#LIVE_AUDIENCE=openvibe.live++#TIPS_DELIVERY_MAX_ATTEMPTS=6++#TIPS_DELIVERY_BACKOFF_MS=2000,10000,60000,300000,900000++++# ── Overlays ────────────────────────────────────────────────++#TIPS_OVERLAY_TTL_MS=600000++#TIPS_OVERLAY_HEARTBEAT_MS=25000++#TIPS_OVERLAY_REPLAY_LIMIT=50++++# ── Limits ──────────────────────────────────────────────────++#TIPS_MAX_BITS=10000000++#TIPS_MESSAGE_CHARS=300++#TIPS_TTS_HARD_CAP=1200++#TIPS_MEDIA_HOSTS=youtube.com,www.youtube.com,m.youtube.com,music.youtube.com,youtu.be++++# ── Background jobs: effects worker, due transfers, overlay window, outbox relay ──++#TIPS_JOBS=on++#TIPS_JOBS_INTERVAL_MS=2000++#LIVE_URL=https://openvibe.livennew file mode 100644@@@ -0,0 +1,21 @@++name: ci++on:++ push:++ branches: [main]++ pull_request:++jobs:++ test:++ runs-on: ubuntu-latest++ steps:++ - uses: actions/checkout@v4++ - uses: actions/setup-node@v4++ with:++ node-version: 22.22.1 # production runs Node 22++ cache: npm++ - run: npm ci --no-audit --no-fund++ - run: npm test++ # The tips manifest in openvibe-contracts is still the charter placeholder and the tips.*++ # capabilities are proposals (docs/), so this check cannot pass until the lead releases them++ # in a contracts version. Remove continue-on-error in that release.++ - run: npx openvibe-contracts-check --service tips --src server++ continue-on-error: truennew file mode 100644@@@ -0,0 +1,12 @@++node_modules/++data/++dist/++.env++.env.*++!.env.example++*.db++*.db-wal++*.db-shm++*.log++*.pem++*.key@@@ -2,77 +2,235 @@> Creator support: tips, goals, paid messages, TTS and media requests, overlays.--**Status:** placeholder — planning only, no runnable code yet.--**Domain:** `openvibe.tips`--**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §11.2.--**License:** AGPL-3.0 (same as every OpenVibe service).++**Status:** alpha — runtime built and tested against stubs (roadmap Wave 9); not deployed. OpenVibe.Live++remains where tips happen until the Billing cutover and the Live chat patch ship (see *What waits*).++**Domain:** `openvibe.tips` (keeps its OpenVibe.Sites placeholder until the launch rule below holds)++**Port / service id:** 4610 / `tips`++**Decision:** [ADR-012](https://github.com/OpenVibers/OpenVibe.Contracts/blob/main/docs/adr/ADR-012-economic-classification.md) — 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 first-class 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*.++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`, `overlay_configs`, `overlay_deliveries`, `interaction_effects`, `migration_maps`--- revocable scoped overlay tokens (never creator cookies)++- `tip_interactions`, `creator_tip_profiles`, `tip_goals`, `tip_goal_contributions`, `paid_messages`,++ `paid_media_requests`, `overlay_configs`, `overlay_deliveries`, `interaction_effects`, `migration_maps`++ (its own SQLite, `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 (Billing)--- chat/TTS queues (Chat)--- media bytes (Media)----## Planned surfaces----- `apps/web` SSR creator page, tip page, goals, history/receipts, TTS/paid-message config, overlays, simulation/test mode--- `apps/api`, `packages/domain`, `packages/overlays`, `workers/` post-settlement orchestration----## Data (authority tables / families)----- see above----## Capabilities and events----- `tips.profile.get|update`, `tips.checkout.create`, `tips.interaction.get|list`, `tips.goal.*`, `tips.superchat.create`, `tips.tts.request`, `tips.media_request.create`, `tips.overlay.token.create|revoke`, `tips.overlay.config.*`, `tips.simulate`----Events: ``tips.interaction.ready|failed|cancelled``, ``tips.goal.updated``, ``tips.overlay.delivered|failed``++- 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--- OpenVibe.Billing--- OpenVibe.Chat--- OpenVibe.Media--- OpenVibe.Events--- OpenVibe.Live (overlay consumer only)++- **OpenVibe.Billing** — `POST /api/v1/intents` (checkout), `POST /api/v1/transfers` (a tip from credit),++ `billing.transaction.settled|reversed` events++- **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 (importer)++- **OpenVibe.Live** — chat delivery through `/internal/tips/deliveries` ([docs/live-patch.diff](docs/live-patch.diff)) until OpenVibe.Chat exposes paid messages; overlay consumer++- **OpenVibe.Shared** v1.2.1 (app icon, SSR footer, noscript nav, release manifest, legal pages) and++ the Network's `navbar.js`++++## Run it++++```bash++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++npm test # 6 files: stub Network/Billing/Events/Live, temp DBs, random ports++npm run subscribe # create the billing.transaction.* subscription in OpenVibe.Events++node scripts/import-live.js --live-db <live snapshot> --billing-db <billing snapshot> [--dry-run] [--json]++```++++Production (when launched): `/opt/openvibe.tips`, env `/etc/openvibe/tips.env`, unit++[deploy/systemd/openvibe-tips.service](deploy/systemd/openvibe-tips.service) (state in `/var/lib/openvibe-tips`),++vhost [deploy/nginx/openvibe.tips.conf](deploy/nginx/openvibe.tips.conf).++++## 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 (Live 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`, 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 |++++**Exactly one logical interaction:** the inbox claims `(consumer, event_id)` in the same SQLite++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.++++## API++++`/api/v1`, problem+json errors, `Idempotency-Key` on every POST/PATCH. 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?}` | `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 |++| `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) | public when the page is on; owner sees contributions |++| `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 | — |++++Capabilities are proposed in [docs/capabilities-proposal/](docs/capabilities-proposal/) (3-segment ids:++the charter's `tips.simulate` is `tips.simulation.run`; `tips.interaction.record` is new, for EXTERNAL++tips) with a service manifest in [docs/service-manifest-proposal.json](docs/service-manifest-proposal.json).++Until a contracts release carries them, grants are matched with contracts' `capabilities.grants()` (exact++id or a `.*` family), as Billing did before v0.8.0.++++## Events++++Produced through the openvibe-sdk outbox (table `event_outbox`, source `tips`, same transaction as the++change; relayed 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`. Simulations and Billing test money produce none. Consumed:++`billing.transaction.settled`, `billing.transaction.reversed` (only from source `billing`).++++## 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, `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). `live-chat` posts to Live's `/internal/tips/deliveries` with Tips' service token (audience++`openvibe.live`, `live.tips_delivery.write`), `Idempotency-Key = <interaction>:<effect>`; Live does what++`POST /api/funds/donate` does after the money moved (channel + global broadcast, a `donation` chat++message, the alert sound), TTS through `synthesizeAndBroadcastTTS`, media requests into its queue at no++charge. Live has no such route today: [docs/live-patch.diff](docs/live-patch.diff) adds it with a test++(applies to Live `seadragon` with `git apply`; its test passes in a scratch copy of Live). `test`++records jobs in memory (development; simulations always use it). `none` (the production default) creates++no chat effects. When OpenVibe.Chat publishes a paid-message capability, a `chat` adapter replaces++`live-chat`.++++## Pages (server-rendered, useful without JavaScript)++++`/` · `/<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` ·++`/receipts`, `/receipts/:id` (payment and delivery shown separately) · `/dashboard` (page settings,++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. Shared chrome: Network++`navbar.js`, `openvibe-shared` app icon, SSR footer and `<noscript>` navigation.++++## Import and reconciliation++++`scripts/import-live.js` reads a **copy** of Live's database (read-only) after Billing's own import, and++a copy of Billing's database for the links and totals:++++- `transactions` donations → interactions (`settlement imported`), linked to Billing by its importer key++ `import:live:txn:<live id>`; non-completed rows excluded with the reason; rows before++ `stats_vibes_reset_at` flagged test (as Billing flags them); unmapped creators **held**, released by a++ later run; Live refunds applied to the donation they undo++- PowerChat donations recorded only in `chat_messages` → EXTERNAL interactions; the celebration line of a++ site-routed `pcdon` tip is matched to its transaction and not counted twice++- `donation_goals` → goals, Live's `current_amount` carried over as `opening_amount`++- reconciliation: per creator, Tips' `settled_via_billing` must equal Billing's (donations to them minus++ refunds from them, non-test); mismatches are listed and the exit code is 1. Every source row ends up++ imported, excluded with a reason, or held (`migration_maps`).## Acceptance (must be true before "done")--- a duplicate provider webhook yields one Billing transaction and one logical interaction--- overlay replay never charges again--- the creator can use openvibe.tips with Live offline--- creator totals reconcile exactly to Billing----## Bootstrap / extraction source----Live's Vibes/PayPal tipping, cashout, alert/overlay and paid-interaction behaviour, migrated only after Billing reconciliation.++| 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); only the `live-chat` adapter talks to Live and its failure never touches payment (`test/delivery.test.js`) — **not yet demonstrated on the real host** |++| creator totals reconcile exactly to Billing | `test/import.test.js` (fixtures in Live's schema and Billing's importer keys; a Billing donation Tips never saw is reported, then reconciles once its event arrives); `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` |++++## What waits++++- **Billing cutover** (Billing README runbook): until Live's money writes go through Billing, real tips++ still happen on Live; Tips can only record them from Billing events once Live sends donations as++ Billing transfers. For PowerChat checkout, Billing answers with a `checkout_ref` and no URL — Tips needs++ `TIPS_POWERCHAT_LINK_TEMPLATE` or Billing returning the link.++- **EXTERNAL PowerChat tips** reach Billing's webhook, which records them with no effect and no event;++ until Billing emits one (or Live forwards them with `tips.interaction.record`), Tips does not see them.++- **Chat**: `live-chat` needs the Live patch deployed and the grant below; OpenVibe.Chat has no public++ paid-message/TTS capability yet. The Live patch's media-request path (yt-dlp/oEmbed) is not covered by++ its test.++- **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.## Launch ruleThis repository does not make the product real, and the domain keeps its placeholder page on--[OpenVibers/OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites) until all of the--following exist here (plan §12.12):----1. an owning runtime with health/readiness endpoints and observability;--2. canonical identity/auth integration (OpenVibe.Network subjects, scoped service principals);--3. server-rendered or static public routes that are useful without JavaScript;--4. real persistence and end-to-end workflows;--5. capability and event registration against `OpenVibe.Contracts`;--6. a migration/seed strategy, a security/threat review, and sitemap/robots/feed behaviour;--7. acceptance tests proving the advertised functionality.----The launch release removes the domain from `OpenVibe.Sites/sites.json`, switches routing and--registers maturity in the ecosystem registry atomically. A placeholder is never counted as an--implemented service.++[OpenVibers/OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites) until all of the following++exist here (plan §12.12):++++1. an owning runtime with health/readiness endpoints and observability — ✔ `/api/health`, `/api/ready`++ (DB + Network key), outbox status in health;++2. canonical identity/auth integration (Network subjects, scoped service principals) — ✔ in code;++ the `tips` principal and grants are not provisioned yet;++3. server-rendered public routes useful without JavaScript — ✔;++4. real persistence and end-to-end workflows — ✔ against stubs; not yet against the real Billing and++ Events;++5. capability and event registration against OpenVibe.Contracts — proposed, not released;++6. a migration/seed strategy ✔, a security/threat review (not done beyond the tests' refusals), and++ sitemap/robots ✔ (no feed);++7. acceptance tests proving the advertised functionality — ✔ (table above).++++The launch release removes the domain from `OpenVibe.Sites/sites.json`, switches routing and registers++maturity in the ecosystem registry atomically. A placeholder is never counted as an implemented service.---@@@ -1,7 +1,11 @@{"repository": "OpenVibers/OpenVibe.Tips",-- "stage": "placeholder",-- "code": false,++ "stage": "alpha",++ "code": true,++ "wave": "W9","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",-- "updated": "2026-09-21"++ "adr": "ADR-012",++ "authoritative": false,++ "notes": "Runtime, API, SSR pages, overlays, Billing settlement consumer, events outbox, Live import and reconciliation are built and tested against stubs. Not deployed; the openvibe.tips placeholder stays on OpenVibe.Sites until the launch rule in README.md holds. Live stays the place tips happen until the Billing cutover and the Live chat patch ship.",++ "updated": "2026-09-22"}nnew file mode 100644@@@ -0,0 +1,115 @@++## ═══════════════════════════════════════════════════════════════++## openvibe.tips — Nginx Configuration (REFERENCE)++##++## Deployed to /etc/nginx/sites-available/openvibe.tips.conf once the launch rule in README.md++## holds (until then the domain keeps its OpenVibe.Sites placeholder). The Node app (port 4610,++## unit openvibe-tips) renders every page, serves the API and the overlay streams. nginx terminates++## TLS, redirects www → apex, rate-limits auth and API, keeps overlay streams unbuffered, keeps++## overlay tokens out of the access log, and never exposes /internal/ (OpenVibe.Events calls++## 127.0.0.1:4610 directly).++## ═══════════════════════════════════════════════════════════════++++limit_req_zone $binary_remote_addr zone=ovtips_api:10m rate=10r/s;++limit_req_zone $binary_remote_addr zone=ovtips_auth:10m rate=10r/m;++limit_req_zone $binary_remote_addr zone=ovtips_forms:10m rate=2r/s;++++server {++ listen 80;++ listen [::]:80;++ server_name openvibe.tips www.openvibe.tips;++ location /.well-known/acme-challenge/ { root /var/www/certbot; }++ location / { return 301 https://openvibe.tips$request_uri; }++}++++server {++ listen 443 ssl;++ listen [::]:443 ssl;++ http2 on;++ server_name www.openvibe.tips;++ ssl_certificate /etc/letsencrypt/live/openvibe.tips/fullchain.pem;++ ssl_certificate_key /etc/letsencrypt/live/openvibe.tips/privkey.pem;++ ssl_protocols TLSv1.2 TLSv1.3;++ return 301 https://openvibe.tips$request_uri;++}++++server {++ listen 443 ssl;++ listen [::]:443 ssl;++ http2 on;++ server_name openvibe.tips;++++ ssl_certificate /etc/letsencrypt/live/openvibe.tips/fullchain.pem;++ ssl_certificate_key /etc/letsencrypt/live/openvibe.tips/privkey.pem;++ ssl_protocols TLSv1.2 TLSv1.3;++++ access_log /var/log/nginx/openvibe.tips.access.log;++ error_log /var/log/nginx/openvibe.tips.error.log;++ client_max_body_size 64k;++++ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;++++ gzip on;++ gzip_proxied any;++ gzip_vary on;++ gzip_types text/css application/javascript application/json image/svg+xml text/plain application/xml;++++ # Service-to-service only (Events → 127.0.0.1:4610/internal/events). Never public.++ location /internal/ { return 404; }++++ location /auth/ {++ limit_req zone=ovtips_auth burst=10 nodelay;++ proxy_pass http://127.0.0.1:4610;++ proxy_http_version 1.1;++ proxy_set_header Host $host;++ proxy_set_header X-Real-IP $remote_addr;++ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;++ proxy_set_header X-Forwarded-Proto $scheme;++ }++++ location /api/ {++ limit_req zone=ovtips_api burst=30 nodelay;++ proxy_pass http://127.0.0.1:4610;++ proxy_http_version 1.1;++ proxy_set_header Host $host;++ proxy_set_header X-Real-IP $remote_addr;++ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;++ proxy_set_header X-Forwarded-Proto $scheme;++ }++++ # Overlays: the token is in the path, so these requests are not logged. Streams are long-lived++ # server-sent events: no buffering, a long read timeout (the app sends a heartbeat every 25 s).++ location /overlay/ {++ access_log off;++ proxy_pass http://127.0.0.1:4610;++ proxy_http_version 1.1;++ proxy_set_header Connection '';++ proxy_set_header Host $host;++ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;++ proxy_set_header X-Forwarded-Proto $scheme;++ proxy_buffering off;++ proxy_cache off;++ proxy_read_timeout 1h;++ }++++ # Tip and dashboard form posts.++ location ~ ^/(dashboard/|[^/]+/tip$) {++ limit_req zone=ovtips_forms burst=10 nodelay;++ proxy_pass http://127.0.0.1:4610;++ proxy_http_version 1.1;++ proxy_set_header Host $host;++ proxy_set_header X-Real-IP $remote_addr;++ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;++ proxy_set_header X-Forwarded-Proto $scheme;++ }++++ location / {++ proxy_pass http://127.0.0.1:4610;++ proxy_http_version 1.1;++ proxy_set_header Host $host;++ proxy_set_header X-Real-IP $remote_addr;++ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;++ proxy_set_header X-Forwarded-Proto $scheme;++ }++++ location ~ /\.(?!well-known) { deny all; }++}nnew file mode 100644@@@ -0,0 +1,48 @@++[Unit]++Description=OpenVibe.Tips — creator support: tips, goals, paid messages, TTS and media requests, overlays++# Network signs the tokens Tips verifies (its key is fetched at boot and retried); Billing moves++# the money; Events carries Billing's settlements in and Tips' events out. Tips starts without any++# of them: pages and overlays keep working, payments wait.++After=network.target++Wants=openvibe-network.service openvibe-billing.service++++[Service]++Type=simple++User=ubuntu++Group=ubuntu++WorkingDirectory=/opt/openvibe.tips++EnvironmentFile=/etc/openvibe/tips.env++Environment=NODE_ENV=production++Environment=PORT=4610++Environment=HOST=127.0.0.1++Environment=PATH=/usr/local/bin:/usr/bin:/bin++ExecStart=/usr/bin/env node /opt/openvibe.tips/server/index.js++Restart=always++RestartSec=5++# Tips' own database (ADR-007: one database per service); the code tree stays read-only.++StateDirectory=openvibe-tips++StateDirectoryMode=0700++Environment=TIPS_DB_PATH=/var/lib/openvibe-tips/tips.db++UMask=0077++NoNewPrivileges=true++PrivateTmp=true++PrivateDevices=true++ProtectSystem=strict++ProtectHome=true++ProtectKernelTunables=true++ProtectKernelModules=true++ProtectKernelLogs=true++ProtectControlGroups=true++ProtectClock=true++ProtectHostname=true++RestrictSUIDSGID=true++RestrictRealtime=true++RestrictNamespaces=true++LockPersonality=true++SystemCallArchitectures=native++RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX++CapabilityBoundingSet=++AmbientCapabilities=++++[Install]++WantedBy=multi-user.targetnnew file mode 100644@@@ -0,0 +1,19 @@++{++ "id": "live.tips_delivery.write",++ "version": "1.0",++ "owner": "live",++ "status": "active",++ "visibility": "internal",++ "description": "OpenVibe.Tips' live-chat adapter: announce a settled tip in the creator's Live chat (donation line, paid message, TTS, media request at no charge). Owned by Live (docs/live-patch.diff adds the route); proposed here for Live's manifest.",++ "permissions": [++ "live:chat"++ ],++ "resourceConstraints": [++ "none"++ ],++ "quotaClass": "standard",++ "events": [],++ "implementedBy": [++ "POST /internal/tips/deliveries"++ ]++}nnew file mode 100644@@@ -0,0 +1,22 @@++{++ "id": "tips.checkout.create",++ "version": "1.0",++ "owner": "tips",++ "status": "active",++ "visibility": "first-party",++ "description": "Start a tip for a supporter (on_behalf SubjectRef): paid from the supporter's Billing credit (billing.transfer.create) or through a Billing checkout intent (billing.intent.create). Tips never prices money; Billing does. Idempotent by Idempotency-Key.",++ "permissions": [++ "tips:checkout"++ ],++ "resourceConstraints": [++ "owner"++ ],++ "quotaClass": "payments",++ "events": [++ "tips.interaction.ready",++ "tips.interaction.cancelled"++ ],++ "implementedBy": [++ "POST /api/v1/checkout"++ ]++}nnew file mode 100644@@@ -0,0 +1,21 @@++{++ "id": "tips.goal.close",++ "version": "1.0",++ "owner": "tips",++ "status": "active",++ "visibility": "first-party",++ "description": "Close a goal; its history stays.",++ "permissions": [++ "tips:goals"++ ],++ "resourceConstraints": [++ "owner"++ ],++ "quotaClass": "standard",++ "events": [++ "tips.goal.updated"++ ],++ "implementedBy": [++ "POST /api/v1/goals/:id/close"++ ]++}nnew file mode 100644@@@ -0,0 +1,21 @@++{++ "id": "tips.goal.create",++ "version": "1.0",++ "owner": "tips",++ "status": "active",++ "visibility": "first-party",++ "description": "Create a goal for a creator. Goal totals are derived from settled interactions only.",++ "permissions": [++ "tips:goals"++ ],++ "resourceConstraints": [++ "owner"++ ],++ "quotaClass": "standard",++ "events": [++ "tips.goal.updated"++ ],++ "implementedBy": [++ "POST /api/v1/goals"++ ]++}nnew file mode 100644@@@ -0,0 +1,22 @@++{++ "id": "tips.goal.update",++ "version": "1.0",++ "owner": "tips",++ "status": "active",++ "visibility": "first-party",++ "description": "Edit a goal (title, target, description, image, order) with optimistic revision checks; read private goals with contributions.",++ "permissions": [++ "tips:goals"++ ],++ "resourceConstraints": [++ "owner"++ ],++ "quotaClass": "standard",++ "events": [++ "tips.goal.updated"++ ],++ "implementedBy": [++ "PATCH /api/v1/goals/:id",++ "GET /api/v1/goals/:id"++ ]++}nnew file mode 100644@@@ -0,0 +1,19 @@++{++ "id": "tips.interaction.get",++ "version": "1.0",++ "owner": "tips",++ "status": "active",++ "visibility": "first-party",++ "description": "Read one interaction with its separate payment state (from Billing) and delivery state (effects).",++ "permissions": [++ "tips:interactions"++ ],++ "resourceConstraints": [++ "owner"++ ],++ "quotaClass": "standard",++ "events": [],++ "implementedBy": [++ "GET /api/v1/interactions/:id"++ ]++}nnew file mode 100644@@@ -0,0 +1,20 @@++{++ "id": "tips.interaction.list",++ "version": "1.0",++ "owner": "tips",++ "status": "active",++ "visibility": "first-party",++ "description": "List a creator's or a supporter's interactions and a creator's derived totals.",++ "permissions": [++ "tips:interactions"++ ],++ "resourceConstraints": [++ "owner"++ ],++ "quotaClass": "standard",++ "events": [],++ "implementedBy": [++ "GET /api/v1/interactions",++ "GET /api/v1/profiles/:creator/totals"++ ]++}nnew file mode 100644@@@ -0,0 +1,21 @@++{++ "id": "tips.interaction.record",++ "version": "1.0",++ "owner": "tips",++ "status": "active",++ "visibility": "internal",++ "description": "Record a tip that never touched OpenVibe money (ADR-012 EXTERNAL: a tip on a creator's own PowerChat) once per (provider, provider_ref); no Billing liability, excluded from Billing reconciliation. Not in the charter list: proposed because EXTERNAL tips need a recording path while Billing emits nothing for them.",++ "permissions": [++ "tips:interactions"++ ],++ "resourceConstraints": [++ "none"++ ],++ "quotaClass": "standard",++ "events": [++ "tips.interaction.ready"++ ],++ "implementedBy": [++ "POST /api/v1/interactions/external"++ ]++}nnew file mode 100644@@@ -0,0 +1,21 @@++{++ "id": "tips.media_request.create",++ "version": "1.0",++ "owner": "tips",++ "status": "active",++ "visibility": "first-party",++ "description": "Start a paid media request for a supporter (allowed hosts, the creator's minimum and length); queued with the media-queue owner only after settlement.",++ "permissions": [++ "tips:checkout"++ ],++ "resourceConstraints": [++ "owner"++ ],++ "quotaClass": "payments",++ "events": [++ "tips.interaction.ready"++ ],++ "implementedBy": [++ "POST /api/v1/media-requests"++ ]++}nnew file mode 100644@@@ -0,0 +1,20 @@++{++ "id": "tips.overlay.config.get",++ "version": "1.0",++ "owner": "tips",++ "status": "active",++ "visibility": "first-party",++ "description": "Read a creator's overlay configs (alert and goal display settings).",++ "permissions": [++ "tips:overlays"++ ],++ "resourceConstraints": [++ "owner"++ ],++ "quotaClass": "standard",++ "events": [],++ "implementedBy": [++ "GET /api/v1/overlay-configs",++ "GET /api/v1/overlay-configs/:id"++ ]++}nnew file mode 100644@@@ -0,0 +1,20 @@++{++ "id": "tips.overlay.config.update",++ "version": "1.0",++ "owner": "tips",++ "status": "active",++ "visibility": "first-party",++ "description": "Create or change a creator's overlay configs; open overlays receive the change at once.",++ "permissions": [++ "tips:overlays"++ ],++ "resourceConstraints": [++ "owner"++ ],++ "quotaClass": "standard",++ "events": [],++ "implementedBy": [++ "POST /api/v1/overlay-configs",++ "PATCH /api/v1/overlay-configs/:id"++ ]++}nnew file mode 100644@@@ -0,0 +1,20 @@++{++ "id": "tips.overlay.token.create",++ "version": "1.0",++ "owner": "tips",++ "status": "active",++ "visibility": "first-party",++ "description": "Mint a scoped overlay token (alerts, goals) for a creator — shown once, stored hashed, never a creator cookie — and list a creator's tokens without secrets.",++ "permissions": [++ "tips:overlays"++ ],++ "resourceConstraints": [++ "owner"++ ],++ "quotaClass": "standard",++ "events": [],++ "implementedBy": [++ "POST /api/v1/overlay-tokens",++ "GET /api/v1/overlay-tokens"++ ]++}nnew file mode 100644@@@ -0,0 +1,19 @@++{++ "id": "tips.overlay.token.revoke",++ "version": "1.0",++ "owner": "tips",++ "status": "active",++ "visibility": "first-party",++ "description": "Revoke an overlay token; open overlay streams using it are closed immediately.",++ "permissions": [++ "tips:overlays"++ ],++ "resourceConstraints": [++ "owner"++ ],++ "quotaClass": "standard",++ "events": [],++ "implementedBy": [++ "POST /api/v1/overlay-tokens/:id/revoke"++ ]++}nnew file mode 100644@@@ -0,0 +1,19 @@++{++ "id": "tips.profile.get",++ "version": "1.0",++ "owner": "tips",++ "status": "active",++ "visibility": "first-party",++ "description": "Read a creator's tip profile including a page that is switched off (public pages are readable without a token).",++ "permissions": [++ "tips:profile"++ ],++ "resourceConstraints": [++ "owner"++ ],++ "quotaClass": "standard",++ "events": [],++ "implementedBy": [++ "GET /api/v1/profiles/:creator"++ ]++}nnew file mode 100644@@@ -0,0 +1,19 @@++{++ "id": "tips.profile.update",++ "version": "1.0",++ "owner": "tips",++ "status": "active",++ "visibility": "first-party",++ "description": "Create or update a creator's tip profile on their behalf: page switch (indexing), accepting, minimums, TTS and media-request settings. Creators do this themselves with their user token.",++ "permissions": [++ "tips:profile"++ ],++ "resourceConstraints": [++ "owner"++ ],++ "quotaClass": "standard",++ "events": [],++ "implementedBy": [++ "PATCH /api/v1/profiles/:creator"++ ]++}nnew file mode 100644@@@ -0,0 +1,19 @@++{++ "id": "tips.simulation.run",++ "version": "1.0",++ "owner": "tips",++ "status": "active",++ "visibility": "first-party",++ "description": "Run the full effect path (overlay alert, goal widget, delivery) flagged test, with no Billing call, never counted in any total and never emitted as a durable event. The charter's 'tips.simulate' as a 3-segment id.",++ "permissions": [++ "tips:simulate"++ ],++ "resourceConstraints": [++ "owner"++ ],++ "quotaClass": "standard",++ "events": [],++ "implementedBy": [++ "POST /api/v1/simulate"++ ]++}nnew file mode 100644@@@ -0,0 +1,21 @@++{++ "id": "tips.superchat.create",++ "version": "1.0",++ "owner": "tips",++ "status": "active",++ "visibility": "first-party",++ "description": "Start a paid (highlighted) chat message for a supporter; the paid_messages row exists only after Billing settles it.",++ "permissions": [++ "tips:checkout"++ ],++ "resourceConstraints": [++ "owner"++ ],++ "quotaClass": "payments",++ "events": [++ "tips.interaction.ready"++ ],++ "implementedBy": [++ "POST /api/v1/paid-messages"++ ]++}nnew file mode 100644@@@ -0,0 +1,21 @@++{++ "id": "tips.tts.request",++ "version": "1.0",++ "owner": "tips",++ "status": "active",++ "visibility": "first-party",++ "description": "Start a paid text-to-speech request for a supporter, within the creator's TTS settings; delivered to chat only after settlement.",++ "permissions": [++ "tips:checkout"++ ],++ "resourceConstraints": [++ "owner"++ ],++ "quotaClass": "payments",++ "events": [++ "tips.interaction.ready"++ ],++ "implementedBy": [++ "POST /api/v1/tts-requests"++ ]++}nnew file mode 100644@@@ -0,0 +1,232 @@++diff --git a/server/index.js b/server/index.js++index 4bba504..63c0969 100644++--- a/server/index.js++@@ -564,6 +564,8 @@ app.get('/internal/analytics-summary', (req, res) => {++ const chatLiveContext = require('./chat/live-context-routes');++ app.use('/internal/chat-context', chatLiveContext.contextRouter);++ app.use('/internal/chat-effects', chatLiveContext.effectsRouter);+++// OpenVibe.Tips announces settled tips in the creator's chat here (service token, live.tips_delivery.write).+++app.use('/internal/tips', require('./tips/delivery-routes'));++ app.use('/internal', require('./internal/routes'));++++++diff --git a/server/tips/delivery-routes.js b/server/tips/delivery-routes.js++new file mode 100644++index 0000000..ff08b97++--- /dev/null++@@ -0,0 +1,108 @@+++'use strict';+++/**+++ * OpenVibe.Tips → Live: announce a settled tip in the creator's Live chat (Tips' live-chat+++ * delivery adapter, roadmap Wave 9). The money already moved in OpenVibe.Billing; this route only+++ * does what the tail of POST /api/funds/donate does after the balance changed.+++ *+++ * POST /internal/tips/deliveries capability live.tips_delivery.write (Network service token,+++ * audience openvibe.live; loopback-only like every /internal route)+++ * Idempotency-Key: <interaction id>:<effect> a repeat answers the first result and does nothing+++ * body { delivery_id, effect, test, creator: { type, id: usr_… }, supporter: { name, subject? },+++ * interaction: { id, kind, amount, currency, message }, text, tts?: { text, voice },+++ * media?: { url }, highlight_seconds?, target?: { service: 'live', type: 'stream', id } }+++ * → 200 { ok: true, ref } 404 creator has no Live channel (permanent) 409 not live (retried)+++ *+++ * chat_line | paid_message donation event to the channel room + global, saved as a 'donation'+++ * chat message (metadata.source 'tips'), the donation alert sound+++ * tts synthesizeAndBroadcastTTS on the live stream (or the offline channel room)+++ * media_request into the media queue at cost 0 — Billing charged it, Tips holds the record+++ */+++const express = require('express');+++const db = require('../db/database');+++const { guard } = require('../net/service-guard');++++++const router = express.Router();+++const done = new Map(); // Idempotency-Key → response (Tips retries within minutes; bounded)+++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++++++function channelUserId(subject) {+++ if (!SUBJECT_RE.test(String(subject || ''))) return null;+++ const row = db.getDb().prepare("SELECT user_id FROM linked_accounts WHERE service = 'network' AND subject_id = ? ORDER BY id DESC LIMIT 1").get(subject);+++ return row ? row.user_id : null;+++}++++++function streamIdFor(target, userId) {+++ if (target && target.service === 'live' && target.type === 'stream') {+++ const s = db.getStreamById(Number(target.id));+++ if (s && s.user_id === userId && s.is_live) return s.id;+++ }+++ const live = db.getStreamByUserId(userId);+++ return live ? live.id : null;+++}++++++function remember(key, out) {+++ done.set(key, out);+++ if (done.size > 5000) done.delete(done.keys().next().value);+++ return out;+++}++++++router.post('/deliveries', guard('live.tips_delivery.write'), express.json({ limit: '32kb' }), async (req, res) => {+++ const key = String(req.get('idempotency-key') || '');+++ if (!/^[A-Za-z0-9_:.-]{8,200}$/.test(key)) return res.status(400).json({ error: 'Idempotency-Key required' });+++ if (done.has(key)) return res.json(done.get(key));+++ const b = req.body || {};+++ const i = b.interaction || {};+++ const userId = channelUserId(b.creator && b.creator.id);+++ if (!userId) return res.status(404).json({ error: 'this creator has no Live channel' });+++ const chatServer = require('../chat/chat-server');+++ const streamId = streamIdFor(b.target, userId);+++ const name = String((b.supporter && b.supporter.name) || 'Someone').slice(0, 80);+++ const amount = Math.max(0, Math.round(Number(i.amount) || 0));+++ const message = String(i.message || '').slice(0, 500);+++ try {+++ if (b.effect === 'chat_line' || b.effect === 'paid_message') {+++ const event = {+++ type: 'donation', username: name, user_id: null, avatar_url: null, amount, message, timestamp: new Date().toISOString(),+++ source: 'tips', paid_message: b.effect === 'paid_message', highlight_seconds: Number(b.highlight_seconds) || 0,+++ };+++ chatServer.broadcastToChannelRoom(userId, streamId, event);+++ try { chatServer.broadcastGlobal({ ...event, global: true, channel_user_id: userId }); } catch { /* non-critical */ }+++ const saved = db.saveChatMessage({+++ stream_id: streamId, channel_user_id: userId, user_id: null, username: name,+++ message: String(b.text || `${name} tipped ${amount.toLocaleString()} Vibes${message ? ': ' + message : ''}`).slice(0, 1000),+++ message_type: 'donation',+++ metadata: { kind: 'donation', amount, message, username: name, source: 'tips', interaction_id: i.id || null, paid_message: event.paid_message, highlight_seconds: event.highlight_seconds, test: !!b.test },+++ });+++ require('../monetization/alerts').playAlertSound(chatServer, userId, streamId, 'donation');+++ return res.json(remember(key, { ok: true, ref: { chat_message_id: saved && saved.lastInsertRowid != null ? Number(saved.lastInsertRowid) : null } }));+++ }+++ if (b.effect === 'tts') {+++ const text = String((b.tts && b.tts.text) || '').slice(0, 1200);+++ if (!text) return res.status(422).json({ error: 'no text' });+++ await chatServer.synthesizeAndBroadcastTTS(streamId, name, text, null, 'tips', `tips:${i.id}`, userId, `tips-${i.id}`);+++ return res.json(remember(key, { ok: true, ref: { stream_id: streamId } }));+++ }+++ if (b.effect === 'media_request') {+++ const mq = require('../media/media-queue');+++ const settings = mq.getSettings(userId);+++ const normalized = await mq.normalizeInput(String((b.media && b.media.url) || ''), settings);+++ const max = Number(settings.max_duration_seconds) || 600;+++ if (Number.isFinite(normalized.duration_seconds) && normalized.duration_seconds > max) return res.status(422).json({ error: `longer than ${max} seconds` });+++ const r = db.createMediaRequest({+++ streamer_id: userId, stream_id: streamId, user_id: null, username: name, input: String(b.media.url),+++ canonical_url: normalized.canonical_url, embed_url: normalized.embed_url, provider: normalized.provider, title: normalized.title,+++ thumbnail_url: normalized.thumbnail_url, duration_seconds: normalized.duration_seconds,+++ // Paid through OpenVibe.Billing and recorded by OpenVibe.Tips: Live charges nothing here.+++ cost: 0, currency: 'free', queue_position: db.getMediaRequestMaxQueuePosition(userId) + 1,+++ });+++ mq.broadcastQueueUpdate(userId);+++ return res.json(remember(key, { ok: true, ref: { media_request_id: Number(r.lastInsertRowid) } }));+++ }+++ return res.status(422).json({ error: `unknown effect ${b.effect}` });+++ } catch (err) {+++ console.warn('[Tips delivery]', b.effect, err.message);+++ return res.status(422).json({ error: err.message });+++ }+++});++++++module.exports = router;++diff --git a/test/tips-delivery.test.js b/test/tips-delivery.test.js++new file mode 100644++index 0000000..2c3730a++--- /dev/null++@@ -0,0 +1,99 @@+++'use strict';++++++// OpenVibe.Tips → Live (roadmap Wave 9): POST /internal/tips/deliveries announces a settled tip in the+++// creator's chat — service token with live.tips_delivery.write, idempotent per delivery id, the+++// creator found by Network subject, no Live balance touched.++++++const assert = require('assert');+++const fs = require('fs');+++const os = require('os');+++const path = require('path');+++const http = require('http');+++const crypto = require('crypto');+++const express = require('express');+++const { serviceAuth } = require('openvibe-contracts');++++++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-tips-delivery-'));+++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });+++fs.writeFileSync(path.join(tmp, 'network.pem'), keys.publicKey);+++fs.mkdirSync(path.join(tmp, 'sounds'));+++process.env.DB_PATH = path.join(tmp, 'live.db');+++process.env.OV_NETWORK_PUBLIC_KEY = path.join(tmp, 'network.pem');+++process.env.OV_NETWORK_URL = 'https://openvibe.network';+++process.env.SOUNDS_PATH = path.join(tmp, 'sounds');+++console.log = () => {};+++console.warn = () => {};++++++const ISS = 'https://openvibe.network';+++const SUBJECT = 'usr_01J0000000000000000000000Z';+++const now = () => Math.floor(Date.now() / 1000);+++let jti = 0;+++const token = (cap, aud = 'openvibe.live') => serviceAuth.signServiceToken({ iss: ISS, sub: 'svc:tips', actor_type: 'service', aud: [aud], cap, iat: now(), exp: now() + 300, jti: `tok_tips_${++jti}` }, keys.privateKey);++++++(async () => {+++ const db = require('../server/db/database');+++ db.initDb();+++ const d = db.getDb();+++ d.prepare("INSERT INTO users (id, username, display_name, password_hash, openvibe_bucks_balance, openvibe_bucks_cashout_balance) VALUES (501, 'alex', 'Alex', 'x', 0, 0)").run();+++ d.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (501, 'network', '77', ?)").run(SUBJECT);++++++ const chatServer = require('../server/chat/chat-server');+++ const broadcasts = [];+++ chatServer.broadcastToChannelRoom = (uid, sid, ev) => broadcasts.push({ uid, sid, ev });+++ chatServer.broadcastGlobal = () => {};+++ const tts = [];+++ chatServer.synthesizeAndBroadcastTTS = async (...a) => { tts.push(a); };++++++ const app = express();+++ app.use('/internal/tips', require('../server/tips/delivery-routes'));+++ const server = await new Promise((r) => { const s = http.createServer(app); s.listen(0, '127.0.0.1', () => r(s)); });+++ const base = `http://127.0.0.1:${server.address().port}`;+++ const post = (body, { cap = ['live.tips_delivery.write'], key = 'tint_1:chat_line', headers = {} } = {}) => fetch(`${base}/internal/tips/deliveries`, {+++ method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token(cap)}`, 'Idempotency-Key': key, ...headers }, body: JSON.stringify(body),+++ }).then(async (r) => ({ status: r.status, json: await r.json().catch(() => null) }));++++++ const job = {+++ delivery_id: 'tint_1:chat_line', effect: 'chat_line', test: false, creator: { type: 'user', id: SUBJECT },+++ supporter: { name: 'Viewer' }, interaction: { id: 'tint_1', kind: 'tip', amount: 250, currency: 'vibes-bits', message: 'gg' },+++ text: 'Viewer tipped 250 Vibes: gg',+++ };++++++ assert.strictEqual((await post(job, { cap: ['live.chat_effects.write'] })).status, 403, 'needs live.tips_delivery.write');+++ assert.strictEqual((await post(job, { headers: { 'X-Forwarded-For': '1.2.3.4' } })).status, 403, 'loopback only');++++++ const a = await post(job);+++ assert.strictEqual(a.status, 200);+++ assert.ok(a.json.ref.chat_message_id > 0);+++ assert.strictEqual(broadcasts.length, 1);+++ assert.strictEqual(broadcasts[0].uid, 501);+++ assert.strictEqual(broadcasts[0].ev.type, 'donation');+++ assert.strictEqual(broadcasts[0].ev.amount, 250);+++ const saved = d.prepare("SELECT * FROM chat_messages WHERE message_type = 'donation'").get();+++ assert.strictEqual(saved.message, 'Viewer tipped 250 Vibes: gg');+++ assert.strictEqual(JSON.parse(saved.metadata).source, 'tips');++++++ // A retry of the same delivery changes nothing.+++ const b = await post(job);+++ assert.deepStrictEqual(b.json, a.json);+++ assert.strictEqual(broadcasts.length, 1);+++ assert.strictEqual(d.prepare("SELECT COUNT(*) AS n FROM chat_messages WHERE message_type = 'donation'").get().n, 1);++++++ // TTS on the (offline) channel room.+++ const t = await post({ ...job, effect: 'tts', tts: { text: 'read me', voice: 'gary' } }, { key: 'tint_1:tts' });+++ assert.strictEqual(t.status, 200);+++ assert.strictEqual(tts[0][2], 'read me');+++ assert.strictEqual(tts[0][6], 501);++++++ // Unknown creator: permanent refusal.+++ const u = await post({ ...job, creator: { type: 'user', id: 'usr_01J0000000000000000000000Y' } }, { key: 'tint_2:chat_line' });+++ assert.strictEqual(u.status, 404);++++++ // No Live balance moved.+++ const user = d.prepare('SELECT openvibe_bucks_balance, openvibe_bucks_cashout_balance FROM users WHERE id = 501').get();+++ assert.deepStrictEqual(user, { openvibe_bucks_balance: 0, openvibe_bucks_cashout_balance: 0 });+++ assert.strictEqual(d.prepare("SELECT COUNT(*) AS n FROM transactions").get().n, 0);++++++ server.close();+++ process.stdout.write('tips-delivery: all passed\n');+++ process.exit(0);+++})().catch((e) => { process.stderr.write(`${e.stack}\n`); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,51 @@++{++ "version": "0.2.0",++ "contractRanges": {++ "openvibe-contracts": ">=0.11.0 <1.0.0"++ },++ "id": "tips",++ "name": "OpenVibe.Tips",++ "status": "alpha",++ "repository": "OpenVibers/OpenVibe.Tips",++ "domains": [++ "openvibe.tips"++ ],++ "publicOrigin": "https://openvibe.tips",++ "health": "/api/health",++ "ready": "/api/ready",++ "capabilities": [++ "tips.profile.get",++ "tips.profile.update",++ "tips.checkout.create",++ "tips.superchat.create",++ "tips.tts.request",++ "tips.media_request.create",++ "tips.interaction.get",++ "tips.interaction.list",++ "tips.interaction.record",++ "tips.goal.create",++ "tips.goal.update",++ "tips.goal.close",++ "tips.overlay.token.create",++ "tips.overlay.token.revoke",++ "tips.overlay.config.get",++ "tips.overlay.config.update",++ "tips.simulation.run"++ ],++ "eventsProduced": [++ "tips.interaction.ready",++ "tips.interaction.failed",++ "tips.interaction.cancelled",++ "tips.goal.updated",++ "tips.overlay.delivered",++ "tips.overlay.failed"++ ],++ "eventsConsumed": [++ "billing.transaction.settled",++ "billing.transaction.reversed"++ ],++ "namespacesOwned": [++ "tips.*"++ ],++ "notes": "Creator support (roadmap Wave 9): tip interactions whose payment state mirrors OpenVibe.Billing (keyed by Billing transaction id) and whose delivery state is Tips' own; goals derived from settled interactions; paid messages, TTS and media requests created on settlement; scoped revocable overlay tokens with SSE overlays; simulation mode. Tips never moves money (ADR-012). Alpha: the placeholder on OpenVibe.Sites stays until the launch rule in README.md holds. Proposed manifest; replaces the placeholder in manifests/services/tips.json."++}nnew file mode 100644@@@ -0,0 +1,1501 @@++{++ "name": "openvibe-tips",++ "version": "0.1.0",++ "lockfileVersion": 3,++ "requires": true,++ "packages": {++ "": {++ "name": "openvibe-tips",++ "version": "0.1.0",++ "license": "AGPL-3.0-only",++ "dependencies": {++ "better-sqlite3": "^11.10.0",++ "cookie-parser": "^1.4.7",++ "dotenv": "^16.4.7",++ "express": "^4.21.2",++ "jsonwebtoken": "^9.0.2",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.11.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.2.1"++ },++ "engines": {++ "node": ">=22.0.0"++ }++ },++ "node_modules/accepts": {++ "version": "1.3.8",++ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",++ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",++ "license": "MIT",++ "dependencies": {++ "mime-types": "~2.1.34",++ "negotiator": "0.6.3"++ },++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/ajv": {++ "version": "8.20.0",++ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",++ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",++ "license": "MIT",++ "dependencies": {++ "fast-deep-equal": "^3.1.3",++ "fast-uri": "^3.0.1",++ "json-schema-traverse": "^1.0.0",++ "require-from-string": "^2.0.2"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/sponsors/epoberezkin"++ }++ },++ "node_modules/ajv-formats": {++ "version": "3.0.1",++ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",++ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",++ "license": "MIT",++ "dependencies": {++ "ajv": "^8.0.0"++ },++ "peerDependencies": {++ "ajv": "^8.0.0"++ },++ "peerDependenciesMeta": {++ "ajv": {++ "optional": true++ }++ }++ },++ "node_modules/array-flatten": {++ "version": "1.1.1",++ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",++ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",++ "license": "MIT"++ },++ "node_modules/base64-js": {++ "version": "1.5.1",++ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",++ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/feross"++ },++ {++ "type": "patreon",++ "url": "https://www.patreon.com/feross"++ },++ {++ "type": "consulting",++ "url": "https://feross.org/support"++ }++ ],++ "license": "MIT"++ },++ "node_modules/better-sqlite3": {++ "version": "11.10.0",++ "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",++ "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",++ "hasInstallScript": true,++ "license": "MIT",++ "dependencies": {++ "bindings": "^1.5.0",++ "prebuild-install": "^7.1.1"++ }++ },++ "node_modules/bindings": {++ "version": "1.5.0",++ "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",++ "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",++ "license": "MIT",++ "dependencies": {++ "file-uri-to-path": "1.0.0"++ }++ },++ "node_modules/bl": {++ "version": "4.1.0",++ "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",++ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",++ "license": "MIT",++ "dependencies": {++ "buffer": "^5.5.0",++ "inherits": "^2.0.4",++ "readable-stream": "^3.4.0"++ }++ },++ "node_modules/body-parser": {++ "version": "1.20.8",++ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",++ "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==",++ "license": "MIT",++ "dependencies": {++ "bytes": "~3.1.2",++ "content-type": "~1.0.5",++ "debug": "2.6.9",++ "depd": "2.0.0",++ "destroy": "~1.2.0",++ "http-errors": "~2.0.1",++ "iconv-lite": "~0.4.24",++ "on-finished": "~2.4.1",++ "qs": "~6.16.0",++ "raw-body": "~2.5.3",++ "type-is": "~1.6.18",++ "unpipe": "~1.0.0"++ },++ "engines": {++ "node": ">= 0.8",++ "npm": "1.2.8000 || >= 1.4.16"++ }++ },++ "node_modules/buffer": {++ "version": "5.7.1",++ "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",++ "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/feross"++ },++ {++ "type": "patreon",++ "url": "https://www.patreon.com/feross"++ },++ {++ "type": "consulting",++ "url": "https://feross.org/support"++ }++ ],++ "license": "MIT",++ "dependencies": {++ "base64-js": "^1.3.1",++ "ieee754": "^1.1.13"++ }++ },++ "node_modules/buffer-equal-constant-time": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",++ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",++ "license": "BSD-3-Clause"++ },++ "node_modules/bytes": {++ "version": "3.1.2",++ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",++ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/call-bind-apply-helpers": {++ "version": "1.0.2",++ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",++ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",++ "license": "MIT",++ "dependencies": {++ "es-errors": "^1.3.0",++ "function-bind": "^1.1.2"++ },++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/call-bound": {++ "version": "1.0.4",++ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",++ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",++ "license": "MIT",++ "dependencies": {++ "call-bind-apply-helpers": "^1.0.2",++ "get-intrinsic": "^1.3.0"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/chownr": {++ "version": "1.1.4",++ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",++ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",++ "license": "ISC"++ },++ "node_modules/content-disposition": {++ "version": "0.5.4",++ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",++ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",++ "license": "MIT",++ "dependencies": {++ "safe-buffer": "5.2.1"++ },++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/content-type": {++ "version": "1.0.5",++ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",++ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/cookie": {++ "version": "0.7.2",++ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",++ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/cookie-parser": {++ "version": "1.4.7",++ "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",++ "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",++ "license": "MIT",++ "dependencies": {++ "cookie": "0.7.2",++ "cookie-signature": "1.0.6"++ },++ "engines": {++ "node": ">= 0.8.0"++ }++ },++ "node_modules/cookie-signature": {++ "version": "1.0.6",++ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",++ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",++ "license": "MIT"++ },++ "node_modules/debug": {++ "version": "2.6.9",++ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",++ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",++ "license": "MIT",++ "dependencies": {++ "ms": "2.0.0"++ }++ },++ "node_modules/decompress-response": {++ "version": "6.0.0",++ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",++ "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",++ "license": "MIT",++ "dependencies": {++ "mimic-response": "^3.1.0"++ },++ "engines": {++ "node": ">=10"++ },++ "funding": {++ "url": "https://github.com/sponsors/sindresorhus"++ }++ },++ "node_modules/deep-extend": {++ "version": "0.6.0",++ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",++ "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",++ "license": "MIT",++ "engines": {++ "node": ">=4.0.0"++ }++ },++ "node_modules/depd": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",++ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/destroy": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",++ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8",++ "npm": "1.2.8000 || >= 1.4.16"++ }++ },++ "node_modules/detect-libc": {++ "version": "2.1.2",++ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",++ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",++ "license": "Apache-2.0",++ "engines": {++ "node": ">=8"++ }++ },++ "node_modules/dotenv": {++ "version": "16.6.1",++ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",++ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",++ "license": "BSD-2-Clause",++ "engines": {++ "node": ">=12"++ },++ "funding": {++ "url": "https://dotenvx.com"++ }++ },++ "node_modules/dunder-proto": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",++ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",++ "license": "MIT",++ "dependencies": {++ "call-bind-apply-helpers": "^1.0.1",++ "es-errors": "^1.3.0",++ "gopd": "^1.2.0"++ },++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/ecdsa-sig-formatter": {++ "version": "1.0.11",++ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",++ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",++ "license": "Apache-2.0",++ "dependencies": {++ "safe-buffer": "^5.0.1"++ }++ },++ "node_modules/ee-first": {++ "version": "1.1.1",++ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",++ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",++ "license": "MIT"++ },++ "node_modules/encodeurl": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",++ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/end-of-stream": {++ "version": "1.4.5",++ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",++ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",++ "license": "MIT",++ "dependencies": {++ "once": "^1.4.0"++ }++ },++ "node_modules/es-define-property": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",++ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/es-errors": {++ "version": "1.3.0",++ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",++ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/es-object-atoms": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",++ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",++ "license": "MIT",++ "dependencies": {++ "es-errors": "^1.3.0"++ },++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/escape-html": {++ "version": "1.0.3",++ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",++ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",++ "license": "MIT"++ },++ "node_modules/etag": {++ "version": "1.8.1",++ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",++ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/expand-template": {++ "version": "2.0.3",++ "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",++ "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",++ "license": "(MIT OR WTFPL)",++ "engines": {++ "node": ">=6"++ }++ },++ "node_modules/express": {++ "version": "4.22.3",++ "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",++ "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==",++ "license": "MIT",++ "dependencies": {++ "accepts": "~1.3.8",++ "array-flatten": "1.1.1",++ "body-parser": "~1.20.5",++ "content-disposition": "~0.5.4",++ "content-type": "~1.0.4",++ "cookie": "~0.7.1",++ "cookie-signature": "~1.0.6",++ "debug": "2.6.9",++ "depd": "2.0.0",++ "encodeurl": "~2.0.0",++ "escape-html": "~1.0.3",++ "etag": "~1.8.1",++ "finalhandler": "~1.3.1",++ "fresh": "~0.5.2",++ "http-errors": "~2.0.0",++ "merge-descriptors": "1.0.3",++ "methods": "~1.1.2",++ "on-finished": "~2.4.1",++ "parseurl": "~1.3.3",++ "path-to-regexp": "~0.1.13",++ "proxy-addr": "~2.0.7",++ "qs": "~6.16.0",++ "range-parser": "~1.2.1",++ "safe-buffer": "5.2.1",++ "send": "~0.19.0",++ "serve-static": "~1.16.2",++ "setprototypeof": "1.2.0",++ "statuses": "~2.0.1",++ "type-is": "~1.6.18",++ "utils-merge": "1.0.1",++ "vary": "~1.1.2"++ },++ "engines": {++ "node": ">= 0.10.0"++ },++ "funding": {++ "type": "opencollective",++ "url": "https://opencollective.com/express"++ }++ },++ "node_modules/fast-deep-equal": {++ "version": "3.1.3",++ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",++ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",++ "license": "MIT"++ },++ "node_modules/fast-uri": {++ "version": "3.1.8",++ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",++ "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/fastify"++ },++ {++ "type": "opencollective",++ "url": "https://opencollective.com/fastify"++ }++ ],++ "license": "BSD-3-Clause"++ },++ "node_modules/file-uri-to-path": {++ "version": "1.0.0",++ "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",++ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",++ "license": "MIT"++ },++ "node_modules/finalhandler": {++ "version": "1.3.2",++ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",++ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",++ "license": "MIT",++ "dependencies": {++ "debug": "2.6.9",++ "encodeurl": "~2.0.0",++ "escape-html": "~1.0.3",++ "on-finished": "~2.4.1",++ "parseurl": "~1.3.3",++ "statuses": "~2.0.2",++ "unpipe": "~1.0.0"++ },++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/forwarded": {++ "version": "0.2.0",++ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",++ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/fresh": {++ "version": "0.5.2",++ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",++ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/fs-constants": {++ "version": "1.0.0",++ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",++ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",++ "license": "MIT"++ },++ "node_modules/function-bind": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",++ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",++ "license": "MIT",++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/get-intrinsic": {++ "version": "1.3.0",++ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",++ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",++ "license": "MIT",++ "dependencies": {++ "call-bind-apply-helpers": "^1.0.2",++ "es-define-property": "^1.0.1",++ "es-errors": "^1.3.0",++ "es-object-atoms": "^1.1.1",++ "function-bind": "^1.1.2",++ "get-proto": "^1.0.1",++ "gopd": "^1.2.0",++ "has-symbols": "^1.1.0",++ "hasown": "^2.0.2",++ "math-intrinsics": "^1.1.0"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/get-proto": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",++ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",++ "license": "MIT",++ "dependencies": {++ "dunder-proto": "^1.0.1",++ "es-object-atoms": "^1.0.0"++ },++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/github-from-package": {++ "version": "0.0.0",++ "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",++ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",++ "license": "MIT"++ },++ "node_modules/gopd": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",++ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/has-symbols": {++ "version": "1.1.0",++ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",++ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/hasown": {++ "version": "2.0.4",++ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",++ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",++ "license": "MIT",++ "dependencies": {++ "function-bind": "^1.1.2"++ },++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/http-errors": {++ "version": "2.0.1",++ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",++ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",++ "license": "MIT",++ "dependencies": {++ "depd": "~2.0.0",++ "inherits": "~2.0.4",++ "setprototypeof": "~1.2.0",++ "statuses": "~2.0.2",++ "toidentifier": "~1.0.1"++ },++ "engines": {++ "node": ">= 0.8"++ },++ "funding": {++ "type": "opencollective",++ "url": "https://opencollective.com/express"++ }++ },++ "node_modules/iconv-lite": {++ "version": "0.4.24",++ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",++ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",++ "license": "MIT",++ "dependencies": {++ "safer-buffer": ">= 2.1.2 < 3"++ },++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/ieee754": {++ "version": "1.2.1",++ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",++ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/feross"++ },++ {++ "type": "patreon",++ "url": "https://www.patreon.com/feross"++ },++ {++ "type": "consulting",++ "url": "https://feross.org/support"++ }++ ],++ "license": "BSD-3-Clause"++ },++ "node_modules/inherits": {++ "version": "2.0.4",++ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",++ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",++ "license": "ISC"++ },++ "node_modules/ini": {++ "version": "1.3.8",++ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",++ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",++ "license": "ISC"++ },++ "node_modules/ipaddr.js": {++ "version": "1.9.1",++ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",++ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.10"++ }++ },++ "node_modules/json-schema-traverse": {++ "version": "1.0.0",++ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",++ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",++ "license": "MIT"++ },++ "node_modules/jsonwebtoken": {++ "version": "9.0.3",++ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",++ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",++ "license": "MIT",++ "dependencies": {++ "jws": "^4.0.1",++ "lodash.includes": "^4.3.0",++ "lodash.isboolean": "^3.0.3",++ "lodash.isinteger": "^4.0.4",++ "lodash.isnumber": "^3.0.3",++ "lodash.isplainobject": "^4.0.6",++ "lodash.isstring": "^4.0.1",++ "lodash.once": "^4.0.0",++ "ms": "^2.1.1",++ "semver": "^7.5.4"++ },++ "engines": {++ "node": ">=12",++ "npm": ">=6"++ }++ },++ "node_modules/jsonwebtoken/node_modules/ms": {++ "version": "2.1.3",++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",++ "license": "MIT"++ },++ "node_modules/jwa": {++ "version": "2.0.1",++ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",++ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",++ "license": "MIT",++ "dependencies": {++ "buffer-equal-constant-time": "^1.0.1",++ "ecdsa-sig-formatter": "1.0.11",++ "safe-buffer": "^5.0.1"++ }++ },++ "node_modules/jws": {++ "version": "4.0.1",++ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",++ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",++ "license": "MIT",++ "dependencies": {++ "jwa": "^2.0.1",++ "safe-buffer": "^5.0.1"++ }++ },++ "node_modules/lodash.includes": {++ "version": "4.3.0",++ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",++ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",++ "license": "MIT"++ },++ "node_modules/lodash.isboolean": {++ "version": "3.0.3",++ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",++ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",++ "license": "MIT"++ },++ "node_modules/lodash.isinteger": {++ "version": "4.0.4",++ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",++ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",++ "license": "MIT"++ },++ "node_modules/lodash.isnumber": {++ "version": "3.0.3",++ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",++ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",++ "license": "MIT"++ },++ "node_modules/lodash.isplainobject": {++ "version": "4.0.6",++ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",++ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",++ "license": "MIT"++ },++ "node_modules/lodash.isstring": {++ "version": "4.0.1",++ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",++ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",++ "license": "MIT"++ },++ "node_modules/lodash.once": {++ "version": "4.1.1",++ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",++ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",++ "license": "MIT"++ },++ "node_modules/math-intrinsics": {++ "version": "1.1.0",++ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",++ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/media-typer": {++ "version": "0.3.0",++ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",++ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/merge-descriptors": {++ "version": "1.0.3",++ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",++ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",++ "license": "MIT",++ "funding": {++ "url": "https://github.com/sponsors/sindresorhus"++ }++ },++ "node_modules/methods": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",++ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/mime": {++ "version": "1.6.0",++ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",++ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",++ "license": "MIT",++ "bin": {++ "mime": "cli.js"++ },++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/mime-db": {++ "version": "1.52.0",++ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",++ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/mime-types": {++ "version": "2.1.35",++ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",++ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",++ "license": "MIT",++ "dependencies": {++ "mime-db": "1.52.0"++ },++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/mimic-response": {++ "version": "3.1.0",++ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",++ "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",++ "license": "MIT",++ "engines": {++ "node": ">=10"++ },++ "funding": {++ "url": "https://github.com/sponsors/sindresorhus"++ }++ },++ "node_modules/minimist": {++ "version": "1.2.8",++ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",++ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",++ "license": "MIT",++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/mkdirp-classic": {++ "version": "0.5.3",++ "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",++ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",++ "license": "MIT"++ },++ "node_modules/ms": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",++ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",++ "license": "MIT"++ },++ "node_modules/napi-build-utils": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",++ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",++ "license": "MIT"++ },++ "node_modules/negotiator": {++ "version": "0.6.3",++ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",++ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/node-abi": {++ "version": "3.96.0",++ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz",++ "integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==",++ "license": "MIT",++ "dependencies": {++ "semver": "^7.3.5"++ },++ "engines": {++ "node": ">=10"++ }++ },++ "node_modules/object-inspect": {++ "version": "1.13.4",++ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",++ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/on-finished": {++ "version": "2.4.1",++ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",++ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",++ "license": "MIT",++ "dependencies": {++ "ee-first": "1.1.1"++ },++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/once": {++ "version": "1.4.0",++ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",++ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",++ "license": "ISC",++ "dependencies": {++ "wrappy": "1"++ }++ },++ "node_modules/openvibe-contracts": {++ "version": "0.11.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.11.0",++ "integrity": "sha512-M1UIDUF1nK2v94S++Sqw+d1G9mj3UKaPB8vse6UWIKZy9IAdA7Yu6DsTK0mJ0UXKeOVLLWuxeQX4G+/HtV9REg==",++ "license": "AGPL-3.0-only",++ "dependencies": {++ "ajv": "^8.17.1",++ "ajv-formats": "^3.0.1"++ },++ "bin": {++ "openvibe-contracts-check": "bin/check-service.js"++ },++ "engines": {++ "node": ">=20"++ }++ },++ "node_modules/openvibe-sdk": {++ "version": "0.2.2",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",++ "integrity": "sha512-kDifUp3EcmnTWzElAmaCZnX659WErcnm4+qVEoAiYQWX/w/1p4TWvoHbUPsrX65TSROVR3tiyVkl801n/0yyCw==",++ "license": "MIT",++ "engines": {++ "node": ">=20"++ }++ },++ "node_modules/openvibe-shared": {++ "version": "1.2.1",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.2.1",++ "integrity": "sha512-t8EHgju5/H2VqkM89LzdCKxBogJ8xilxmvZVJWQIYaGPKe7vMe4yrITlJi/Sy98phsffeFRogpaukDuO6QlDUw==",++ "license": "MIT",++ "dependencies": {++ "jsonwebtoken": "^9.0.0"++ }++ },++ "node_modules/parseurl": {++ "version": "1.3.3",++ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",++ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/path-to-regexp": {++ "version": "0.1.13",++ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",++ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",++ "license": "MIT"++ },++ "node_modules/prebuild-install": {++ "version": "7.1.3",++ "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",++ "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",++ "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",++ "license": "MIT",++ "dependencies": {++ "detect-libc": "^2.0.0",++ "expand-template": "^2.0.3",++ "github-from-package": "0.0.0",++ "minimist": "^1.2.3",++ "mkdirp-classic": "^0.5.3",++ "napi-build-utils": "^2.0.0",++ "node-abi": "^3.3.0",++ "pump": "^3.0.0",++ "rc": "^1.2.7",++ "simple-get": "^4.0.0",++ "tar-fs": "^2.0.0",++ "tunnel-agent": "^0.6.0"++ },++ "bin": {++ "prebuild-install": "bin.js"++ },++ "engines": {++ "node": ">=10"++ }++ },++ "node_modules/proxy-addr": {++ "version": "2.0.8",++ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",++ "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",++ "license": "MIT",++ "dependencies": {++ "forwarded": "0.2.0",++ "ipaddr.js": "1.9.1"++ },++ "engines": {++ "node": ">= 0.10"++ },++ "funding": {++ "type": "opencollective",++ "url": "https://opencollective.com/express"++ }++ },++ "node_modules/pump": {++ "version": "3.0.4",++ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",++ "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",++ "license": "MIT",++ "dependencies": {++ "end-of-stream": "^1.1.0",++ "once": "^1.3.1"++ }++ },++ "node_modules/qs": {++ "version": "6.16.0",++ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",++ "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",++ "license": "BSD-3-Clause",++ "dependencies": {++ "es-define-property": "^1.0.1",++ "side-channel": "^1.1.1"++ },++ "engines": {++ "node": ">=0.6"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/range-parser": {++ "version": "1.2.1",++ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",++ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/raw-body": {++ "version": "2.5.3",++ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",++ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",++ "license": "MIT",++ "dependencies": {++ "bytes": "~3.1.2",++ "http-errors": "~2.0.1",++ "iconv-lite": "~0.4.24",++ "unpipe": "~1.0.0"++ },++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/rc": {++ "version": "1.2.8",++ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",++ "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",++ "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",++ "dependencies": {++ "deep-extend": "^0.6.0",++ "ini": "~1.3.0",++ "minimist": "^1.2.0",++ "strip-json-comments": "~2.0.1"++ },++ "bin": {++ "rc": "cli.js"++ }++ },++ "node_modules/readable-stream": {++ "version": "3.6.2",++ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",++ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",++ "license": "MIT",++ "dependencies": {++ "inherits": "^2.0.3",++ "string_decoder": "^1.1.1",++ "util-deprecate": "^1.0.1"++ },++ "engines": {++ "node": ">= 6"++ }++ },++ "node_modules/require-from-string": {++ "version": "2.0.2",++ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",++ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/safe-buffer": {++ "version": "5.2.1",++ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",++ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/feross"++ },++ {++ "type": "patreon",++ "url": "https://www.patreon.com/feross"++ },++ {++ "type": "consulting",++ "url": "https://feross.org/support"++ }++ ],++ "license": "MIT"++ },++ "node_modules/safer-buffer": {++ "version": "2.1.2",++ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",++ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",++ "license": "MIT"++ },++ "node_modules/semver": {++ "version": "7.8.5",++ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",++ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",++ "license": "ISC",++ "bin": {++ "semver": "bin/semver.js"++ },++ "engines": {++ "node": ">=10"++ }++ },++ "node_modules/send": {++ "version": "0.19.2",++ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",++ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",++ "license": "MIT",++ "dependencies": {++ "debug": "2.6.9",++ "depd": "2.0.0",++ "destroy": "1.2.0",++ "encodeurl": "~2.0.0",++ "escape-html": "~1.0.3",++ "etag": "~1.8.1",++ "fresh": "~0.5.2",++ "http-errors": "~2.0.1",++ "mime": "1.6.0",++ "ms": "2.1.3",++ "on-finished": "~2.4.1",++ "range-parser": "~1.2.1",++ "statuses": "~2.0.2"++ },++ "engines": {++ "node": ">= 0.8.0"++ }++ },++ "node_modules/send/node_modules/ms": {++ "version": "2.1.3",++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",++ "license": "MIT"++ },++ "node_modules/serve-static": {++ "version": "1.16.3",++ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",++ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",++ "license": "MIT",++ "dependencies": {++ "encodeurl": "~2.0.0",++ "escape-html": "~1.0.3",++ "parseurl": "~1.3.3",++ "send": "~0.19.1"++ },++ "engines": {++ "node": ">= 0.8.0"++ }++ },++ "node_modules/setprototypeof": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",++ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",++ "license": "ISC"++ },++ "node_modules/side-channel": {++ "version": "1.1.1",++ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",++ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",++ "license": "MIT",++ "dependencies": {++ "es-errors": "^1.3.0",++ "object-inspect": "^1.13.4",++ "side-channel-list": "^1.0.1",++ "side-channel-map": "^1.0.1",++ "side-channel-weakmap": "^1.0.2"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/side-channel-list": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",++ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",++ "license": "MIT",++ "dependencies": {++ "es-errors": "^1.3.0",++ "object-inspect": "^1.13.4"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/side-channel-map": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",++ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",++ "license": "MIT",++ "dependencies": {++ "call-bound": "^1.0.2",++ "es-errors": "^1.3.0",++ "get-intrinsic": "^1.2.5",++ "object-inspect": "^1.13.3"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/side-channel-weakmap": {++ "version": "1.0.2",++ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",++ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",++ "license": "MIT",++ "dependencies": {++ "call-bound": "^1.0.2",++ "es-errors": "^1.3.0",++ "get-intrinsic": "^1.2.5",++ "object-inspect": "^1.13.3",++ "side-channel-map": "^1.0.1"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/simple-concat": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",++ "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/feross"++ },++ {++ "type": "patreon",++ "url": "https://www.patreon.com/feross"++ },++ {++ "type": "consulting",++ "url": "https://feross.org/support"++ }++ ],++ "license": "MIT"++ },++ "node_modules/simple-get": {++ "version": "4.0.1",++ "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",++ "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/feross"++ },++ {++ "type": "patreon",++ "url": "https://www.patreon.com/feross"++ },++ {++ "type": "consulting",++ "url": "https://feross.org/support"++ }++ ],++ "license": "MIT",++ "dependencies": {++ "decompress-response": "^6.0.0",++ "once": "^1.3.1",++ "simple-concat": "^1.0.0"++ }++ },++ "node_modules/statuses": {++ "version": "2.0.2",++ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",++ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/string_decoder": {++ "version": "1.3.0",++ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",++ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",++ "license": "MIT",++ "dependencies": {++ "safe-buffer": "~5.2.0"++ }++ },++ "node_modules/strip-json-comments": {++ "version": "2.0.1",++ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",++ "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/tar-fs": {++ "version": "2.1.5",++ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",++ "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",++ "license": "MIT",++ "dependencies": {++ "chownr": "^1.1.1",++ "mkdirp-classic": "^0.5.2",++ "pump": "^3.0.0",++ "tar-stream": "^2.1.4"++ }++ },++ "node_modules/tar-stream": {++ "version": "2.2.0",++ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",++ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",++ "license": "MIT",++ "dependencies": {++ "bl": "^4.0.3",++ "end-of-stream": "^1.4.1",++ "fs-constants": "^1.0.0",++ "inherits": "^2.0.3",++ "readable-stream": "^3.1.1"++ },++ "engines": {++ "node": ">=6"++ }++ },++ "node_modules/toidentifier": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",++ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",++ "license": "MIT",++ "engines": {++ "node": ">=0.6"++ }++ },++ "node_modules/tunnel-agent": {++ "version": "0.6.0",++ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",++ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",++ "license": "Apache-2.0",++ "dependencies": {++ "safe-buffer": "^5.0.1"++ },++ "engines": {++ "node": "*"++ }++ },++ "node_modules/type-is": {++ "version": "1.6.18",++ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",++ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",++ "license": "MIT",++ "dependencies": {++ "media-typer": "0.3.0",++ "mime-types": "~2.1.24"++ },++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/unpipe": {++ "version": "1.0.0",++ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",++ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/util-deprecate": {++ "version": "1.0.2",++ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",++ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",++ "license": "MIT"++ },++ "node_modules/utils-merge": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",++ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4.0"++ }++ },++ "node_modules/vary": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",++ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/wrappy": {++ "version": "1.0.2",++ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",++ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",++ "license": "ISC"++ }++ }++}nnew file mode 100644@@@ -0,0 +1,31 @@++{++ "name": "openvibe-tips",++ "version": "0.1.0",++ "private": true,++ "description": "OpenVibe.Tips — creator support on the OpenVibe network: tips, goals, paid messages, TTS and media requests, and overlays. Billing moves the money; Tips owns the interaction.",++ "main": "server/index.js",++ "scripts": {++ "start": "node server/index.js",++ "dev": "NODE_ENV=development node server/index.js",++ "test": "node test/run.js",++ "subscribe": "node scripts/subscribe.js",++ "import-live": "node scripts/import-live.js"++ },++ "keywords": ["openvibe", "tips", "overlays", "creator"],++ "author": "OpenVibers",++ "license": "AGPL-3.0-only",++ "repository": "github:OpenVibers/OpenVibe.Tips",++ "dependencies": {++ "better-sqlite3": "^11.10.0",++ "cookie-parser": "^1.4.7",++ "dotenv": "^16.4.7",++ "express": "^4.21.2",++ "jsonwebtoken": "^9.0.2",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.11.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.2.1"++ },++ "engines": {++ "node": ">=22.0.0"++ }++}nnew file mode 100644@@@ -0,0 +1,16 @@++/* OpenVibe.Tips overlay — transparent, for OBS Browser Sources. */++html, body { margin: 0; background: transparent; color: #fff; font: 600 28px/1.3 system-ui, -apple-system, "Segoe UI", sans-serif; overflow: hidden; }++#alert { position: fixed; left: 50%; top: 12%; transform: translateX(-50%); width: min(90vw, 760px); text-align: center; opacity: 0; transition: opacity .35s, transform .35s; }++#alert.show { opacity: 1; transform: translateX(-50%) translateY(6px); }++#alert .box { background: rgba(10, 15, 28, .82); border: 2px solid #3b82f6; border-radius: 18px; padding: 18px 24px; text-shadow: 0 2px 6px rgba(0, 0, 0, .6); }++#alert img { max-width: 220px; max-height: 160px; display: block; margin: 0 auto 10px; }++#alert .title b { color: #60a5fa; }++#alert .msg { font-weight: 500; font-size: 24px; margin-top: 8px; overflow-wrap: anywhere; }++#alert .test { font-size: 14px; color: #fbbf24; letter-spacing: .08em; text-transform: uppercase; }++#goals { position: fixed; left: 24px; right: 24px; bottom: 24px; display: grid; gap: 10px; }++.g { background: rgba(10, 15, 28, .78); border-radius: 12px; padding: 10px 14px; font-size: 20px; }++.g .bar { height: 12px; border-radius: 6px; background: rgba(255, 255, 255, .15); overflow: hidden; margin-top: 6px; }++.g .bar span { display: block; height: 100%; background: #3b82f6; transition: width .6s; }++.g.reached .bar span { background: #34d399; }++.g .test { color: #fbbf24; font-size: 14px; }++.nojs { font-size: 16px; }nnew file mode 100644@@@ -0,0 +1,64 @@++/* OpenVibe.Tips — page styles. Colours follow the shared theme variables (theme-loader.js). */++:root {++ --tips-bg: var(--bg-primary, #0a0f1c);++ --tips-card: var(--bg-secondary, #121a2b);++ --tips-text: var(--text-primary, #e6edf7);++ --tips-muted: var(--text-secondary, #9aa7bd);++ --tips-accent: var(--accent, #3b82f6);++ --tips-on-accent: var(--on-accent, #ffffff);++ --tips-border: var(--border, rgba(255, 255, 255, .1));++ --tips-bad: #f87171;++ --tips-good: #34d399;++}++* { box-sizing: border-box; }++body { margin: 0; background: var(--tips-bg); color: var(--tips-text); font: 16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }++a { color: var(--tips-accent); }++.site-head { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 18px; padding: 12px 20px; border-bottom: 1px solid var(--tips-border); }++.ov-has-navbar .site-head { display: none; }++.site-head .brand { font-weight: 700; color: inherit; text-decoration: none; }++.site-head nav { display: flex; gap: 14px; flex: 1; }++.site-head nav a { color: var(--tips-muted); text-decoration: none; }++.site-head nav a[aria-current] { color: var(--tips-text); }++.page { max-width: 880px; margin: 0 auto; padding: 24px 16px 48px; }++h1 { font-size: 1.8rem; margin: .2em 0 .5em; }++.hero p { font-size: 1.1rem; color: var(--tips-muted); }++.card { background: var(--tips-card); border: 1px solid var(--tips-border); border-radius: 12px; padding: 18px 20px; margin: 18px 0; }++.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 18px; }++.grid .card { margin: 0; }++.btn { display: inline-block; background: var(--tips-accent); color: var(--tips-on-accent); border: 0; border-radius: 8px; padding: 9px 16px; font: inherit; font-weight: 600; cursor: pointer; text-decoration: none; }++.btn.ghost { background: transparent; color: var(--tips-accent); border: 1px solid var(--tips-accent); }++form label { display: block; margin: 10px 0; }++form input[type=text], form input[type=number], form input[type=url], form textarea, form select, input.copy {++ display: block; width: 100%; margin-top: 4px; padding: 8px 10px; border-radius: 8px; border: 1px solid var(--tips-border);++ background: var(--tips-bg); color: var(--tips-text); font: inherit; }++form input[type=checkbox], form input[type=radio] { margin-right: 6px; }++form.inline { display: inline-flex; flex-wrap: wrap; gap: 8px; align-items: end; margin: 4px 8px 12px 0; }++form.inline label { margin: 0; }++fieldset { border: 1px solid var(--tips-border); border-radius: 8px; margin: 12px 0; }++small, .fine, .meta { color: var(--tips-muted); }++.fine { font-size: .9rem; }++.notice { background: rgba(59, 130, 246, .12); border: 1px solid rgba(59, 130, 246, .4); border-radius: 8px; padding: 10px 14px; }++.notice.error { background: rgba(248, 113, 113, .12); border-color: rgba(248, 113, 113, .5); }++.creator-head { display: flex; gap: 18px; align-items: center; }++.creator-head .avatar { border-radius: 50%; object-fit: cover; }++.headline { color: var(--tips-muted); margin: 0; }++.goal { margin: 12px 0; }++.goal h3 { margin: 0 0 4px; font-size: 1.05rem; }++.bar { height: 12px; border-radius: 6px; background: var(--tips-border); overflow: hidden; }++.bar span { display: block; height: 100%; background: var(--tips-accent); }++.goal.reached .bar span { background: var(--tips-good); }++table.list { width: 100%; border-collapse: collapse; font-size: .95rem; }++table.list th, table.list td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--tips-border); }++.num { text-align: right !important; font-variant-numeric: tabular-nums; }++tr.test td, tr.revoked td { color: var(--tips-muted); }++dl.receipt { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; }++dl.receipt dt { color: var(--tips-muted); }++dl.receipt dd { margin: 0; overflow-wrap: anywhere; }++.big { font-size: 1.8rem; font-weight: 700; margin: 0; }++.creators { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 10px; }++.creators a { display: flex; align-items: center; gap: 10px; padding: 8px; border-radius: 8px; background: var(--tips-card); text-decoration: none; color: inherit; }++.creators img { border-radius: 50%; }++.steps li { margin: 8px 0; }++.js .tip-form .when-tts, .js .tip-form .when-media { display: none; }++.js .tip-form[data-kind=tts] .when-tts, .js .tip-form[data-kind=media_request] .when-media { display: block; }++@media (max-width: 560px) { dl.receipt { grid-template-columns: 1fr; } .creator-head { flex-direction: column; align-items: flex-start; } table.list { font-size: .85rem; } }nnew file mode 100644@@@ -0,0 +1,88 @@++// OpenVibe.Tips overlay client (OBS Browser Source). The overlay link's token is in the page URL;++// this script only reads the event stream next to it. Replays after a reconnect (Last-Event-ID)++// are shown again but change nothing on the server.++(function () {++ 'use strict';++ var base = location.pathname.replace(/\/+$/, '');++ var alertEl = document.getElementById('alert');++ var goalsEl = document.getElementById('goals');++ var config = { settings: { duration_ms: 8000, show_message: true, show_amount: true, template: '{name} tipped {amount} Vibes' } };++ var goals = {};++ var queue = [];++ var busy = false;++ var shown = {};++++ function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }++ function fmt(n) { try { return Number(n).toLocaleString('en-US'); } catch (e) { return String(n); } }++++ function renderGoals() {++ goalsEl.textContent = '';++ Object.keys(goals).forEach(function (id) {++ var g = goals[id];++ if (g.status !== 'active') return;++ var box = el('div', 'g' + (g.reached ? ' reached' : ''));++ box.appendChild(el('div', null, g.title + ' — ' + fmt(g.current_amount) + ' / ' + fmt(g.target_amount) + ' Vibes'));++ if (g._test) box.appendChild(el('div', 'test', 'simulation — not counted'));++ var bar = el('div', 'bar'); var fill = el('span'); fill.style.width = Math.min(100, g.percent) + '%'; bar.appendChild(fill); box.appendChild(bar);++ goalsEl.appendChild(box);++ });++ }++++ function next() {++ if (busy || !queue.length) return;++ busy = true;++ var a = queue.shift();++ var s = config.settings || {};++ alertEl.textContent = '';++ var box = el('div', 'box');++ if (s.image_url) { var img = el('img'); img.src = s.image_url; img.alt = ''; box.appendChild(img); }++ if (a.test) box.appendChild(el('div', 'test', 'test alert'));++ var title = el('div', 'title');++ var tpl = String(s.template || '{name} tipped {amount} Vibes');++ var parts = tpl.split(/(\{name\}|\{amount\})/);++ parts.forEach(function (p) {++ if (p === '{name}') title.appendChild(el('b', null, a.supporter_name || 'Someone'));++ else if (p === '{amount}') title.appendChild(document.createTextNode(s.show_amount === false ? '' : fmt(a.amount)));++ else title.appendChild(document.createTextNode(p));++ });++ box.appendChild(title);++ var text = a.tts ? a.tts.text : a.message;++ if (s.show_message !== false && text) box.appendChild(el('div', 'msg', text));++ alertEl.appendChild(box);++ alertEl.className = 'show';++ if (s.sound_url) { try { new Audio(s.sound_url).play().catch(function () {}); } catch (e) { /* no audio */ } }++ if (s.speak_message && a.tts && window.speechSynthesis) {++ try { window.speechSynthesis.speak(new SpeechSynthesisUtterance(a.tts.text)); } catch (e) { /* no voice */ }++ }++ setTimeout(function () {++ alertEl.className = '';++ setTimeout(function () { busy = false; next(); }, 400);++ }, Number(s.duration_ms) || 8000);++ }++++ var es = new EventSource(base + '/events');++ es.addEventListener('hello', function (e) {++ var d = JSON.parse(e.data);++ if (d.config) config = d.config;++ goals = {};++ (d.goals || []).forEach(function (g) { goals[g.id] = g; });++ renderGoals();++ });++ es.addEventListener('config', function (e) { var d = JSON.parse(e.data); if (d.config) config = d.config; });++ es.addEventListener('alert', function (e) {++ var d = JSON.parse(e.data);++ if (shown[d.delivery_id]) return; // a replay of one this page already showed++ shown[d.delivery_id] = 1;++ queue.push(d);++ next();++ });++ es.addEventListener('goal', function (e) {++ var d = JSON.parse(e.data);++ if (!d.goal) return;++ var g = d.goal; g._test = !!d.test;++ goals[g.id] = g;++ renderGoals();++ if (d.test) setTimeout(function () { if (goals[g.id] && goals[g.id]._test) { delete goals[g.id]; renderGoals(); es.close(); location.reload(); } }, 15000);++ });++ es.addEventListener('revoked', function () { es.close(); alertEl.textContent = ''; goalsEl.textContent = ''; });++})();nnew file mode 100644@@@ -0,0 +1,15 @@++// OpenVibe.Tips — progressive touches only; every form works without this file.++(function () {++ 'use strict';++ document.documentElement.classList.add('js');++ document.addEventListener('DOMContentLoaded', function () {++ var form = document.querySelector('.tip-form');++ if (!form) return;++ function sync() {++ var checked = form.querySelector('input[name=kind]:checked');++ form.setAttribute('data-kind', checked ? checked.value : 'tip');++ }++ form.addEventListener('change', sync);++ sync();++ });++})();nnew file mode 100644@@@ -0,0 +1,62 @@++#!/usr/bin/env node++'use strict';++/**++ * Import OpenVibe.Live's tip history into Tips, linked to OpenVibe.Billing's imported journal.++ *++ * node scripts/import-live.js --live-db <live snapshot> [--billing-db <billing snapshot>] [--dry-run] [--json]++ *++ * Both databases must be COPIES (e.g. `sqlite3 live.db ".backup live-snapshot.db"`); they are opened++ * read-only. Run it AFTER Billing's own import (scripts/import-live.js in OpenVibe.Billing) so every++ * Live donation links to its Billing transaction. Live user ids are resolved to Network subjects++ * through resolve-batch with Tips' client credentials (capability identity.subject.resolve).++ *++ * With --billing-db the report ends with the reconciliation: per creator, Tips' settled total must++ * equal Billing's; the exit code is 1 when it does not. Safe to re-run.++ */++const path = require('path');++const Database = require('better-sqlite3');++const { loadConfig } = require('../server/config');++const { openDb } = require('../server/db');++const { createIdentity } = require('../server/network');++const { createTipsOutbox } = require('../server/events/outbox');++const { createDomain } = require('../server/domain');++const { importLive, billingSnapshotSource } = require('../server/importer/live');++++const args = process.argv.slice(2);++const opt = (name) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : null; };++const flag = (name) => args.includes(`--${name}`);++++async function main() {++ const livePath = opt('live-db');++ if (!livePath) { console.error('usage: import-live.js --live-db <snapshot> [--billing-db <snapshot>] [--dry-run] [--json]'); process.exit(2); }++ const config = loadConfig();++ for (const p of [livePath, opt('billing-db')].filter(Boolean)) if (path.resolve(p) === path.resolve(config.dbPath)) throw new Error(`${p} is the Tips database`);++ const live = new Database(livePath, { readonly: true, fileMustExist: true });++ const bdb = opt('billing-db') ? new Database(opt('billing-db'), { readonly: true, fileMustExist: true }) : null;++ const db = openDb(config.dbPath);++ const outbox = createTipsOutbox({ db, config });++ const domain = createDomain({ db, config, outbox, billing: null, adapters: {}, log: console });++ const identity = createIdentity(config);++ const report = await importLive(domain, { live, billingSource: bdb ? billingSnapshotSource(bdb) : null, resolveLiveUsers: identity.resolveLiveUsers, dryRun: flag('dry-run') });++ if (flag('json')) console.log(JSON.stringify(report, null, 2));++ else {++ const c = report.counts;++ console.log(`import ${report.run_id}${report.dry_run ? ' (DRY RUN — nothing kept)' : ''}`);++ console.log(` live snapshot: ${JSON.stringify(c.live)}; identities ${JSON.stringify(c.identities)}; test before ${report.test_before || '(none)'}`);++ console.log(` imported ${c.imported} tips (${c.linked_to_billing} linked to Billing, ${c.unlinked} not), ${c.refunds_applied} refunds applied, ${c.external} external PowerChat tips, ${c.goals} goals`);++ console.log(` held ${c.held}, excluded ${c.excluded}, unchanged ${c.unchanged}, released from hold ${c.released}`);++ for (const h of report.holds) console.log(` held: ${JSON.stringify(h)}`);++ const r = report.reconciliation;++ if (!r) console.log(' reconciliation: skipped (no --billing-db)');++ else {++ console.log(` reconciliation: ${r.ok ? 'OK' : 'MISMATCH'} over ${r.creators} creators`);++ for (const m of r.mismatches) console.log(` ${m.creator}: tips ${m.tips} vs billing ${m.billing} (difference ${m.difference})`);++ }++ }++ live.close();++ if (bdb) bdb.close();++ db.close();++ if (report.reconciliation && !report.reconciliation.ok) process.exit(1);++}++++main().catch((e) => { console.error(e.message); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,42 @@++#!/usr/bin/env node++'use strict';++/**++ * Create Tips' OpenVibe.Events subscription for Billing's settlement events.++ *++ * node scripts/subscribe.js [--endpoint http://127.0.0.1:4610/internal/events] [--pattern 'billing.transaction.*']++ *++ * Reads the environment (.env or /etc/openvibe/tips.env): EVENTS_URL, OV_NETWORK_INTERNAL_URL,++ * OV_OAUTH_CLIENT_ID, OV_OAUTH_CLIENT_SECRET (the tips principal needs events.subscription.manage for++ * audience openvibe.events) and TIPS_EVENTS_SECRET — the delivery signing secret, handed to Events++ * here, so generate it first (`openssl rand -hex 32`). Nothing secret is printed. An existing++ * identical subscription is reported, not duplicated.++ */++require('dotenv').config();++const { serviceAuth } = require('openvibe-contracts');++const { loadConfig } = require('../server/config');++++const args = process.argv.slice(2);++const opt = (name, d) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : d; };++++(async () => {++ const config = loadConfig();++ const endpoint = opt('endpoint', `http://127.0.0.1:${config.port}/internal/events`);++ const pattern = opt('pattern', 'billing.transaction.*');++ const secret = config.events.webhookSecrets[0];++ if (!config.events.url) throw new Error('EVENTS_URL is not set');++ if (!secret || secret.length < 32) throw new Error('TIPS_EVENTS_SECRET must be set (32+ characters) before subscribing');++ if (!config.oauth.clientSecret) throw new Error('OV_OAUTH_CLIENT_SECRET is not set');++ const tokens = serviceAuth.createTokenClient({++ tokenUrl: `${config.network.internalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,++ audience: 'openvibe.events', scope: 'events.subscription.manage',++ });++ const res = await fetch(`${config.events.url}/api/v1/subscriptions`, {++ method: 'POST',++ headers: { 'Content-Type': 'application/json', ...(await tokens.authHeaders()) },++ body: JSON.stringify({ topic_pattern: pattern, endpoint, secret }),++ });++ const body = await res.json().catch(() => ({}));++ if (res.status === 409 && body.subscription_id) { console.log(`subscription exists: ${body.subscription_id} (${pattern} → ${endpoint})`); return; }++ if (!res.ok) throw new Error(`Events answered ${res.status}: ${body.code || ''} ${body.detail || ''}`.trim());++ console.log(`subscribed: ${body.id} (${pattern} → ${endpoint}). Replay history with POST /api/v1/deliveries/replay { subscription_id, from_seq }.`);++})().catch((err) => { console.error(`subscribe failed: ${err.message}`); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,75 @@++'use strict';++++/**++ * Who is calling /api/v1 — resolved into req.principal:++ *++ * { kind: 'service', sub: 'svc:live', cap: [...] }++ * A Network client-credentials token for audience openvibe.tips. Each route checks ONE++ * capability against the token's `cap` claim. The tips.* capabilities are proposals (not yet in++ * an openvibe-contracts release), so the grant is matched with contracts' capabilities.grants()++ * (exact id or a `.*` family) — the same interim rule Billing used before v0.8.0.++ * { kind: 'user', subject: 'usr_…', username, name, avatar, role }++ * A browser's Network user JWT, presented as `Authorization: Bearer`. Users act only on their++ * own things (their profile, goals, overlays, receipts; tips paid by themselves). The API never++ * reads cookies, so a cross-site form cannot drive it; the SSR pages carry their own++ * anti-forgery tokens.++ * { kind: 'anonymous' }++ *++ * A request that presents a token is judged on that token alone: a bad one is refused, never++ * downgraded to anonymous.++ */++const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');++++const PRINCIPAL_SUB = /^(svc|app|mod):/;++const ANON = Object.freeze({ kind: 'anonymous' });++++function decodePayload(token) {++ const parts = String(token || '').split('.');++ if (parts.length !== 3) return null;++ try { return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); } catch { return null; }++}++++/** A Network user token's claims → the user principal (null when it names no subject). */++function userPrincipal(claims) {++ if (!claims) return null;++ const subject = ids.isSubjectId('user', claims.subject_id) ? claims.subject_id : null;++ if (!subject) return null;++ return {++ kind: 'user', subject, username: claims.username || null, name: claims.display_name || claims.username || null,++ avatar: claims.avatar_url || null, role: claims.role || 'user',++ };++}++++function createApiAuth({ config, keys, userAuth }) {++ function resolve(req) {++ const header = String(req.headers.authorization || '');++ if (!header.startsWith('Bearer ')) return { principal: ANON };++ const token = header.slice(7).trim();++ const publicKey = keys.get();++ if (!publicKey) return { error: [503, 'identity.unavailable', 'the Network signing key is not loaded yet'] };++ const payload = decodePayload(token);++ if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {++ const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.network.issuer, audience: config.audience });++ if (!r.ok) return { error: [401, r.code, r.reason] };++ return { principal: { kind: 'service', sub: r.claims.sub, cap: r.claims.cap || [], jti: r.claims.jti } };++ }++ const claims = userAuth.verify(token);++ if (!claims) return { error: [401, 'token.invalid', 'the user token is invalid or expired'] };++ const p = userPrincipal(claims);++ if (!p) return { error: [403, 'identity.no_subject', 'this account has no canonical subject yet; sign in again'] };++ return { principal: p };++ }++++ function middleware(req, res, next) {++ const r = resolve(req);++ if (r.error) return http.sendProblem(res, r.error[0], r.error[1], { detail: r.error[2], ctx: req.ov });++ req.principal = r.principal;++ return next();++ }++++ const granted = (p, cap) => p.kind === 'service' && capabilities.grants(p.cap, cap);++++ return { middleware, resolve, granted };++}++++module.exports = { createApiAuth, userPrincipal, decodePayload, PRINCIPAL_SUB };nnew file mode 100644@@@ -0,0 +1,51 @@++'use strict';++++/**++ * Idempotency-Key for every mutating /api/v1 call (Billing's rule). The key is scoped to the++ * calling principal. The first 2xx response is stored with a hash of the request; a replay with the++ * same key and request returns it verbatim (Idempotent-Replayed: true) and does nothing; the same++ * key with another request is 422 idempotency.key_reused. Refusals are not stored.++ *++ * The scoped key also becomes the interaction's idempotency_key (UNIQUE), the second line of defence.++ */++const crypto = require('crypto');++const { http } = require('openvibe-contracts');++++const KEY_RE = /^[A-Za-z0-9._:-]{8,200}$/;++++function stable(v) {++ if (Array.isArray(v)) return `[${v.map(stable).join(',')}]`;++ if (v && typeof v === 'object') return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${stable(v[k])}`).join(',')}}`;++ return JSON.stringify(v === undefined ? null : v);++}++++const principalKey = (p) => (p.kind === 'service' ? p.sub : p.kind === 'user' ? p.subject : 'anonymous');++++function idempotent(db, now) {++ return function idempotencyKey(req, res, next) {++ const key = req.get('Idempotency-Key');++ if (!key || !KEY_RE.test(key)) {++ return http.sendProblem(res, 400, 'idempotency.key_required', { detail: 'mutating calls need an Idempotency-Key header (8-200 of A-Z a-z 0-9 . _ : -)', ctx: req.ov });++ }++ const scoped = `${principalKey(req.principal)}:${key}`;++ const hash = crypto.createHash('sha256').update(`${req.method} ${req.baseUrl}${req.path}\n${stable(req.body || {})}`).digest('hex');++ const row = db.prepare('SELECT * FROM api_idempotency WHERE key = ?').get(scoped);++ if (row) {++ if (row.request_hash !== hash) return http.sendProblem(res, 422, 'idempotency.key_reused', { detail: 'this Idempotency-Key was used for a different request', ctx: req.ov });++ res.setHeader('Idempotent-Replayed', 'true');++ return res.status(row.status).json(JSON.parse(row.response));++ }++ req.idempotencyKey = `api:${scoped}`;++ const json = res.json.bind(res);++ res.json = (body) => {++ if (res.statusCode >= 200 && res.statusCode < 300) {++ db.prepare('INSERT OR IGNORE INTO api_idempotency (key, request_hash, method, path, status, response, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)')++ .run(scoped, hash, req.method, `${req.baseUrl}${req.path}`, res.statusCode, JSON.stringify(body), new Date(now()).toISOString());++ }++ return json(body);++ };++ return next();++ };++}++++module.exports = { idempotent, stable, principalKey };nnew file mode 100644@@@ -0,0 +1,320 @@++'use strict';++++/**++ * /api/v1 — Tips' API for services (capability-guarded Network service tokens, audience++ * openvibe.tips) and for people (their Network user token as a Bearer; they act on their own++ * things only). Every POST/PATCH needs an Idempotency-Key. Errors are RFC 9457 problem+json.++ * Amounts are integer Vibes bits; people are SubjectRefs { type: 'user', id: 'usr_…' }.++ *++ * | Method & path | Capability (services) | People |++ * |----------------------------------------|------------------------------|-------------------------------------|++ * | GET /profiles/:creator | tips.profile.get | anyone when the page is on; owner |++ * | PATCH /profiles/:creator | tips.profile.update | owner (`me` creates it) |++ * | GET /profiles/:creator/totals | tips.interaction.list | owner |++ * | POST /checkout | tips.checkout.create | the supporter |++ * | POST /paid-messages | tips.superchat.create | the supporter |++ * | POST /tts-requests | tips.tts.request | the supporter |++ * | POST /media-requests | tips.media_request.create | the supporter |++ * | GET /interactions/:id | tips.interaction.get | its creator or supporter |++ * | GET /interactions | tips.interaction.list | own receipts; ?as=creator own tips |++ * | POST /interactions/external | tips.interaction.record | — |++ * | GET /goals?creator=, /goals/:id | tips.goal.update (private) | anyone when the page is on; owner |++ * | POST /goals | tips.goal.create | owner |++ * | PATCH /goals/:id | tips.goal.update | owner |++ * | POST /goals/:id/close | tips.goal.close | owner |++ * | GET|POST /overlay-tokens | tips.overlay.token.create | owner |++ * | POST /overlay-tokens/:id/revoke | tips.overlay.token.revoke | owner |++ * | GET /overlay-configs[/:id] | tips.overlay.config.get | owner |++ * | POST /overlay-configs, PATCH …/:id | tips.overlay.config.update | owner |++ * | POST /simulate | tips.simulation.run | owner |++ */++const express = require('express');++const { http } = require('openvibe-contracts');++const { TipsError, fail, userSubject, displayName } = require('../util');++const { idempotent } = require('./idempotency');++++const CAP = {++ profileGet: 'tips.profile.get',++ profileUpdate: 'tips.profile.update',++ checkout: 'tips.checkout.create',++ superchat: 'tips.superchat.create',++ tts: 'tips.tts.request',++ media: 'tips.media_request.create',++ interactionGet: 'tips.interaction.get',++ interactionList: 'tips.interaction.list',++ interactionRecord: 'tips.interaction.record',++ goalCreate: 'tips.goal.create',++ goalUpdate: 'tips.goal.update',++ goalClose: 'tips.goal.close',++ tokenCreate: 'tips.overlay.token.create',++ tokenRevoke: 'tips.overlay.token.revoke',++ configGet: 'tips.overlay.config.get',++ configUpdate: 'tips.overlay.config.update',++ simulate: 'tips.simulation.run',++};++++function v1Router({ domain, apiAuth }) {++ const r = express.Router();++ const { db, profiles, goals, interactions, overlays } = domain;++ const idem = idempotent(db, domain.now);++++ /** Service with `cap`, or the user who owns `owner` (subject). Anonymous → 401. */++ function allow(req, cap, owner) {++ const p = req.principal;++ if (p.kind === 'anonymous') fail(401, 'token.missing', 'sign in (a Network user token) or present a service token');++ if (p.kind === 'service') { if (!apiAuth.granted(p, cap)) fail(403, 'capability.denied', `${cap} not granted`); return 'service'; }++ if (owner && p.subject === owner) return 'owner';++ fail(403, 'tips.forbidden', 'this belongs to someone else');++ return null;++ }++ /** The creator a call is about: body/query `creator` (subject or handle), or the signed-in user. */++ function creatorOf(req, value, { mustExist = true } = {}) {++ const v = value != null && value !== '' ? value : (req.principal.kind === 'user' ? req.principal.subject : null);++ if (!v) fail(422, 'tips.invalid_input', 'creator is required');++ const ref = typeof v === 'object' ? userSubject(v, 'creator') : String(v);++ const profile = ref === 'me' && req.principal.kind === 'user' ? profiles.bySubject(req.principal.subject) : profiles.resolve(ref);++ if (!profile && mustExist) fail(404, 'tips.creator_not_found', 'no tip profile for this creator');++ return profile;++ }++ const write = (handler) => [idem, wrap(handler)];++++ // ── Profiles ─────────────────────────────────────────────++ r.get('/profiles/:creator', wrap((req, res) => {++ const p = creatorOf(req, req.params.creator);++ const who = req.principal;++ const mine = who.kind === 'user' && who.subject === p.creator_subject;++ const svc = who.kind === 'service' && apiAuth.granted(who, CAP.profileGet);++ // A page that is switched off does not exist for anyone but its creator and granted services.++ if (!p.page_enabled && !mine && !svc) fail(404, 'tips.creator_not_found', 'no tip profile for this creator');++ const owner = mine || svc;++ res.json({ profile: profiles.present(p, { owner }), goals: goals.list(p.creator_subject, { status: 'active' }).map((g) => goals.present(g)) });++ }));++++ r.patch('/profiles/:creator', ...write((req, res) => {++ const b = req.body || {};++ let p;++ if (req.params.creator === 'me') {++ if (req.principal.kind !== 'user') fail(401, 'token.missing', '`me` needs a user token');++ p = profiles.ensure(req.principal.subject, { username: req.principal.username, displayName: req.principal.name, avatarUrl: req.principal.avatar });++ } else if (req.principal.kind === 'service' && /^usr_/.test(req.params.creator) && !profiles.bySubject(req.params.creator)) {++ allow(req, CAP.profileUpdate);++ // A service opening a profile for a creator names the account projection it knows.++ if (!b.handle) fail(422, 'tips.invalid_input', 'handle is required to create a profile');++ p = profiles.ensure(userSubject(req.params.creator, 'creator'), { username: b.handle, displayName: b.display_name || b.handle, avatarUrl: b.avatar_url });++ } else {++ p = creatorOf(req, req.params.creator);++ }++ allow(req, CAP.profileUpdate, p.creator_subject);++ const out = profiles.update(p.creator_subject, b, { expectedRevision: b.revision });++ res.json({ profile: profiles.present(out, { owner: true }) });++ }));++++ r.get('/profiles/:creator/totals', wrap((req, res) => {++ const p = creatorOf(req, req.params.creator);++ allow(req, CAP.interactionList, p.creator_subject);++ res.json({ creator: { type: 'user', id: p.creator_subject }, totals: interactions.totals(p.creator_subject) });++ }));++++ // ── Paid requests (checkout / credit) ────────────────────++ function paid(kind, cap) {++ return write(async (req, res) => {++ const b = req.body || {};++ const p = req.principal;++ if (p.kind === 'anonymous') fail(401, 'token.missing', 'sign in to tip');++ let supporter;++ let supporterName;++ if (p.kind === 'service') {++ allow(req, cap);++ supporter = userSubject(b.supporter, 'supporter');++ supporterName = displayName(b.supporter_name) || null;++ } else {++ supporter = p.subject;++ supporterName = displayName(b.supporter_name) || p.name || p.username;++ }++ const profile = creatorOf(req, b.creator);++ const funding = b.pay_with || 'credit';++ const tp = req.ov && req.ov.traceparent;++ const { interaction, replay } = interactions.request(profile, { ...b, kind }, { supporter, supporterName, funding, idempotencyKey: req.idempotencyKey });++ let i = interaction;++ let checkout = null;++ if (!replay) {++ if (funding === 'credit') {++ const out = await interactions.transfer(i, { traceparent: tp });++ i = interactions.get(i.id);++ if (out.refused) {++ fail(out.refused === 'billing.insufficient_funds' ? 409 : 422, out.refused, out.detail || 'Billing refused the tip', { interaction: interactions.present(i, { viewer: 'supporter' }) });++ }++ } else {++ checkout = await interactions.startCheckout(i, { provider: b.provider, traceparent: tp });++ i = checkout.interaction;++ }++ } else if (i.payment_state === 'failed') {++ fail(409, 'tips.payment_failed', i.failure || 'the payment for this request failed', { interaction: interactions.present(i, { viewer: 'supporter' }) });++ }++ res.status(replay ? 200 : 201).json({++ interaction: interactions.present(i, { viewer: p.kind === 'service' ? 'service' : 'supporter' }),++ checkout_url: i.checkout_url || null, checkout_ref: i.checkout_ref || null,++ });++ });++ }++ r.post('/checkout', ...paid('tip', CAP.checkout));++ r.post('/paid-messages', ...paid('paid_message', CAP.superchat));++ r.post('/tts-requests', ...paid('tts', CAP.tts));++ r.post('/media-requests', ...paid('media_request', CAP.media));++++ // ── Interactions ─────────────────────────────────────────++ r.get('/interactions', wrap((req, res) => {++ const p = req.principal;++ const q = req.query;++ let filter;++ if (p.kind === 'service') {++ allow(req, CAP.interactionList);++ filter = {++ creator: q.creator ? (creatorOf(req, q.creator).creator_subject) : null,++ supporter: q.supporter ? userSubject(String(q.supporter), 'supporter') : null,++ };++ if (!filter.creator && !filter.supporter) fail(422, 'tips.invalid_input', 'creator or supporter is required');++ } else if (p.kind === 'user') {++ filter = q.as === 'creator' ? { creator: p.subject } : { supporter: p.subject };++ } else fail(401, 'token.missing', 'sign in to see receipts');++ const out = interactions.list({ ...filter, includeTest: q.include_test !== '0', cursor: q.cursor, limit: q.limit });++ const viewer = p.kind === 'service' ? 'service' : (filter.creator ? 'owner' : 'supporter');++ res.json({ interactions: out.rows.map((i) => interactions.present(i, { viewer })), next_cursor: out.next_cursor });++ }));++++ r.get('/interactions/:id', wrap((req, res) => {++ const i = interactions.get(req.params.id);++ const p = req.principal;++ if (!i) fail(404, 'tips.interaction_not_found', 'no such interaction');++ let viewer;++ if (p.kind === 'service') { allow(req, CAP.interactionGet); viewer = 'service'; }++ else if (p.kind === 'user' && p.subject === i.creator_subject) viewer = 'owner';++ else if (p.kind === 'user' && p.subject === i.supporter_subject) viewer = 'supporter';++ else if (p.kind === 'anonymous') fail(401, 'token.missing', 'sign in to see this receipt');++ else fail(404, 'tips.interaction_not_found', 'no such interaction');++ res.json({ interaction: interactions.present(i, { viewer }) });++ }));++++ r.post('/interactions/external', ...write((req, res) => {++ allow(req, CAP.interactionRecord);++ const b = req.body || {};++ const profile = creatorOf(req, b.creator);++ const out = interactions.recordExternal(profile, b);++ res.status(out.replay ? 200 : 201).json({ interaction: interactions.present(out.interaction, { viewer: 'service' }), duplicate: out.replay });++ }));++++ // ── Goals ────────────────────────────────────────────────++ function goalVisible(req, g) {++ const profile = profiles.bySubject(g.creator_subject);++ if (profile && profile.page_enabled) return true;++ const p = req.principal;++ return (p.kind === 'user' && p.subject === g.creator_subject) || (p.kind === 'service' && apiAuth.granted(p, CAP.goalUpdate));++ }++ r.get('/goals', wrap((req, res) => {++ const profile = creatorOf(req, req.query.creator);++ const sample = { creator_subject: profile.creator_subject };++ if (!goalVisible(req, sample)) fail(404, 'tips.creator_not_found', 'no tip profile for this creator');++ const status = ['active', 'closed'].includes(req.query.status) ? req.query.status : undefined;++ res.json({ goals: goals.list(profile.creator_subject, { status }).map((g) => goals.present(g)) });++ }));++ r.get('/goals/:id', wrap((req, res) => {++ const g = goals.get(req.params.id);++ if (!g || !goalVisible(req, g)) fail(404, 'tips.goal_not_found', 'no such goal');++ const mine = (req.principal.kind === 'user' && req.principal.subject === g.creator_subject) || req.principal.kind === 'service';++ res.json({ goal: goals.present(g, { contributions: mine }) });++ }));++ r.post('/goals', ...write((req, res) => {++ const b = req.body || {};++ const profile = creatorOf(req, b.creator);++ allow(req, CAP.goalCreate, profile.creator_subject);++ res.status(201).json({ goal: goals.create(profile.creator_subject, b) });++ }));++ const ownGoal = (req, cap) => {++ const g = goals.get(req.params.id);++ if (!g) fail(404, 'tips.goal_not_found', 'no such goal');++ allow(req, cap, g.creator_subject);++ return g;++ };++ r.patch('/goals/:id', ...write((req, res) => res.json({ goal: goals.update(ownGoal(req, CAP.goalUpdate), req.body || {}) })));++ r.post('/goals/:id/close', ...write((req, res) => res.json({ goal: goals.close(ownGoal(req, CAP.goalClose)) })));++++ // ── Overlay tokens and configs ───────────────────────────++ r.get('/overlay-tokens', wrap((req, res) => {++ const profile = creatorOf(req, req.query.creator);++ allow(req, CAP.tokenCreate, profile.creator_subject);++ res.json({ tokens: overlays.listTokens(profile.creator_subject).map(overlays.presentToken) });++ }));++ r.post('/overlay-tokens', ...write((req, res) => {++ const b = req.body || {};++ const profile = creatorOf(req, b.creator);++ allow(req, CAP.tokenCreate, profile.creator_subject);++ const by = req.principal.kind === 'service' ? req.principal.sub : req.principal.subject;++ const out = overlays.createToken(profile.creator_subject, { scopes: b.scopes, label: b.label, configId: b.config_id, createdBy: by });++ // Shown once: only its hash is stored. An idempotent replay of this call returns it again to++ // the same caller with the same key, which is the point of the key.++ res.status(201).json({ token: out.token, secret: out.secret, overlay_url: out.overlay_url, events_url: out.events_url });++ }));++ r.post('/overlay-tokens/:id/revoke', ...write((req, res) => {++ const t = overlays.getToken(req.params.id);++ if (!t) fail(404, 'tips.overlay_token_not_found', 'no such overlay token');++ allow(req, CAP.tokenRevoke, t.creator_subject);++ res.json({ token: overlays.revokeToken(t) });++ }));++++ r.get('/overlay-configs', wrap((req, res) => {++ const profile = creatorOf(req, req.query.creator);++ allow(req, CAP.configGet, profile.creator_subject);++ res.json({ configs: overlays.listConfigs(profile.creator_subject).map(overlays.presentConfig) });++ }));++ r.get('/overlay-configs/:id', wrap((req, res) => {++ const c = overlays.getConfig(req.params.id);++ if (!c) fail(404, 'tips.overlay_config_not_found', 'no such overlay config');++ allow(req, CAP.configGet, c.creator_subject);++ res.json({ config: overlays.presentConfig(c) });++ }));++ r.post('/overlay-configs', ...write((req, res) => {++ const b = req.body || {};++ const profile = creatorOf(req, b.creator);++ allow(req, CAP.configUpdate, profile.creator_subject);++ res.status(201).json({ config: overlays.createConfig(profile.creator_subject, b) });++ }));++ r.patch('/overlay-configs/:id', ...write((req, res) => {++ const c = overlays.getConfig(req.params.id);++ if (!c) fail(404, 'tips.overlay_config_not_found', 'no such overlay config');++ allow(req, CAP.configUpdate, c.creator_subject);++ res.json({ config: overlays.updateConfig(c, req.body || {}) });++ }));++++ // ── Simulation ───────────────────────────────────────────++ r.post('/simulate', ...write((req, res) => {++ const b = req.body || {};++ const profile = creatorOf(req, b.creator);++ allow(req, CAP.simulate, profile.creator_subject);++ const by = req.principal.kind === 'service' ? req.principal.sub : req.principal.subject;++ const i = interactions.simulate(profile, b, { by });++ res.status(201).json({ interaction: interactions.present(i, { viewer: 'owner' }) });++ }));++++ return r;++}++++/** Async-safe handler: TipsError → problem+json; anything else → 500. */++function wrap(fn) {++ return (req, res, next) => {++ try {++ const p = fn(req, res, next);++ if (p && typeof p.catch === 'function') p.catch((e) => sendError(req, res, e));++ } catch (e) { sendError(req, res, e); }++ };++}++++function sendError(req, res, e) {++ if (res.headersSent) return;++ if (e instanceof TipsError) {++ return http.sendProblem(res, e.status, e.code, { detail: e.detail || e.message, ctx: req.ov, extra: e.extra && e.status < 500 ? { details: e.extra } : undefined });++ }++ console.error('[Tips] unexpected error:', e);++ return http.sendProblem(res, 500, 'tips.internal', { detail: 'internal error', ctx: req.ov });++}++++module.exports = { v1Router, wrap, sendError, CAP };nnew file mode 100644@@@ -0,0 +1,120 @@++'use strict';++++/**++ * OpenVibe.Tips — creator support: tips, goals, paid messages, TTS and media requests, overlays.++ * Express app factory; server/index.js listens, tests build their own instance.++ *++ * GET /api/health, /api/ready, /release.json++ * /api/v1/* API (service tokens + user tokens, see api/v1.js)++ * POST /internal/events Billing settlement events from OpenVibe.Events (signed webhook + inbox)++ * /auth/* Network SSO session for the pages++ * /overlay/:token[/events|/state] overlays (scoped revocable tokens)++ * /, /dashboard, /receipts, /:handle … server-rendered pages++ *++ * createApp({ config, db, keys, billing, adapters, now, fetchImpl, log }) — everything injectable.++ */++const path = require('path');++const express = require('express');++const cookieParser = require('cookie-parser');++const { http } = require('openvibe-contracts');++const { loadConfig } = require('./config');++const { openDb } = require('./db');++const { createKeyProvider, createUserAuth } = require('./network');++const { createBillingClient } = require('./billing-client');++const { createAdapters } = require('./delivery');++const { createTipsOutbox } = require('./events/outbox');++const { consumerRouter } = require('./events/consumer');++const { createDomain } = require('./domain');++const { createApiAuth } = require('./api/auth');++const { v1Router } = require('./api/v1');++const { createSessionRoutes } = require('./web/session');++const { createWebRoutes } = require('./web/routes');++const { createLayout, assetVersion } = require('./web/layout');++const pages = require('./web/pages');++++const VERSION = require('../package.json').version;++const PUBLIC_DIR = path.join(__dirname, '..', 'public');++++function createApp(opts = {}) {++ const config = opts.config || loadConfig();++ const db = opts.db || openDb(config.dbPath);++ const fetchImpl = opts.fetchImpl || globalThis.fetch;++ const log = opts.log || console;++ const now = opts.now || (() => Date.now());++ const keys = opts.keys || createKeyProvider(config, { fetchImpl, log });++ const userAuth = createUserAuth(config, keys);++ const billing = opts.billing || createBillingClient(config, { fetchImpl });++ const adapters = opts.adapters || createAdapters(config, { fetchImpl });++ const outbox = opts.outbox || createTipsOutbox({ db, config, fetchImpl: opts.eventsFetch, now, log });++ const domain = createDomain({ db, config, outbox, billing, adapters, now, log });++ const apiAuth = createApiAuth({ config, keys, userAuth });++ const release = require('openvibe-shared/release').createRelease({ service: 'tips', root: path.join(__dirname, '..') });++ const layout = createLayout({ config, release });++++ const app = express();++ app.disable('x-powered-by');++ app.set('trust proxy', config.trustProxy);++ app.use(http.middleware());++ app.use((req, res, next) => {++ res.setHeader('X-Content-Type-Options', 'nosniff');++ res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');++ // Pages frame nothing but the Network's /sso/check; overlays are loaded by OBS, not framed by sites.++ res.setHeader('Content-Security-Policy', [++ "default-src 'self'", "script-src 'self' 'unsafe-inline' https://openvibe.network", "style-src 'self' 'unsafe-inline' https://openvibe.network",++ "img-src 'self' data: https:", "media-src 'self' https:", "connect-src 'self' https://openvibe.network", "frame-src 'self' https://openvibe.network",++ "frame-ancestors 'self'", "object-src 'none'", "base-uri 'self'", "form-action 'self' https://openvibe.network https:",++ ].join('; '));++ next();++ });++ app.use(cookieParser());++++ app.get('/api/health', (req, res) => res.json({++ ok: true, service: 'tips', version: VERSION, chat_adapter: config.chat.adapter, events: outbox.status(),++ }));++ app.get('/api/ready', (req, res) => {++ const problems = [];++ try { db.prepare('SELECT 1 FROM settings WHERE id = 1').get(); } catch (e) { problems.push(`database: ${e.message}`); }++ if (!keys.get()) problems.push('Network public key not loaded');++ if (problems.length) return http.sendProblem(res, 503, 'service.not_ready', { detail: problems.join('; '), ctx: req.ov });++ return res.json({ ready: true });++ });++ app.get('/release.json', release.handler);++++ const consumer = consumerRouter({ domain, config, log });++ // Service-to-service only: OpenVibe.Events calls 127.0.0.1:4610 directly; anything that came++ // through nginx carries X-Forwarded-For and is refused (the signature is checked as well).++ app.use('/internal', (req, res, next) => (req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.headers['cf-connecting-ip']++ ? http.sendProblem(res, 404, 'not_found', { ctx: req.ov }) : next()), consumer.router);++ app.use('/api/v1', express.json({ limit: '64kb' }), (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); }, apiAuth.middleware, v1Router({ domain, apiAuth }));++ app.use('/auth', createSessionRoutes(config, userAuth, { fetchImpl }));++ { const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'tips', service: 'tips', host: 'openvibe.tips', name: 'OpenVibe.Tips', profile: 'ugc' })); }++++ app.use(express.static(PUBLIC_DIR, {++ index: false, redirect: false,++ setHeaders(res, filePath) {++ const rel = path.relative(PUBLIC_DIR, filePath).split(path.sep).join('/');++ const v = res.req && res.req.query && res.req.query.v;++ res.setHeader('Cache-Control', v && v === assetVersion(rel) ? 'public, max-age=31536000, immutable' : 'no-cache');++ },++ }));++ app.use(createWebRoutes({ domain, config, layout, userAuth }));++++ app.use((req, res) => {++ if (req.path.startsWith('/api/') || req.path.startsWith('/internal/')) return http.sendProblem(res, 404, 'not_found', { ctx: req.ov });++ return res.status(404).type('html').send(layout.page({ title: 'Not found', robots: 'noindex', body: pages.errorPage({ status: 404, title: 'Nothing here', message: 'That page does not exist.' }) }));++ });++ // eslint-disable-next-line no-unused-vars++ app.use((err, req, res, next) => {++ if (err && err.type === 'entity.parse.failed') return http.sendProblem(res, 400, 'request.malformed_json', { ctx: req.ov });++ if (err && err.type === 'entity.too.large') return http.sendProblem(res, 413, 'request.too_large', { ctx: req.ov });++ log.error('[Tips] unhandled error:', err);++ if (res.headersSent) return undefined;++ if (req.path.startsWith('/api/')) return http.sendProblem(res, 500, 'tips.internal', { ctx: req.ov });++ return res.status(500).type('html').send(layout.page({ title: 'Error', robots: 'noindex', body: pages.errorPage({ status: 500, title: 'Something went wrong', message: 'This one is on us. Please try again.' }) }));++ });++++ Object.assign(app.locals, { config, db, domain, keys, outbox, adapters, billing, consumer });++ return app;++}++++module.exports = { createApp, VERSION };nnew file mode 100644@@@ -0,0 +1,86 @@++'use strict';++++/**++ * OpenVibe.Billing client (the only place money moves, ADR-012). Tips holds a client-credentials++ * token for audience openvibe.billing with:++ *++ * billing.intent.create POST /api/v1/intents checkout: the supporter buys the credit they give++ * billing.transfer.create POST /api/v1/transfers a tip from the supporter's credit to the creator++ * POST /api/v1/transfers/:id/refund a paid media request that never played++ *++ * Every POST carries an Idempotency-Key derived from the interaction id, so a retry after a lost++ * response (or a crash) returns Billing's original transaction instead of moving money twice.++ *++ * Errors: a Billing problem+json becomes a BillingCallError with .status/.code (4xx: Billing++ * refused, e.g. billing.insufficient_funds, billing.frozen); a network failure has no status and is++ * retryable.++ */++const { serviceAuth } = require('openvibe-contracts');++++class BillingCallError extends Error {++ constructor(message, { status = null, code = null, body = null } = {}) {++ super(message);++ this.name = 'BillingCallError';++ this.status = status;++ this.code = code;++ this.body = body;++ }++ get retryable() { return this.status == null || this.status >= 500 || this.status === 429 || this.status === 401 || this.code === 'billing.frozen'; }++}++++function createBillingClient(config, { fetchImpl = globalThis.fetch, tokenClient } = {}) {++ const base = config.billing.url;++ let tokens = tokenClient || null;++ const tokenClientFor = () => {++ if (!tokens) {++ tokens = serviceAuth.createTokenClient({++ tokenUrl: `${config.network.internalUrl}/oauth/token`,++ clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret,++ audience: config.billing.audience,++ scope: 'billing.intent.create billing.transfer.create',++ fetchImpl,++ });++ }++ return tokens;++ };++++ async function call(method, path, { body, key, traceparent, retried = false } = {}) {++ let auth;++ try { auth = await tokenClientFor().authHeaders(); } catch (e) { throw new BillingCallError(`token: ${e.message}`); }++ const headers = { Accept: 'application/json', ...auth };++ if (body !== undefined) headers['Content-Type'] = 'application/json';++ if (key) headers['Idempotency-Key'] = key;++ if (traceparent) headers.traceparent = traceparent;++ let res;++ try {++ res = await fetchImpl(`${base}${path}`, { method, headers, body: body !== undefined ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(10000) });++ } catch (e) {++ throw new BillingCallError(`Billing unreachable: ${e.message}`);++ }++ if (res.status === 401 && !retried) { tokenClientFor().invalidate(); return call(method, path, { body, key, traceparent, retried: true }); }++ const data = await res.json().catch(() => null);++ if (!res.ok) {++ const code = (data && (data.code || data.error)) || `http_${res.status}`;++ throw new BillingCallError(`Billing ${res.status} ${code}: ${(data && data.detail) || ''}`.trim(), { status: res.status, code, body: data });++ }++ return data;++ }++++ return {++ /** { intent, checkout_url } — kind purchase: the supporter buys `bits` of credit. */++ createIntent: ({ provider, subject, bits, successUrl, cancelUrl, key, traceparent }) => call('POST', '/api/v1/intents', {++ body: { provider, kind: 'purchase', subject: { type: 'user', id: subject }, bits, success_url: successUrl, cancel_url: cancelUrl },++ key, traceparent,++ }),++ /** { transaction } — credit of `from` → payable of `to`, tagged with the interaction. */++ createTransfer: ({ from, to, amount, kind, target, message, key, traceparent }) => call('POST', '/api/v1/transfers', {++ body: { from: { type: 'user', id: from }, to: { type: 'user', id: to }, amount, kind, target, message: message || undefined },++ key, traceparent,++ }),++ refundTransfer: ({ txnId, amount, reason, key }) => call('POST', `/api/v1/transfers/${encodeURIComponent(txnId)}/refund`, { body: { amount, reason }, key }),++ rates: () => call('GET', '/api/v1/rates'),++ };++}++++module.exports = { createBillingClient, BillingCallError };nnew file mode 100644@@@ -0,0 +1,111 @@++'use strict';++++/**++ * OpenVibe.Tips configuration. Everything comes from the environment (.env in development,++ * /etc/openvibe/tips.env in production). loadConfig(env) is pure so tests build their own.++ *++ * Tips never moves money (ADR-012): amounts here are product limits (minimums, message lengths),++ * never prices or rates. Prices and the value of a bit are Billing's.++ */++require('dotenv').config();++++const int = (v, d) => { const n = parseInt(v, 10); return Number.isFinite(n) ? n : d; };++const bool = (v, d = false) => (v == null || v === '' ? d : ['1', 'true', 'yes', 'on'].includes(String(v).toLowerCase()));++const trim = (u) => String(u || '').replace(/\/+$/, '');++const list = (v) => String(v || '').split(',').map((s) => s.trim()).filter(Boolean);++++function loadConfig(env = process.env) {++ const nodeEnv = env.NODE_ENV || 'development';++ const isProduction = nodeEnv === 'production';++ const port = int(env.PORT, 4610);++ const networkUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');++ const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://openvibe.tips' : `http://localhost:${port}`));++ const chatAdapter = String(env.TIPS_CHAT_ADAPTER || (nodeEnv === 'production' ? 'none' : 'test')).toLowerCase();++ return {++ nodeEnv,++ isProduction,++ port,++ host: env.HOST || '127.0.0.1',++ baseUrl,++ trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 1,++ dbPath: env.TIPS_DB_PATH || './data/tips.db',++++ // Identity: service tokens and user tokens are RS256 JWTs signed by OpenVibe.Network.++ network: {++ url: networkUrl,++ internalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),++ issuer: trim(env.OV_NETWORK_ISSUER || networkUrl),++ publicKey: env.OV_NETWORK_PUBLIC_KEY ? env.OV_NETWORK_PUBLIC_KEY.replace(/\\n/g, '\n') : null,++ },++ audience: env.TIPS_AUDIENCE || 'openvibe.tips',++ // Tips' own client credentials (client `tips` in the Network): service tokens for Billing,++ // Events, Live (chat delivery) and Network (identity resolve for the importer); and the++ // OAuth code flow for browser sign-in (redirect ${baseUrl}/auth/callback).++ oauth: {++ clientId: env.OV_OAUTH_CLIENT_ID || 'tips',++ clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',++ redirectUri: env.OV_OAUTH_REDIRECT_URI || `${baseUrl}/auth/callback`,++ scope: env.OV_OAUTH_SCOPE || 'profile',++ },++ cookies: { secure: env.COOKIE_SECURE != null ? bool(env.COOKIE_SECURE) : isProduction },++ // Signs the anti-forgery tokens of the server-rendered forms.++ formSecret: env.TIPS_FORM_SECRET || '',++++ billing: {++ url: trim(env.BILLING_URL || 'http://127.0.0.1:4600'),++ audience: env.BILLING_AUDIENCE || 'openvibe.billing',++ // Checkout providers Tips offers (must be enabled on Billing too).++ providers: list(env.TIPS_CHECKOUT_PROVIDERS || 'powerchat'),++ // Billing's minimum purchase; a checkout tip below it is refused here with a clear message.++ minPurchaseBits: int(env.TIPS_MIN_CHECKOUT_BITS, 100),++ retryMs: int(env.TIPS_BILLING_RETRY_MS, 30_000),++ // Billing answers a PowerChat intent with a checkout_ref (pcorder:…) instead of a URL. When++ // set, this template turns it into the link: {ref}, {cents} and {bits} are filled in.++ powerchatLinkTemplate: env.TIPS_POWERCHAT_LINK_TEMPLATE || '',++ },++++ events: {++ url: trim(env.EVENTS_URL || ''),++ intervalMs: int(env.EVENTS_RELAY_INTERVAL_MS, 2000),++ // Signing secret(s) of Tips' OpenVibe.Events subscription (billing.transaction.*).++ // Comma-separated so a rotation can accept the old and the new value.++ webhookSecrets: list(env.TIPS_EVENTS_SECRET),++ },++++ // Chat delivery (paid messages, TTS, media requests, the tip line):++ // live-chat OpenVibe.Live's /internal/tips/deliveries (docs/live-patch.diff)++ // test recorded here, nothing leaves the process (development, tests)++ // none no chat effects are created++ chat: {++ adapter: ['live-chat', 'test', 'none'].includes(chatAdapter) ? chatAdapter : 'none',++ liveUrl: trim(env.LIVE_INTERNAL_URL || 'http://127.0.0.1:3000'),++ liveAudience: env.LIVE_AUDIENCE || 'openvibe.live',++ maxAttempts: int(env.TIPS_DELIVERY_MAX_ATTEMPTS, 6),++ backoffMs: list(env.TIPS_DELIVERY_BACKOFF_MS || '2000,10000,60000,300000,900000').map(Number),++ },++++ overlays: {++ // A delivery no overlay received within this window is recorded failed (tips.overlay.failed).++ ttlMs: int(env.TIPS_OVERLAY_TTL_MS, 10 * 60 * 1000),++ heartbeatMs: int(env.TIPS_OVERLAY_HEARTBEAT_MS, 25_000),++ replayLimit: int(env.TIPS_OVERLAY_REPLAY_LIMIT, 50),++ },++++ limits: {++ maxBits: int(env.TIPS_MAX_BITS, 10_000_000), // Billing's maximum++ messageChars: int(env.TIPS_MESSAGE_CHARS, 300), // Live's donation message limit++ ttsHardCap: int(env.TIPS_TTS_HARD_CAP, 1200), // Live's hard TTS length cap++ goalTitleChars: 120, // Live's goal title limit++ },++++ jobs: {++ enabled: env.TIPS_JOBS !== 'off',++ intervalMs: int(env.TIPS_JOBS_INTERVAL_MS, 2000),++ },++ liveUrl: trim(env.LIVE_URL || 'https://openvibe.live'),++ // Media requests Tips accepts (the media queue owner re-checks them).++ mediaHosts: list(env.TIPS_MEDIA_HOSTS || 'youtube.com,www.youtube.com,m.youtube.com,music.youtube.com,youtu.be'),++ };++}++++module.exports = { loadConfig };nnew file mode 100644@@@ -0,0 +1,272 @@++'use strict';++++/**++ * Tips' own SQLite database (WAL), created on boot, idempotent.++ *++ * The ten product tables (roadmap §15.11) store interaction state and REFERENCES to Billing++ * transactions — never a mutable cash balance. Amounts are integer vibes-bits as Billing counts++ * them; a creator's totals are always derived from settled interactions.++ *++ * creator_tip_profiles one per creator subject: page switch, minimums, TTS/media settings++ * tip_interactions one logical interaction; payment_state (mirrored from Billing, keyed by++ * Billing txn id) is separate from delivery_state (Tips' own effects)++ * tip_goals creator goals++ * tip_goal_contributions settled interactions counted toward a goal (never tests, never pending)++ * paid_messages highlighted paid chat messages and TTS requests — created on settlement++ * paid_media_requests paid media requests — created on settlement++ * overlay_configs alert / goal overlay settings++ * overlay_deliveries what overlays were sent (monotonic seq per creator, replayable)++ * interaction_effects the retryable delivery work of an interaction (chat line, TTS, media, overlay)++ * migration_maps legacy (Live) row → Tips entity, with held/excluded reasons++ *++ * Supporting: overlay_tokens (hashed, scoped, revocable), api_idempotency, import_runs,++ * event_outbox + idempotency_receipts (openvibe-sdk outbox and inbox), settings.++ */++const fs = require('fs');++const path = require('path');++const Database = require('better-sqlite3');++++const SCHEMA = `++CREATE TABLE IF NOT EXISTS settings (++ id INTEGER PRIMARY KEY CHECK (id = 1),++ schema_version INTEGER NOT NULL,++ created_at TEXT NOT NULL++);++++CREATE TABLE IF NOT EXISTS creator_tip_profiles (++ creator_subject TEXT PRIMARY KEY, -- usr_…++ handle TEXT NOT NULL UNIQUE, -- lower-case Network username (projection)++ display_name TEXT NOT NULL,++ avatar_url TEXT,++ headline TEXT,++ page_enabled INTEGER NOT NULL DEFAULT 0, -- public page + indexing++ accepting INTEGER NOT NULL DEFAULT 1, -- new tips accepted++ min_amount INTEGER NOT NULL DEFAULT 1,++ paid_message_min INTEGER NOT NULL DEFAULT 100,++ tts_enabled INTEGER NOT NULL DEFAULT 0,++ tts_min_amount INTEGER NOT NULL DEFAULT 100,++ tts_max_chars INTEGER NOT NULL DEFAULT 200,++ tts_voice TEXT NOT NULL DEFAULT 'gary',++ media_requests_enabled INTEGER NOT NULL DEFAULT 0,++ media_request_min INTEGER NOT NULL DEFAULT 25,++ media_max_seconds INTEGER NOT NULL DEFAULT 600,++ revision INTEGER NOT NULL DEFAULT 1,++ created_at TEXT NOT NULL,++ updated_at TEXT NOT NULL++);++++CREATE TABLE IF NOT EXISTS tip_interactions (++ id TEXT PRIMARY KEY, -- tint_<ULID>++ creator_subject TEXT NOT NULL,++ supporter_subject TEXT, -- null: external/anonymous/unmapped++ supporter_name TEXT, -- display projection at the time++ kind TEXT NOT NULL CHECK (kind IN ('tip', 'paid_message', 'tts', 'media_request')),++ amount INTEGER NOT NULL CHECK (amount > 0),++ currency TEXT NOT NULL DEFAULT 'vibes-bits',++ amount_cents INTEGER, -- money that arrived (provider/external)++ message TEXT,++ request TEXT NOT NULL DEFAULT '{}', -- JSON: tts text/voice, media url, goal_id, target++ funding TEXT NOT NULL CHECK (funding IN ('credit', 'checkout', 'provider', 'external', 'none')),++ settlement TEXT NOT NULL CHECK (settlement IN ('billing', 'external', 'simulated', 'imported')),++ payment_state TEXT NOT NULL CHECK (payment_state IN ('pending', 'settled', 'reversed', 'failed')),++ delivery_state TEXT NOT NULL CHECK (delivery_state IN ('awaiting_payment', 'queued', 'delivered', 'failed', 'cancelled')),++ test INTEGER NOT NULL DEFAULT 0, -- simulation or Billing test money: never counted++ billing_txn_id TEXT UNIQUE, -- the Billing transaction that settled it++ billing_intent_id TEXT UNIQUE, -- checkout: the Billing payment intent++ funding_txn_id TEXT UNIQUE, -- checkout: the purchase that funded it++ reversal_txn_ids TEXT NOT NULL DEFAULT '[]',++ reversed_bits INTEGER NOT NULL DEFAULT 0, -- bits Billing took back from the creator++ checkout_url TEXT,++ checkout_ref TEXT,++ provider TEXT,++ provider_ref TEXT,++ legacy_source TEXT, -- 'live:transactions:<id>' etc.++ idempotency_key TEXT UNIQUE,++ failure TEXT,++ origin TEXT NOT NULL DEFAULT 'tips', -- tips | billing (settled elsewhere) | external | import++ transfer_due INTEGER NOT NULL DEFAULT 0, -- a Billing transfer still has to be (re)tried++ transfer_attempts INTEGER NOT NULL DEFAULT 0,++ next_transfer_at INTEGER NOT NULL DEFAULT 0,++ target TEXT, -- JSON EntityRef (e.g. a Live stream)++ created_at TEXT NOT NULL,++ settled_at TEXT,++ reversed_at TEXT,++ delivered_at TEXT,++ updated_at TEXT NOT NULL,++ UNIQUE (provider, provider_ref),++ UNIQUE (legacy_source)++);++CREATE INDEX IF NOT EXISTS idx_tint_creator ON tip_interactions (creator_subject, created_at);++CREATE INDEX IF NOT EXISTS idx_tint_supporter ON tip_interactions (supporter_subject, created_at);++CREATE INDEX IF NOT EXISTS idx_tint_pending ON tip_interactions (payment_state, funding);++++CREATE TABLE IF NOT EXISTS tip_goals (++ id TEXT PRIMARY KEY, -- tgoal_<ULID>++ creator_subject TEXT NOT NULL,++ title TEXT NOT NULL,++ description TEXT,++ target_amount INTEGER NOT NULL CHECK (target_amount > 0),++ currency TEXT NOT NULL DEFAULT 'vibes-bits',++ image_url TEXT,++ status TEXT NOT NULL CHECK (status IN ('active', 'closed')),++ sort_order INTEGER NOT NULL DEFAULT 0,++ reached_at TEXT,++ closed_at TEXT,++ opening_amount INTEGER NOT NULL DEFAULT 0, -- carried over from Live's goal column (not tied to tips)++ legacy_source TEXT UNIQUE,++ revision INTEGER NOT NULL DEFAULT 1,++ created_at TEXT NOT NULL,++ updated_at TEXT NOT NULL++);++CREATE INDEX IF NOT EXISTS idx_goal_creator ON tip_goals (creator_subject, status);++++CREATE TABLE IF NOT EXISTS tip_goal_contributions (++ id INTEGER PRIMARY KEY AUTOINCREMENT,++ goal_id TEXT NOT NULL REFERENCES tip_goals(id),++ interaction_id TEXT NOT NULL REFERENCES tip_interactions(id),++ amount INTEGER NOT NULL CHECK (amount > 0),++ reversed_amount INTEGER NOT NULL DEFAULT 0,++ created_at TEXT NOT NULL,++ updated_at TEXT NOT NULL,++ UNIQUE (goal_id, interaction_id)++);++++CREATE TABLE IF NOT EXISTS paid_messages (++ id TEXT PRIMARY KEY, -- tpm_<ULID>++ interaction_id TEXT NOT NULL UNIQUE REFERENCES tip_interactions(id),++ creator_subject TEXT NOT NULL,++ kind TEXT NOT NULL CHECK (kind IN ('paid_message', 'tts')),++ text TEXT NOT NULL,++ voice TEXT,++ highlight_seconds INTEGER NOT NULL DEFAULT 0,++ status TEXT NOT NULL CHECK (status IN ('queued', 'delivered', 'failed', 'cancelled')),++ chat_ref TEXT, -- JSON reference the chat adapter returned++ test INTEGER NOT NULL DEFAULT 0,++ created_at TEXT NOT NULL,++ updated_at TEXT NOT NULL++);++++CREATE TABLE IF NOT EXISTS paid_media_requests (++ id TEXT PRIMARY KEY, -- tmr_<ULID>++ interaction_id TEXT NOT NULL UNIQUE REFERENCES tip_interactions(id),++ creator_subject TEXT NOT NULL,++ url TEXT NOT NULL,++ provider TEXT,++ status TEXT NOT NULL CHECK (status IN ('queued', 'accepted', 'played', 'skipped', 'failed', 'cancelled', 'refunded')),++ queue_ref TEXT, -- JSON reference returned by the queue owner++ test INTEGER NOT NULL DEFAULT 0,++ created_at TEXT NOT NULL,++ updated_at TEXT NOT NULL++);++++CREATE TABLE IF NOT EXISTS overlay_configs (++ id TEXT PRIMARY KEY, -- tovc_<ULID>++ creator_subject TEXT NOT NULL,++ kind TEXT NOT NULL CHECK (kind IN ('alerts', 'goal')),++ name TEXT NOT NULL,++ settings TEXT NOT NULL DEFAULT '{}',++ goal_id TEXT,++ revision INTEGER NOT NULL DEFAULT 1,++ created_at TEXT NOT NULL,++ updated_at TEXT NOT NULL++);++CREATE INDEX IF NOT EXISTS idx_ovc_creator ON overlay_configs (creator_subject);++++CREATE TABLE IF NOT EXISTS overlay_tokens (++ id TEXT PRIMARY KEY, -- tovt_<ULID>++ creator_subject TEXT NOT NULL,++ token_hash TEXT NOT NULL UNIQUE, -- sha256 of the secret; the secret is shown once++ scopes TEXT NOT NULL, -- JSON ["alerts","goals"]++ config_id TEXT,++ label TEXT,++ created_by TEXT NOT NULL,++ created_at TEXT NOT NULL,++ last_used_at TEXT,++ revoked_at TEXT++);++CREATE INDEX IF NOT EXISTS idx_ovt_creator ON overlay_tokens (creator_subject);++++CREATE TABLE IF NOT EXISTS overlay_deliveries (++ seq INTEGER PRIMARY KEY AUTOINCREMENT, -- the SSE event id (monotonic)++ id TEXT NOT NULL UNIQUE, -- tovd_<ULID>++ creator_subject TEXT NOT NULL,++ kind TEXT NOT NULL CHECK (kind IN ('alert', 'goal')),++ interaction_id TEXT,++ goal_id TEXT,++ payload TEXT NOT NULL,++ test INTEGER NOT NULL DEFAULT 0,++ status TEXT NOT NULL CHECK (status IN ('pending', 'delivered', 'failed')),++ delivered_at TEXT,++ failed_at TEXT,++ sends INTEGER NOT NULL DEFAULT 0, -- times written to an overlay (replays included)++ dedupe_key TEXT NOT NULL UNIQUE, -- alert:<interaction> | goal:<goal>:<interaction|revision>++ created_at TEXT NOT NULL++);++CREATE INDEX IF NOT EXISTS idx_ovd_creator ON overlay_deliveries (creator_subject, seq);++++CREATE TABLE IF NOT EXISTS interaction_effects (++ id INTEGER PRIMARY KEY AUTOINCREMENT,++ interaction_id TEXT NOT NULL REFERENCES tip_interactions(id),++ effect TEXT NOT NULL CHECK (effect IN ('chat_line', 'paid_message', 'tts', 'media_request', 'overlay_alert')),++ adapter TEXT NOT NULL,++ state TEXT NOT NULL CHECK (state IN ('queued', 'delivered', 'failed', 'cancelled')),++ attempts INTEGER NOT NULL DEFAULT 0,++ next_attempt_at INTEGER NOT NULL DEFAULT 0,++ last_error TEXT,++ result TEXT,++ created_at TEXT NOT NULL,++ updated_at TEXT NOT NULL,++ UNIQUE (interaction_id, effect)++);++CREATE INDEX IF NOT EXISTS idx_effects_due ON interaction_effects (state, next_attempt_at);++++CREATE TABLE IF NOT EXISTS migration_maps (++ id INTEGER PRIMARY KEY AUTOINCREMENT,++ source TEXT NOT NULL, -- 'live'++ source_table TEXT NOT NULL,++ source_id TEXT NOT NULL,++ target_type TEXT, -- 'interaction' | 'goal'++ target_id TEXT,++ status TEXT NOT NULL CHECK (status IN ('imported', 'held', 'excluded')),++ reason TEXT,++ billing_txn_id TEXT,++ run_id TEXT NOT NULL,++ created_at TEXT NOT NULL,++ updated_at TEXT NOT NULL,++ UNIQUE (source, source_table, source_id)++);++++CREATE TABLE IF NOT EXISTS import_runs (++ id TEXT PRIMARY KEY,++ source TEXT NOT NULL,++ dry_run INTEGER NOT NULL,++ started_at TEXT NOT NULL,++ finished_at TEXT,++ report TEXT++);++++CREATE TABLE IF NOT EXISTS api_idempotency (++ key TEXT PRIMARY KEY, -- <principal>:<Idempotency-Key>++ request_hash TEXT NOT NULL,++ method TEXT NOT NULL,++ path TEXT NOT NULL,++ status INTEGER NOT NULL,++ response TEXT NOT NULL,++ created_at TEXT NOT NULL++);++`;++++const SCHEMA_VERSION = 1;++++function openDb(file) {++ if (file !== ':memory:') fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true });++ const db = new Database(file);++ db.pragma('journal_mode = WAL');++ db.pragma('foreign_keys = ON');++ db.pragma('busy_timeout = 5000');++ db.exec(SCHEMA);++ db.prepare('INSERT OR IGNORE INTO settings (id, schema_version, created_at) VALUES (1, ?, ?)').run(SCHEMA_VERSION, new Date().toISOString());++ return db;++}++++module.exports = { openDb, SCHEMA_VERSION };nnew file mode 100644@@@ -0,0 +1,50 @@++'use strict';++++/**++ * Chat delivery seam. An adapter delivers one effect of a settled interaction to the product that++ * owns the chat room, TTS queue or media queue:++ *++ * adapter = { name, async deliver(job) → { ref? } } throw to fail; err.permanent = true stops retries++ * job = { delivery_id, effect: 'chat_line'|'paid_message'|'tts'|'media_request', test,++ * creator: SubjectRef, supporter: { name, subject? }, interaction: { id, kind, amount, currency, message },++ * text, tts?: { text, voice }, media?: { url }, highlight_seconds?, target?: EntityRef }++ *++ * Adapters (TIPS_CHAT_ADAPTER):++ * live-chat OpenVibe.Live's /internal/tips/deliveries — the existing donation path (broadcast to the++ * channel room + global, saved as a 'donation' chat message, the alert sound); see++ * docs/live-patch.diff. Used until OpenVibe.Chat exposes a public paid-message API.++ * test records jobs in memory (development, tests, and simulations when no chat target should++ * hear a test)++ * none no chat effects are created at all++ *++ * A delivery failure is retried with backoff and finally recorded as failed on the effect; it never++ * touches the interaction's payment state (the money is Billing's and already settled).++ */++const { createLiveChatAdapter } = require('./live-chat');++++function createTestAdapter({ name = 'test' } = {}) {++ const jobs = [];++ const state = { failWith: null, permanent: false };++ return {++ name,++ jobs,++ state,++ async deliver(job) {++ if (state.failWith) {++ const e = new Error(state.failWith);++ e.permanent = state.permanent;++ throw e;++ }++ jobs.push(job);++ return { ref: { adapter: name, n: jobs.length } };++ },++ };++}++++function createAdapters(config, { fetchImpl } = {}) {++ const adapters = { test: createTestAdapter() };++ if (config.chat.adapter === 'live-chat') adapters['live-chat'] = createLiveChatAdapter(config, { fetchImpl });++ return adapters;++}++++module.exports = { createAdapters, createTestAdapter };nnew file mode 100644@@@ -0,0 +1,59 @@++'use strict';++++/**++ * live-chat adapter: posts a paid effect into a creator's OpenVibe.Live chat through Live's++ * existing donation path.++ *++ * POST ${LIVE_INTERNAL_URL}/internal/tips/deliveries++ * Authorization: Bearer <Tips service token, audience openvibe.live, capability live.tips_delivery.write>++ * Idempotency-Key: <delivery_id> (Live answers a repeat with the first result)++ * body: the job (server/delivery/index.js)++ * → 200 { ok: true, ref: { chat_message_id?, media_request_id? } }++ *++ * Live has no such route today: docs/live-patch.diff adds it (server/tips/delivery-routes.js,++ * loopback-only like every /internal route, guarded by Live's service-guard). Live maps the creator++ * subject to its channel through linked_accounts and then does exactly what POST /api/funds/donate++ * does after the money moved: broadcastToChannelRoom + broadcastGlobal of the donation event,++ * saveChatMessage(message_type 'donation'), the donation alert sound; TTS through++ * synthesizeAndBroadcastTTS; media requests into its media queue at no charge (Billing already++ * charged). When OpenVibe.Chat publishes a paid-message capability, a `chat` adapter replaces this.++ *++ * 4xx answers other than 408/409/425/429 are permanent (the effect fails at once); everything++ * else is retried by the effects worker.++ */++const { serviceAuth } = require('openvibe-contracts');++++function createLiveChatAdapter(config, { fetchImpl = globalThis.fetch, tokenClient } = {}) {++ let tokens = tokenClient || null;++ const client = () => {++ if (!tokens) {++ tokens = serviceAuth.createTokenClient({++ tokenUrl: `${config.network.internalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,++ audience: config.chat.liveAudience, scope: 'live.tips_delivery.write', fetchImpl,++ });++ }++ return tokens;++ };++++ async function deliver(job, retried = false) {++ const headers = { 'Content-Type': 'application/json', Accept: 'application/json', 'Idempotency-Key': job.delivery_id, ...(await client().authHeaders()) };++ let res;++ try {++ res = await fetchImpl(`${config.chat.liveUrl}/internal/tips/deliveries`, { method: 'POST', headers, body: JSON.stringify(job), signal: AbortSignal.timeout(10000) });++ } catch (e) {++ throw new Error(`Live unreachable: ${e.message}`);++ }++ if (res.status === 401 && !retried) { client().invalidate(); return deliver(job, true); }++ const body = await res.json().catch(() => null);++ if (!res.ok) {++ const e = new Error(`Live ${res.status}: ${(body && (body.detail || body.error || body.code)) || 'refused'}`);++ e.permanent = res.status >= 400 && res.status < 500 && ![401, 408, 409, 425, 429].includes(res.status);++ throw e;++ }++ return { ref: (body && body.ref) || null };++ }++++ return { name: 'live-chat', deliver };++}++++module.exports = { createLiveChatAdapter };nnew file mode 100644@@@ -0,0 +1,117 @@++'use strict';++++/**++ * The effects worker: delivers the queued interaction_effects of settled interactions through the++ * chat adapters, with retries. One effect = one adapter call with a stable delivery id++ * (`<interaction>:<effect>`), so the receiving side can drop a repeat after a lost response.++ *++ * A failure is retried with backoff (TIPS_DELIVERY_BACKOFF_MS) up to TIPS_DELIVERY_MAX_ATTEMPTS, or++ * stops at once when the adapter says it is permanent. The interaction's delivery_state is then++ * recomputed (failed → tips.interaction.failed). payment_state is never touched here.++ */++const { iso, json } = require('../util');++++function createEffects(ctx) {++ const { db, config } = ctx;++ let running = false;++ let kickTimer = null;++++ function jobFor(effect, i, profile) {++ const req = json(i.request, {});++ const name = i.supporter_name || 'Someone';++ const amount = i.amount.toLocaleString('en-US');++ const job = {++ delivery_id: `${i.id}:${effect.effect}`,++ effect: effect.effect,++ test: !!i.test,++ creator: { type: 'user', id: i.creator_subject, handle: profile ? profile.handle : null },++ supporter: { name, subject: i.supporter_subject ? { type: 'user', id: i.supporter_subject } : null },++ interaction: { id: i.id, kind: i.kind, amount: i.amount, currency: i.currency, message: i.message || null },++ text: `${name} tipped ${amount} Vibes${i.message ? `: ${i.message}` : ''}`,++ target: json(i.target, null),++ };++ if (effect.effect === 'paid_message') job.highlight_seconds = req.highlight_seconds || 0;++ if (effect.effect === 'tts') job.tts = req.tts;++ if (effect.effect === 'media_request') { job.media = req.media; job.text = `${name} requested media for ${amount} Vibes`; }++ return job;++ }++++ function markDone(effect, result) {++ ctx.tx(() => {++ const at = iso(ctx.now());++ const r = db.prepare("UPDATE interaction_effects SET state = 'delivered', attempts = attempts + 1, last_error = NULL, result = ?, updated_at = ? WHERE id = ? AND state = 'queued'")++ .run(JSON.stringify(result || null), at, effect.id);++ if (!r.changes) return; // cancelled meanwhile (a reversal): the delivery stays recorded on the adapter's side++ const ref = JSON.stringify((result && result.ref) || null);++ if (effect.effect === 'paid_message' || effect.effect === 'tts') {++ db.prepare("UPDATE paid_messages SET status = 'delivered', chat_ref = ?, updated_at = ? WHERE interaction_id = ? AND status = 'queued'").run(ref, at, effect.interaction_id);++ }++ if (effect.effect === 'media_request') {++ db.prepare("UPDATE paid_media_requests SET status = 'accepted', queue_ref = ?, updated_at = ? WHERE interaction_id = ? AND status = 'queued'").run(ref, at, effect.interaction_id);++ }++ ctx.interactions.recomputeDelivery(effect.interaction_id);++ });++ }++++ function markFailed(effect, err) {++ ctx.tx(() => {++ const at = iso(ctx.now());++ const attempts = effect.attempts + 1;++ const final = err.permanent || attempts >= config.chat.maxAttempts;++ const backoff = config.chat.backoffMs[Math.min(attempts - 1, config.chat.backoffMs.length - 1)] || 60000;++ db.prepare('UPDATE interaction_effects SET state = ?, attempts = ?, next_attempt_at = ?, last_error = ?, updated_at = ? WHERE id = ? AND state = \'queued\'')++ .run(final ? 'failed' : 'queued', attempts, ctx.now() + backoff, String(err.message || err).slice(0, 500), at, effect.id);++ if (final) {++ if (effect.effect === 'paid_message' || effect.effect === 'tts') db.prepare("UPDATE paid_messages SET status = 'failed', updated_at = ? WHERE interaction_id = ? AND status = 'queued'").run(at, effect.interaction_id);++ if (effect.effect === 'media_request') db.prepare("UPDATE paid_media_requests SET status = 'failed', updated_at = ? WHERE interaction_id = ? AND status = 'queued'").run(at, effect.interaction_id);++ ctx.interactions.recomputeDelivery(effect.interaction_id);++ }++ });++ ctx.outboxKick();++ }++++ async function runOne(effect) {++ const i = ctx.interactions.get(effect.interaction_id);++ if (!i || i.payment_state !== 'settled') {++ // Reversed or failed before its turn: nothing to deliver.++ ctx.tx(() => ctx.interactions.cancelQueued(i, 'payment_not_settled'));++ return 'cancelled';++ }++ const adapter = ctx.adapters[effect.adapter];++ if (!adapter) { markFailed(effect, Object.assign(new Error(`no adapter ${effect.adapter} configured`), { permanent: true })); return 'failed'; }++ try {++ const result = await adapter.deliver(jobFor(effect, i, ctx.profiles.bySubject(i.creator_subject)));++ markDone(effect, result);++ return 'delivered';++ } catch (e) {++ markFailed(effect, e);++ return 'retry';++ }++ }++++ /** Deliver every due effect once. */++ async function drain() {++ if (running) return { busy: true };++ running = true;++ const out = { delivered: 0, retry: 0, failed: 0, cancelled: 0 };++ try {++ for (;;) {++ const due = db.prepare("SELECT * FROM interaction_effects WHERE state = 'queued' AND next_attempt_at <= ? ORDER BY id LIMIT 50").all(ctx.now());++ if (!due.length) break;++ for (const e of due) { const r = await runOne(e); out[r] = (out[r] || 0) + 1; }++ if (due.length < 50) break;++ }++ } finally { running = false; }++ return out;++ }++++ function kick() {++ if (!config.jobs.enabled || kickTimer) return;++ kickTimer = setTimeout(() => { kickTimer = null; drain().catch((e) => ctx.log.warn('[Tips] effects:', e.message)); }, 0);++ if (kickTimer.unref) kickTimer.unref();++ }++++ return { drain, kick, jobFor };++}++++module.exports = { createEffects };nnew file mode 100644@@@ -0,0 +1,169 @@++'use strict';++++/**++ * Creator goals. A goal's total is DERIVED: the sum of its contributions, and a contribution is++ * written only when an interaction settles (never while pending, never for a simulation). A++ * reversal lowers the contribution by what Billing took back from the creator.++ *++ * Which goal a tip counts toward (Live's rule): the goal the supporter picked if it is the++ * creator's and active, otherwise the creator's only active goal; with several active goals and++ * no pick, none.++ *++ * Every change bumps the goal's revision and emits tips.goal.updated plus an overlay delivery.++ */++const { fail, iso, prefixedId, text, positiveInt } = require('../util');++const { safeUrl } = require('./profiles');++++function createGoals(ctx) {++ const { db } = ctx;++++ const get = (id) => db.prepare('SELECT * FROM tip_goals WHERE id = ?').get(String(id || '')) || null;++ /** Settled contributions, plus the opening amount a goal imported from Live carried over. */++ const totalOf = (goalId) => {++ const r = db.prepare('SELECT COALESCE(SUM(amount - reversed_amount), 0) AS n, COUNT(*) AS c FROM tip_goal_contributions WHERE goal_id = ?').get(goalId);++ const g = db.prepare('SELECT opening_amount FROM tip_goals WHERE id = ?').get(goalId);++ return { n: r.n + ((g && g.opening_amount) || 0), c: r.c, contributions: r.n, opening: (g && g.opening_amount) || 0 };++ };++++ function list(creator, { status } = {}) {++ const rows = status++ ? db.prepare('SELECT * FROM tip_goals WHERE creator_subject = ? AND status = ? ORDER BY sort_order, created_at').all(creator, status)++ : db.prepare("SELECT * FROM tip_goals WHERE creator_subject = ? ORDER BY status = 'active' DESC, sort_order, created_at DESC").all(creator);++ return rows;++ }++++ function present(g, { contributions = false } = {}) {++ if (!g) return null;++ const t = totalOf(g.id);++ const out = {++ id: g.id, creator: { type: 'user', id: g.creator_subject }, title: g.title, description: g.description || null,++ target_amount: g.target_amount, current_amount: t.n, currency: g.currency, supporters_count: t.c,++ carried_over_amount: t.opening,++ percent: Math.min(100, Math.floor((t.n * 100) / g.target_amount)), reached: !!g.reached_at, reached_at: g.reached_at || null,++ image_url: g.image_url || null, status: g.status, sort_order: g.sort_order, revision: g.revision,++ created_at: g.created_at, updated_at: g.updated_at, closed_at: g.closed_at || null,++ };++ if (contributions) {++ out.contributions = db.prepare(`SELECT c.interaction_id, c.amount, c.reversed_amount, c.created_at, i.supporter_name++ FROM tip_goal_contributions c JOIN tip_interactions i ON i.id = c.interaction_id WHERE c.goal_id = ? ORDER BY c.id DESC LIMIT 200`).all(g.id);++ }++ return out;++ }++++ /** Inside a transaction: bump revision, emit the event, queue the overlay delivery. */++ function changed(goalId, reason, { interactionId = null, by = null } = {}) {++ const at = iso(ctx.now());++ db.prepare('UPDATE tip_goals SET revision = revision + 1, updated_at = ? WHERE id = ?').run(at, goalId);++ const g = get(goalId);++ const view = present(g);++ ctx.outbox.emit('tips.goal.updated', { type: 'goal', id: g.id, revision: g.revision }, {++ goal_id: g.id, creator: view.creator, title: g.title, target_amount: g.target_amount, current_amount: view.current_amount,++ currency: g.currency, status: g.status, reached: view.reached, reason, interaction_id: interactionId,++ });++ ctx.overlays.addGoalDelivery(g.creator_subject, view, { reason, interactionId, by, dedupe: `goal:${g.id}:r${g.revision}` });++ return view;++ }++++ function fields(input, partial) {++ const out = {};++ if (!partial || input.title !== undefined) {++ const t = text(input.title, 'title', ctx.config.limits.goalTitleChars);++ if (!t) fail(422, 'tips.invalid_input', 'a goal needs a title');++ out.title = t;++ }++ if (input.description !== undefined) out.description = text(input.description, 'description', 1000);++ if (!partial || input.target_amount !== undefined) out.target_amount = positiveInt(input.target_amount, 'target_amount', ctx.config.limits.maxBits);++ if (input.image_url !== undefined) {++ out.image_url = input.image_url ? safeUrl(input.image_url) : null;++ if (input.image_url && !out.image_url) fail(422, 'tips.invalid_input', 'image_url must be an https URL');++ }++ if (input.sort_order !== undefined && input.sort_order !== '') {++ const n = Number(input.sort_order);++ if (!Number.isInteger(n) || n < 0 || n > 1000) fail(422, 'tips.invalid_input', 'sort_order must be 0-1000');++ out.sort_order = n;++ }++ return out;++ }++++ function create(creator, input = {}) {++ const f = fields(input, false);++ const count = db.prepare("SELECT COUNT(*) AS n FROM tip_goals WHERE creator_subject = ? AND status = 'active'").get(creator).n;++ if (count >= 20) fail(409, 'tips.too_many_goals', 'close a goal before adding another (20 active at most)');++ return ctx.tx(() => {++ const at = iso(ctx.now());++ const id = prefixedId('tgoal', ctx.now());++ db.prepare(`INSERT INTO tip_goals (id, creator_subject, title, description, target_amount, image_url, status, sort_order, created_at, updated_at)++ VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)`).run(id, creator, f.title, f.description || null, f.target_amount, f.image_url || null, f.sort_order || 0, at, at);++ return changed(id, 'created');++ });++ }++++ function update(goal, input = {}) {++ if (goal.status !== 'active') fail(409, 'tips.goal_closed', 'a closed goal cannot be edited');++ if (input.revision != null && Number(input.revision) !== goal.revision) fail(409, 'tips.revision_conflict', `the goal is at revision ${goal.revision}`);++ const f = fields(input, true);++ const keys = Object.keys(f);++ if (!keys.length) return present(goal);++ return ctx.tx(() => {++ db.prepare(`UPDATE tip_goals SET ${keys.map((k) => `${k} = @${k}`).join(', ')} WHERE id = @id`).run({ ...f, id: goal.id });++ markReached(goal.id);++ return changed(goal.id, 'updated');++ });++ }++++ function close(goal) {++ if (goal.status === 'closed') return present(goal);++ return ctx.tx(() => {++ db.prepare("UPDATE tip_goals SET status = 'closed', closed_at = ? WHERE id = ?").run(iso(ctx.now()), goal.id);++ return changed(goal.id, 'closed');++ });++ }++++ function markReached(goalId) {++ const g = get(goalId);++ const reached = totalOf(goalId).n >= g.target_amount;++ if (reached && !g.reached_at) { db.prepare('UPDATE tip_goals SET reached_at = ? WHERE id = ?').run(iso(ctx.now()), goalId); return true; }++ if (!reached && g.reached_at) db.prepare('UPDATE tip_goals SET reached_at = NULL WHERE id = ?').run(goalId);++ return false;++ }++++ /** The goal an interaction counts toward, or null. */++ function pick(creator, requestedGoalId) {++ if (requestedGoalId) {++ const g = get(requestedGoalId);++ if (g && g.creator_subject === creator && g.status === 'active') return g;++ }++ const active = db.prepare("SELECT * FROM tip_goals WHERE creator_subject = ? AND status = 'active' LIMIT 2").all(creator);++ return active.length === 1 ? active[0] : null;++ }++++ /** Inside the settlement transaction. Simulations never get here. */++ function contribute(interaction, requestedGoalId) {++ const g = pick(interaction.creator_subject, requestedGoalId);++ if (!g) return null;++ const at = iso(ctx.now());++ const r = db.prepare(`INSERT OR IGNORE INTO tip_goal_contributions (goal_id, interaction_id, amount, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`)++ .run(g.id, interaction.id, interaction.amount, at, at);++ if (!r.changes) return null;++ const reachedNow = markReached(g.id);++ return changed(g.id, reachedNow ? 'reached' : 'contribution', { interactionId: interaction.id, by: interaction.supporter_name });++ }++++ /** Inside the reversal transaction: take `bits` back off every goal the interaction counted toward. */++ function reverse(interaction, bits) {++ const rows = db.prepare('SELECT * FROM tip_goal_contributions WHERE interaction_id = ?').all(interaction.id);++ const out = [];++ for (const c of rows) {++ const take = Math.min(bits, c.amount - c.reversed_amount);++ if (take <= 0) continue;++ db.prepare('UPDATE tip_goal_contributions SET reversed_amount = reversed_amount + ?, updated_at = ? WHERE id = ?').run(take, iso(ctx.now()), c.id);++ markReached(c.goal_id);++ out.push(changed(c.goal_id, 'reversal', { interactionId: interaction.id }));++ }++ return out;++ }++++ return { get, list, present, create, update, close, contribute, reverse, pick, totalOf };++}++++module.exports = { createGoals };nnew file mode 100644@@@ -0,0 +1,39 @@++'use strict';++++/**++ * The Tips domain: one context shared by profiles, goals, interactions, overlays and effects.++ *++ * ctx.tx(fn) run fn in one SQLite transaction; ctx.afterCommit(hook) queues work (overlay++ * pushes, worker kicks) that must only happen once the transaction committed++ * ctx.outbox.emit() durable event, inside the transaction (openvibe-sdk outbox)++ */++const { createProfiles } = require('./profiles');++const { createGoals } = require('./goals');++const { createOverlays } = require('./overlays');++const { createInteractions } = require('./interactions');++const { createEffects } = require('./effects');++++function createDomain({ db, config, outbox, billing, adapters, now = () => Date.now(), log = console }) {++ const ctx = { db, config, now, log, outbox, billing, adapters, _after: null };++++ ctx.tx = (fn) => {++ if (ctx._after) return db.transaction(fn)();++ const hooks = [];++ ctx._after = hooks;++ let result;++ try { result = db.transaction(fn)(); } finally { ctx._after = null; }++ for (const h of hooks) { try { h(); } catch (e) { log.warn('[Tips] after-commit:', e.message); } }++ return result;++ };++ ctx.afterCommit = (hook) => { if (ctx._after) ctx._after.push(hook); else hook(); };++ ctx.outboxKick = () => { try { outbox.kick(); } catch { /* relay off */ } };++++ ctx.profiles = createProfiles(ctx);++ ctx.goals = createGoals(ctx);++ ctx.overlays = createOverlays(ctx);++ ctx.interactions = createInteractions(ctx);++ ctx.effects = createEffects(ctx);++ return ctx;++}++++module.exports = { createDomain };nnew file mode 100644@@@ -0,0 +1,496 @@++'use strict';++++/**++ * Tip interactions — the product record of one act of support.++ *++ * Two independent state machines (roadmap §15.11: "payment settled" is not "interaction delivered"):++ *++ * payment_state pending ──► settled ──► reversed mirrored from Billing, keyed by the++ * └──► failed Billing transaction id (UNIQUE)++ * delivery_state awaiting_payment ──► queued ──► delivered | failed | cancelled++ *++ * How money reaches Billing (Tips never moves it, ADR-012):++ * funding credit POST /transfers (supporter credit → creator payable, target = this interaction,++ * Idempotency-Key tips:transfer:<id>). The response settles it at once; the++ * billing.transaction.settled event for the same transaction is then a no-op.++ * funding checkout POST /intents (the supporter buys exactly the credit they give,++ * Idempotency-Key tips:intent:<id>) → checkout URL. When the purchase settles++ * (event, metadata.intent_id), the transfer above runs — CREDIT becomes MONEY only++ * by giving it to someone else.++ * origin billing a donation settled in Billing that Tips did not start (Live's own donate flow,++ * a site-routed PowerChat tip) is recorded once, by its Billing transaction id.++ * external a tip on the creator's own PowerChat account: no Billing liability; recorded++ * once by (provider, provider_ref); never part of the Billing reconciliation.++ * simulated the whole effect path with test = 1: no Billing call, no goal contribution,++ * no durable event, excluded from every total.++ *++ * Settlement creates, in one transaction: the paid message / TTS / media request row, the goal++ * contribution, the overlay alert, the delivery effects and tips.interaction.ready. Delivery then++ * runs in the effects worker; its failure never touches payment_state.++ */++const { fail, iso, prefixedId, json, positiveInt, text, userSubject, entityRef, displayName } = require('../util');++const { VOICES } = require('./profiles');++const { BillingCallError } = require('../billing-client');++++const KINDS = ['tip', 'paid_message', 'tts', 'media_request'];++// Paid-message highlight time by amount (bits): the bigger the message, the longer it stays up.++const HIGHLIGHT = [[5000, 300], [1000, 120], [500, 60], [100, 30]];++const highlightFor = (bits) => (HIGHLIGHT.find(([min]) => bits >= min) || [0, 0])[1];++const TRANSFER_BACKOFF = [2000, 10000, 30000, 60000, 300000, 600000];++++function createInteractions(ctx) {++ const { db, config } = ctx;++++ const get = (id) => db.prepare('SELECT * FROM tip_interactions WHERE id = ?').get(String(id || '')) || null;++ const byBillingTxn = (txnId) => db.prepare('SELECT * FROM tip_interactions WHERE billing_txn_id = ?').get(txnId) || null;++++ // ── Input rules (the creator's settings) ─────────────────++ function cleanTts(s) {++ return String(s || '').replace(/[\u0000-\u001f]/g, ' ').replace(/https?:\/\/\S+/gi, 'link').replace(/\s+/g, ' ').trim();++ }++++ function mediaUrl(v) {++ let u;++ try { u = new URL(String(v || '')); } catch { fail(422, 'tips.invalid_media', 'media.url must be a link'); }++ if (u.protocol !== 'https:') fail(422, 'tips.invalid_media', 'media.url must be an https link');++ if (!config.mediaHosts.includes(u.hostname.toLowerCase())) fail(422, 'tips.invalid_media', `media requests accept links from ${config.mediaHosts.join(', ')}`);++ const s = u.toString();++ if (s.length > 500) fail(422, 'tips.invalid_media', 'media.url is too long');++ return { url: s, provider: /youtu/.test(u.hostname) ? 'youtube' : u.hostname };++ }++++ /**++ * Validate a request against the creator's profile. Returns the normalised fields:++ * { kind, amount, message, request: { goal_id, tts, media, highlight_seconds } }.++ */++ function validate(profile, input, { simulation = false } = {}) {++ if (!profile) fail(404, 'tips.creator_not_found', 'this creator has no tip page');++ if (!profile.accepting && !simulation) fail(409, 'tips.not_accepting', `${profile.display_name} is not accepting tips right now`);++ const kind = input.kind || 'tip';++ if (!KINDS.includes(kind)) fail(422, 'tips.invalid_input', `kind must be one of ${KINDS.join(', ')}`);++ const amount = positiveInt(input.amount, 'amount', config.limits.maxBits);++ const message = text(input.message, 'message', config.limits.messageChars);++ const request = {};++ const min = (n, what) => { if (amount < n) fail(422, 'tips.amount_too_small', `the minimum for ${what} is ${n} Vibes`); };++ min(profile.min_amount, 'a tip');++ if (kind === 'paid_message') {++ min(profile.paid_message_min, 'a paid message');++ if (!message) fail(422, 'tips.invalid_input', 'a paid message needs a message');++ request.highlight_seconds = highlightFor(amount);++ }++ if (kind === 'tts') {++ if (!profile.tts_enabled) fail(409, 'tips.tts_disabled', `${profile.display_name} has text-to-speech switched off`);++ min(profile.tts_min_amount, 'text-to-speech');++ const raw = input.tts && input.tts.text != null ? input.tts.text : message;++ const t = cleanTts(raw);++ if (!t || t.startsWith('.')) fail(422, 'tips.invalid_input', 'text-to-speech needs text to read');++ if (t.length > profile.tts_max_chars) fail(422, 'tips.text_too_long', `text-to-speech is limited to ${profile.tts_max_chars} characters here`);++ const voice = input.tts && input.tts.voice ? String(input.tts.voice) : profile.tts_voice;++ if (!VOICES.includes(voice)) fail(422, 'tips.invalid_input', `voice must be one of ${VOICES.join(', ')}`);++ request.tts = { text: t, voice };++ }++ if (kind === 'media_request') {++ if (!profile.media_requests_enabled) fail(409, 'tips.media_disabled', `${profile.display_name} is not taking media requests`);++ min(profile.media_request_min, 'a media request');++ request.media = { ...mediaUrl(input.media && input.media.url), max_seconds: profile.media_max_seconds };++ }++ if (input.goal_id) {++ const g = ctx.goals.get(input.goal_id);++ if (!g || g.creator_subject !== profile.creator_subject || g.status !== 'active') fail(422, 'tips.goal_not_found', 'that goal is not open on this page');++ request.goal_id = g.id;++ }++ return { kind, amount, message, request };++ }++++ // ── Creation ─────────────────────────────────────────────++ function insert(row) {++ const at = iso(ctx.now());++ const r = {++ supporter_subject: null, supporter_name: null, amount_cents: null, message: null, request: {}, test: 0, billing_txn_id: null,++ billing_intent_id: null, funding_txn_id: null, provider: null, provider_ref: null, legacy_source: null, idempotency_key: null,++ target: null, origin: 'tips', created_at: at, ...row,++ };++ db.prepare(`INSERT INTO tip_interactions (id, creator_subject, supporter_subject, supporter_name, kind, amount, amount_cents, message, request,++ funding, settlement, payment_state, delivery_state, test, billing_txn_id, billing_intent_id, funding_txn_id, provider, provider_ref,++ legacy_source, idempotency_key, origin, target, created_at, updated_at)++ VALUES (@id, @creator_subject, @supporter_subject, @supporter_name, @kind, @amount, @amount_cents, @message, @request,++ @funding, @settlement, @payment_state, @delivery_state, @test, @billing_txn_id, @billing_intent_id, @funding_txn_id, @provider, @provider_ref,++ @legacy_source, @idempotency_key, @origin, @target, @created_at, @created_at)`)++ .run({ ...r, request: JSON.stringify(r.request || {}), target: r.target ? JSON.stringify(r.target) : null });++ return get(r.id);++ }++++ /**++ * A supporter's request (checkout or credit). Idempotent by `idempotencyKey` (the caller's key,++ * scoped to the caller): a repeat returns the interaction it created the first time.++ */++ function request(profile, input, { supporter, supporterName, funding, idempotencyKey }) {++ const v = validate(profile, input);++ const from = userSubject(supporter, 'supporter');++ if (from === profile.creator_subject) fail(422, 'tips.self_dealing', 'you cannot tip yourself');++ if (!['credit', 'checkout'].includes(funding)) fail(422, 'tips.invalid_input', "pay_with must be 'credit' or 'checkout'");++ if (funding === 'checkout' && v.amount < config.billing.minPurchaseBits) {++ fail(422, 'tips.amount_too_small', `paying by checkout starts at ${config.billing.minPurchaseBits} Vibes; smaller tips use your Vibes balance`);++ }++ const target = entityRef(input.target);++ return ctx.tx(() => {++ if (idempotencyKey) {++ const prev = db.prepare('SELECT * FROM tip_interactions WHERE idempotency_key = ?').get(idempotencyKey);++ if (prev) return { interaction: prev, replay: true };++ }++ const i = insert({++ id: prefixedId('tint', ctx.now()), creator_subject: profile.creator_subject, supporter_subject: from, supporter_name: displayName(supporterName) || 'Someone',++ kind: v.kind, amount: v.amount, message: v.message, request: v.request, funding, settlement: 'billing', payment_state: 'pending',++ delivery_state: 'awaiting_payment', idempotency_key: idempotencyKey || null, target,++ });++ return { interaction: i, replay: false };++ });++ }++++ // ── Paying through Billing ───────────────────────────────++ const transferKind = (i) => (i.kind === 'tip' ? 'tip' : 'paid_interaction');++++ /** Try the Billing transfer now; retryable failures leave it due for the jobs loop. */++ async function transfer(i, { traceparent } = {}) {++ try {++ const out = await ctx.billing.createTransfer({++ from: i.supporter_subject, to: i.creator_subject, amount: i.amount, kind: transferKind(i),++ target: { service: 'tips', type: 'interaction', id: i.id }, message: i.message || undefined,++ key: `tips:transfer:${i.id}`, traceparent,++ });++ const txn = out.transaction;++ ctx.tx(() => settleByTransaction(get(i.id), { id: txn.id, test: !!txn.test }));++ return { settled: true };++ } catch (e) {++ if (e instanceof BillingCallError && !e.retryable) {++ ctx.tx(() => failPayment(get(i.id), e.code || 'billing.refused', e.message));++ return { settled: false, refused: e.code, detail: e.body && e.body.detail };++ }++ const cur = get(i.id);++ const n = cur.transfer_attempts + 1;++ db.prepare('UPDATE tip_interactions SET transfer_due = 1, transfer_attempts = ?, next_transfer_at = ?, failure = ?, updated_at = ? WHERE id = ? AND payment_state = \'pending\'')++ .run(n, ctx.now() + TRANSFER_BACKOFF[Math.min(n - 1, TRANSFER_BACKOFF.length - 1)], String(e.message).slice(0, 300), iso(ctx.now()), i.id);++ ctx.log.warn(`[Tips] transfer for ${i.id} will be retried: ${e.message}`);++ return { settled: false, retrying: true };++ }++ }++++ async function startCheckout(i, { provider, traceparent }) {++ const p = String(provider || config.billing.providers[0] || '').toLowerCase();++ if (!config.billing.providers.includes(p)) fail(422, 'tips.invalid_input', `provider must be one of ${config.billing.providers.join(', ') || '(none configured)'}`);++ let out;++ try {++ out = await ctx.billing.createIntent({++ provider: p, subject: i.supporter_subject, bits: i.amount,++ successUrl: `${config.baseUrl}/receipts/${i.id}`, cancelUrl: `${config.baseUrl}/receipts/${i.id}?cancelled=1`,++ key: `tips:intent:${i.id}`, traceparent,++ });++ } catch (e) {++ if (e instanceof BillingCallError && !e.retryable) {++ ctx.tx(() => failPayment(get(i.id), e.code || 'billing.refused', e.message));++ fail(e.status === 409 ? 409 : 422, e.code || 'billing.refused', (e.body && e.body.detail) || 'Billing refused the checkout');++ }++ fail(502, 'tips.billing_unavailable', 'checkout is unavailable right now; nothing was charged — try again shortly');++ }++ const intent = out.intent || {};++ let url = out.checkout_url || null;++ const ref = intent.checkout_ref || null;++ if (!url && ref && p === 'powerchat' && config.billing.powerchatLinkTemplate) {++ url = config.billing.powerchatLinkTemplate.replace('{ref}', encodeURIComponent(ref)).replace('{cents}', String(intent.amount_cents || '')).replace('{bits}', String(i.amount));++ }++ // Pages link and redirect to it: only an https URL is ever kept.++ if (url && !/^https:\/\/[^\s"'<>]+$/.test(url)) url = null;++ db.prepare('UPDATE tip_interactions SET billing_intent_id = ?, checkout_url = ?, checkout_ref = ?, provider = ?, updated_at = ? WHERE id = ?')++ .run(intent.id || null, url, ref, p, iso(ctx.now()), i.id);++ return { interaction: get(i.id), checkout_url: url, checkout_ref: ref, intent };++ }++++ /** Due transfers (credit retries and funded checkouts). Called by the jobs loop. */++ async function processDueTransfers() {++ const rows = db.prepare("SELECT * FROM tip_interactions WHERE payment_state = 'pending' AND transfer_due = 1 AND next_transfer_at <= ? ORDER BY next_transfer_at LIMIT 20").all(ctx.now());++ let settled = 0;++ for (const i of rows) { const r = await transfer(i); if (r.settled) settled++; }++ return { attempted: rows.length, settled };++ }++++ // ── Settlement ───────────────────────────────────────────++ /** Inside a transaction: settle `i` by Billing transaction `txn` ({ id, test }). Idempotent. */++ function settleByTransaction(i, txn) {++ if (!i) return null;++ if (i.billing_txn_id === txn.id || i.payment_state === 'settled' || i.payment_state === 'reversed') return i;++ const other = byBillingTxn(txn.id);++ if (other && other.id !== i.id) return other;++ db.prepare('UPDATE tip_interactions SET billing_txn_id = ?, test = MAX(test, ?), transfer_due = 0, failure = NULL WHERE id = ?').run(txn.id, txn.test ? 1 : 0, i.id);++ return settle(get(i.id));++ }++++ /** Inside a transaction: pending/failed → settled, and the whole effect path. */++ function settle(i) {++ const at = iso(ctx.now());++ const test = !!i.test;++ const req = json(i.request, {});++ db.prepare("UPDATE tip_interactions SET payment_state = 'settled', delivery_state = 'queued', settled_at = ?, updated_at = ? WHERE id = ?").run(at, at, i.id);++ i = get(i.id);++++ if (i.kind === 'paid_message' || i.kind === 'tts') {++ db.prepare(`INSERT OR IGNORE INTO paid_messages (id, interaction_id, creator_subject, kind, text, voice, highlight_seconds, status, test, created_at, updated_at)++ VALUES (?, ?, ?, ?, ?, ?, ?, 'queued', ?, ?, ?)`).run(prefixedId('tpm', ctx.now()), i.id, i.creator_subject, i.kind,++ i.kind === 'tts' ? req.tts.text : (i.message || ''), i.kind === 'tts' ? req.tts.voice : null, req.highlight_seconds || 0, test ? 1 : 0, at, at);++ }++ if (i.kind === 'media_request' && req.media) {++ db.prepare(`INSERT OR IGNORE INTO paid_media_requests (id, interaction_id, creator_subject, url, provider, status, test, created_at, updated_at)++ VALUES (?, ?, ?, ?, ?, 'queued', ?, ?, ?)`).run(prefixedId('tmr', ctx.now()), i.id, i.creator_subject, req.media.url, req.media.provider, test ? 1 : 0, at, at);++ }++ if (!test && i.origin !== 'import') ctx.goals.contribute(i, req.goal_id);++++ const view = { message: i.message, tts: req.tts || null, media: req.media ? { url: req.media.url } : null };++ if (i.origin !== 'import') {++ ctx.overlays.addAlert(i, view);++ addEffect(i, 'overlay_alert', 'overlay', 'delivered');++ }++ // Chat effects: never for imports (history), never twice for a donation another product already++ // announced (origin billing), never to a real chat room for a simulation.++ const adapter = test ? 'test' : config.chat.adapter;++ if (i.origin === 'tips' && adapter !== 'none') {++ if (i.kind === 'tip') addEffect(i, 'chat_line', adapter);++ if (i.kind === 'paid_message') addEffect(i, 'paid_message', adapter);++ if (i.kind === 'tts') { addEffect(i, 'chat_line', adapter); addEffect(i, 'tts', adapter); }++ if (i.kind === 'media_request') addEffect(i, 'media_request', adapter);++ }++ recomputeDelivery(i.id);++ if (!test && i.origin !== 'import') {++ ctx.outbox.emit('tips.interaction.ready', { type: 'interaction', id: i.id }, summary(get(i.id)));++ }++ ctx.afterCommit(() => { ctx.effects.kick(); ctx.outboxKick(); });++ return get(i.id);++ }++++ function addEffect(i, effect, adapter, state = 'queued') {++ const at = iso(ctx.now());++ db.prepare(`INSERT OR IGNORE INTO interaction_effects (interaction_id, effect, adapter, state, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`)++ .run(i.id, effect, adapter, state, at, at);++ }++++ /** Inside a transaction: pending → failed (nothing was delivered; nothing moved in Billing). */++ function failPayment(i, code, detail) {++ if (!i || i.payment_state !== 'pending') return i;++ const at = iso(ctx.now());++ db.prepare("UPDATE tip_interactions SET payment_state = 'failed', delivery_state = 'cancelled', transfer_due = 0, failure = ?, updated_at = ? WHERE id = ?")++ .run(`${code}${detail ? `: ${String(detail).slice(0, 240)}` : ''}`, at, i.id);++ if (!i.test) ctx.outbox.emit('tips.interaction.cancelled', { type: 'interaction', id: i.id }, { ...summary(get(i.id)), reason: 'payment_failed', code });++ return get(i.id);++ }++++ /** Billing settled a transaction. Inside the inbox transaction. */++ function onBillingSettled(p) {++ const meta = p.metadata || {};++ const txn = { id: p.transaction_id, test: !!p.test };++ if (!txn.id) return 'ignored:no_transaction';++ if (p.type === 'donation') {++ const known = byBillingTxn(txn.id);++ if (known) return 'duplicate_transaction';++ const target = meta.target;++ if (target && target.service === 'tips' && target.type === 'interaction') {++ const i = get(target.id);++ if (i) { settleByTransaction(i, txn); return 'settled'; }++ return 'ignored:unknown_interaction';++ }++ // A donation Tips did not start: record it once, by its transaction id.++ if (!p.to_subject) return 'ignored:no_recipient';++ const amount = Number(meta.amount_bits) || 0;++ if (amount <= 0) return 'ignored:no_amount';++ const i = insert({++ id: prefixedId('tint', ctx.now()), creator_subject: p.to_subject, supporter_subject: p.from_subject || null,++ supporter_name: displayName(meta.donor_name) || null, kind: meta.kind === 'paid_interaction' ? 'media_request' : 'tip', amount, amount_cents: meta.paid_cents || null,++ message: meta.message ? String(meta.message).slice(0, config.limits.messageChars) : null,++ funding: p.provider ? 'provider' : 'credit', settlement: 'billing', payment_state: 'pending', delivery_state: 'awaiting_payment',++ test: txn.test ? 1 : 0, billing_txn_id: txn.id, provider: p.provider || null, origin: 'billing',++ target: meta.target && meta.target.service ? meta.target : null,++ });++ settle(i);++ return 'recorded';++ }++ if (p.type === 'purchase' && meta.intent_id) {++ const i = db.prepare('SELECT * FROM tip_interactions WHERE billing_intent_id = ?').get(meta.intent_id);++ if (!i) return 'ignored:not_a_tips_checkout';++ if (i.funding_txn_id) return 'duplicate_funding';++ db.prepare("UPDATE tip_interactions SET funding_txn_id = ?, test = MAX(test, ?), transfer_due = CASE WHEN payment_state = 'pending' THEN 1 ELSE 0 END, next_transfer_at = 0, updated_at = ? WHERE id = ?")++ .run(txn.id, txn.test ? 1 : 0, iso(ctx.now()), i.id);++ ctx.afterCommit(() => { processDueTransfers().catch((e) => ctx.log.warn('[Tips] transfer after funding:', e.message)); });++ return 'funded';++ }++ return 'ignored';++ }++++ /** Billing reversed a transaction. Inside the inbox transaction. */++ function onBillingReversed(p) {++ const meta = p.metadata || {};++ const orig = p.reverses_txn;++ if (!orig) return 'ignored:no_original';++ const i = byBillingTxn(orig);++ if (!i) {++ const funded = db.prepare('SELECT * FROM tip_interactions WHERE funding_txn_id = ?').get(orig);++ if (funded && funded.payment_state === 'pending') { failPayment(funded, 'billing.funding_reversed', 'the checkout payment was reversed before the tip was given'); return 'funding_reversed'; }++ return 'ignored';++ }++ const ids = json(i.reversal_txn_ids, []);++ if (ids.includes(p.transaction_id)) return 'duplicate_reversal';++ ids.push(p.transaction_id);++ // Bits Billing took back from the creator: a transfer refund moves them out of the payable. A++ // provider chargeback on a site-routed tip leaves the creator's payable intact (ADR-012 rule 2).++ const clawed = p.type === 'refund' && p.from_subject === i.creator_subject ? Math.max(0, Number(meta.amount_bits) || 0) : 0;++ const take = Math.min(clawed, i.amount - i.reversed_bits);++ const at = iso(ctx.now());++ db.prepare("UPDATE tip_interactions SET payment_state = 'reversed', reversal_txn_ids = ?, reversed_bits = reversed_bits + ?, reversed_at = ?, updated_at = ? WHERE id = ?")++ .run(JSON.stringify(ids), take, at, at, i.id);++ if (take > 0 && !i.test) ctx.goals.reverse(i, take);++ // Undelivered effects are cancelled; a delivery that already happened stays on record.++ const cancelled = cancelQueued(get(i.id), 'payment_reversed');++ return cancelled ? 'reversed_cancelled' : 'reversed';++ }++++ /** Inside a transaction: queued effects → cancelled. True when anything was cancelled. */++ function cancelQueued(i, reason) {++ const at = iso(ctx.now());++ const r = db.prepare("UPDATE interaction_effects SET state = 'cancelled', updated_at = ? WHERE interaction_id = ? AND state = 'queued'").run(at, i.id);++ db.prepare("UPDATE paid_messages SET status = 'cancelled', updated_at = ? WHERE interaction_id = ? AND status = 'queued'").run(at, i.id);++ db.prepare("UPDATE paid_media_requests SET status = 'cancelled', updated_at = ? WHERE interaction_id = ? AND status = 'queued'").run(at, i.id);++ if (!r.changes) return false;++ db.prepare("UPDATE tip_interactions SET delivery_state = 'cancelled', updated_at = ? WHERE id = ?").run(at, i.id);++ if (!i.test) ctx.outbox.emit('tips.interaction.cancelled', { type: 'interaction', id: i.id }, { ...summary(get(i.id)), reason });++ return true;++ }++++ /** Aggregate the effects into delivery_state (inside a transaction). Emits failed once. */++ function recomputeDelivery(id) {++ const i = get(id);++ if (!i || i.delivery_state === 'cancelled' || i.delivery_state === 'awaiting_payment') return i;++ const effects = db.prepare('SELECT state FROM interaction_effects WHERE interaction_id = ?').all(id).map((e) => e.state);++ let next = 'queued';++ if (effects.includes('failed') && !effects.includes('queued')) next = 'failed';++ else if (!effects.includes('queued')) next = 'delivered';++ if (next === i.delivery_state) return i;++ const at = iso(ctx.now());++ db.prepare(`UPDATE tip_interactions SET delivery_state = ?, delivered_at = CASE WHEN ? = 'delivered' THEN ? ELSE delivered_at END, updated_at = ? WHERE id = ?`).run(next, next, at, at, id);++ if (next === 'failed' && !i.test) {++ const failed = db.prepare("SELECT effect, adapter, last_error FROM interaction_effects WHERE interaction_id = ? AND state = 'failed'").all(id);++ ctx.outbox.emit('tips.interaction.failed', { type: 'interaction', id }, { ...summary(get(id)), failed_effects: failed });++ }++ return get(id);++ }++++ // ── External and simulated ───────────────────────────────++ /** A tip on the creator's own PowerChat (EXTERNAL, ADR-012): recorded once by provider ref. */++ function recordExternal(profile, input) {++ if (!profile) fail(404, 'tips.creator_not_found', 'this creator has no tip profile');++ const provider = String(input.provider || '').toLowerCase();++ if (!/^[a-z][a-z0-9_-]{1,39}$/.test(provider)) fail(422, 'tips.invalid_input', 'provider is required');++ const ref = String(input.provider_ref || '').trim();++ if (!ref || ref.length > 200) fail(422, 'tips.invalid_input', 'provider_ref (the provider event id) is required');++ const cents = positiveInt(input.amount_cents, 'amount_cents', 100_000_000);++ return ctx.tx(() => {++ const prev = db.prepare('SELECT * FROM tip_interactions WHERE provider = ? AND provider_ref = ?').get(provider, ref);++ if (prev) return { interaction: prev, replay: true };++ const i = insert({++ id: prefixedId('tint', ctx.now()), creator_subject: profile.creator_subject, supporter_subject: input.supporter ? userSubject(input.supporter, 'supporter') : null,++ supporter_name: displayName(input.supporter_name) || 'Someone', kind: 'tip', amount: cents, amount_cents: cents,++ message: text(input.message, 'message', 500), request: input.goal_id ? { goal_id: String(input.goal_id) } : {},++ funding: 'external', settlement: 'external', payment_state: 'pending', delivery_state: 'awaiting_payment',++ // origin external: the provider (or Live's webhook) already announced it in chat; with++ // announce: true Tips delivers the chat line itself.++ provider, provider_ref: ref, origin: input.announce === true ? 'tips' : 'external', test: input.test ? 1 : 0,++ });++ return { interaction: settle(i), replay: false };++ });++ }++++ /** The full effect path with test = 1 and no Billing call. */++ function simulate(profile, input, { by }) {++ const v = validate(profile, { ...input, amount: input.amount || Math.max(profile.min_amount, 100) }, { simulation: true });++ return ctx.tx(() => {++ const i = insert({++ id: prefixedId('tint', ctx.now()), creator_subject: profile.creator_subject, supporter_subject: null,++ supporter_name: displayName(input.supporter_name) || 'Test supporter', kind: v.kind, amount: v.amount, message: v.message,++ request: { ...v.request, simulated_by: by }, funding: 'none', settlement: 'simulated', payment_state: 'pending',++ delivery_state: 'awaiting_payment', test: 1,++ });++ const out = settle(i);++ // Show the goal widget moving without counting anything: a test goal delivery with the++ // would-be total. No contribution row is written.++ const g = ctx.goals.pick(profile.creator_subject, v.request.goal_id);++ if (g) {++ const view = ctx.goals.present(g);++ const would = { ...view, current_amount: view.current_amount + v.amount, percent: Math.min(100, Math.floor(((view.current_amount + v.amount) * 100) / view.target_amount)) };++ ctx.overlays.addGoalDelivery(profile.creator_subject, would, { reason: 'simulation', interactionId: i.id, by: i.supporter_name, dedupe: `goal:${g.id}:sim:${i.id}`, test: true });++ }++ return out;++ });++ }++++ // ── Reads ────────────────────────────────────────────────++ function summary(i) {++ return {++ interaction_id: i.id, creator: { type: 'user', id: i.creator_subject }, supporter: i.supporter_subject ? { type: 'user', id: i.supporter_subject } : null,++ supporter_name: i.supporter_name || null, kind: i.kind, amount: i.amount, currency: i.currency, settlement: i.settlement,++ payment_state: i.payment_state, delivery_state: i.delivery_state, billing_txn_id: i.billing_txn_id || null, test: !!i.test,++ };++ }++++ function present(i, { viewer = 'owner' } = {}) {++ if (!i) return null;++ const req = json(i.request, {});++ const out = {++ id: i.id, creator: { type: 'user', id: i.creator_subject }, supporter: i.supporter_subject ? { type: 'user', id: i.supporter_subject } : null,++ supporter_name: i.supporter_name || null, kind: i.kind, amount: i.amount, currency: i.currency, message: i.message || null,++ tts: req.tts || null, media: req.media ? { url: req.media.url, provider: req.media.provider } : null, goal_id: req.goal_id || null,++ funding: i.funding, settlement: i.settlement, origin: i.origin, test: !!i.test,++ payment: { state: i.payment_state, billing_txn_id: i.billing_txn_id || null, reversal_txn_ids: json(i.reversal_txn_ids, []), reversed_amount: i.reversed_bits, failure: i.failure || null, settled_at: i.settled_at || null, reversed_at: i.reversed_at || null },++ delivery: { state: i.delivery_state, delivered_at: i.delivered_at || null },++ created_at: i.created_at, updated_at: i.updated_at,++ };++ if (i.funding === 'checkout') out.checkout = { url: i.checkout_url || null, ref: i.checkout_ref || null, provider: i.provider || null, intent_id: i.billing_intent_id || null };++ if (viewer === 'owner' || viewer === 'service') {++ out.effects = db.prepare('SELECT effect, adapter, state, attempts, last_error, updated_at FROM interaction_effects WHERE interaction_id = ? ORDER BY id').all(i.id);++ out.provider = i.provider || null;++ out.provider_ref = i.provider_ref || null;++ out.legacy_source = i.legacy_source || null;++ }++ return out;++ }++++ /** Cursor-paged list, newest first. filter: { creator?, supporter?, includeTest? } */++ function list({ creator, supporter, includeTest = true, cursor, limit = 50 } = {}) {++ const n = Math.min(200, Math.max(1, Number(limit) || 50));++ let cur = null;++ if (cursor) { try { cur = JSON.parse(Buffer.from(String(cursor), 'base64url').toString('utf8')); } catch { fail(422, 'tips.invalid_input', 'bad cursor'); } }++ const rows = db.prepare(`SELECT * FROM tip_interactions WHERE (@c IS NULL OR creator_subject = @c) AND (@s IS NULL OR supporter_subject = @s)++ AND (@t = 1 OR test = 0) AND (@ca IS NULL OR created_at < @ca OR (created_at = @ca AND id < @ci))++ ORDER BY created_at DESC, id DESC LIMIT @n`).all({ c: creator || null, s: supporter || null, t: includeTest ? 1 : 0, ca: cur ? cur[0] : null, ci: cur ? cur[1] : null, n: n + 1 });++ const page = rows.slice(0, n);++ const next = rows.length > n ? Buffer.from(JSON.stringify([page[n - 1].created_at, page[n - 1].id])).toString('base64url') : null;++ return { rows: page, next_cursor: next };++ }++++ /**++ * A creator's totals, derived from settled interactions. `billing` is what must equal Billing's++ * books (settled through Billing or imported from Live's ledger, minus what Billing took back);++ * external and test interactions are reported apart and never mixed in.++ */++ function totals(creator) {++ const r = db.prepare(`SELECT++ COALESCE(SUM(CASE WHEN test = 0 AND settlement IN ('billing', 'imported') AND payment_state IN ('settled', 'reversed') THEN amount - reversed_bits END), 0) AS billing,++ COALESCE(SUM(CASE WHEN test = 0 AND settlement = 'external' AND payment_state = 'settled' THEN amount END), 0) AS external,++ COALESCE(SUM(CASE WHEN test = 0 AND payment_state IN ('settled', 'reversed') THEN 1 END), 0) AS count,++ COALESCE(SUM(CASE WHEN test = 1 THEN 1 END), 0) AS test_count,++ COALESCE(SUM(CASE WHEN payment_state = 'pending' THEN 1 END), 0) AS pending_count++ FROM tip_interactions WHERE creator_subject = ?`).get(creator);++ return { currency: 'vibes-bits', settled_via_billing: r.billing, external: r.external, interactions: r.count, simulations: r.test_count, pending: r.pending_count };++ }++++ return {++ get, byBillingTxn, validate, request, transfer, startCheckout, processDueTransfers, settleByTransaction, settle, failPayment,++ onBillingSettled, onBillingReversed, cancelQueued, recomputeDelivery, recordExternal, simulate, summary, present, list, totals, insert, KINDS,++ };++}++++module.exports = { createInteractions, KINDS, highlightFor };nnew file mode 100644@@@ -0,0 +1,306 @@++'use strict';++++/**++ * Overlays: scoped, revocable tokens; configs; deliveries; the live SSE hub.++ *++ * Tokens. `tovl_<43 base64url chars>` shown ONCE at creation; only its SHA-256 is stored. A token++ * carries scopes (alerts, goals), optionally a config, and is never a creator cookie: an OBS++ * browser source holds it in its URL and nothing else. Revocation is immediate: the row is marked++ * and every open stream of that token is closed in the same call.++ *++ * Deliveries. Every alert (a settled interaction) and goal change becomes one overlay_deliveries++ * row with a monotonic seq (the SSE event id). The first time a row is written to any overlay it++ * becomes `delivered` and emits tips.overlay.delivered; a row no overlay received within the++ * delivery window becomes `failed` (tips.overlay.failed). A reconnect with Last-Event-ID++ * REPLAYS rows after that id: replay only writes bytes to a socket — it never charges, never++ * re-counts a goal, never emits another event. Simulated (test) rows are flagged and emit nothing.++ */++const crypto = require('crypto');++const { fail, iso, prefixedId, sha256, json, text } = require('../util');++const { safeUrl } = require('./profiles');++++const SCOPES = ['alerts', 'goals'];++const TOKEN_RE = /^tovl_[A-Za-z0-9_-]{43}$/;++++function createOverlays(ctx) {++ const { db } = ctx;++ const clients = new Map(); // creator subject → Set<client>++ let nextClient = 1;++++ // ── Tokens ───────────────────────────────────────────────++ function createToken(creator, { scopes, label, configId, createdBy }) {++ const list = Array.isArray(scopes) && scopes.length ? [...new Set(scopes.map(String))] : SCOPES;++ if (list.some((s) => !SCOPES.includes(s))) fail(422, 'tips.invalid_input', `scopes must be among ${SCOPES.join(', ')}`);++ if (configId) {++ const c = getConfig(configId);++ if (!c || c.creator_subject !== creator) fail(404, 'tips.overlay_config_not_found', 'no such overlay config for this creator');++ }++ const active = db.prepare('SELECT COUNT(*) AS n FROM overlay_tokens WHERE creator_subject = ? AND revoked_at IS NULL').get(creator).n;++ if (active >= 25) fail(409, 'tips.too_many_tokens', 'revoke an overlay token before creating another (25 active at most)');++ const secret = `tovl_${crypto.randomBytes(32).toString('base64url')}`;++ const id = prefixedId('tovt', ctx.now());++ db.prepare(`INSERT INTO overlay_tokens (id, creator_subject, token_hash, scopes, config_id, label, created_by, created_at)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(id, creator, sha256(secret), JSON.stringify(list), configId || null, text(label, 'label', 80), createdBy, iso(ctx.now()));++ return { token: presentToken(getToken(id)), secret, overlay_url: `${ctx.config.baseUrl}/overlay/${secret}`, events_url: `${ctx.config.baseUrl}/overlay/${secret}/events` };++ }++ const getToken = (id) => db.prepare('SELECT * FROM overlay_tokens WHERE id = ?').get(String(id || '')) || null;++ const listTokens = (creator) => db.prepare('SELECT * FROM overlay_tokens WHERE creator_subject = ? ORDER BY revoked_at IS NOT NULL, created_at DESC').all(creator);++++ /** The active token row for a presented secret, or null (unknown, malformed or revoked). */++ function authenticate(secret) {++ if (!TOKEN_RE.test(String(secret || ''))) return null;++ const row = db.prepare('SELECT * FROM overlay_tokens WHERE token_hash = ?').get(sha256(secret));++ if (!row || row.revoked_at) return null;++ return { ...row, scopes: json(row.scopes, []) };++ }++++ function revokeToken(row) {++ if (!row.revoked_at) db.prepare('UPDATE overlay_tokens SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL').run(iso(ctx.now()), row.id);++ closeToken(row.id);++ return presentToken(getToken(row.id));++ }++++ function presentToken(t) {++ if (!t) return null;++ return {++ id: t.id, creator: { type: 'user', id: t.creator_subject }, scopes: json(t.scopes, []), label: t.label || null, config_id: t.config_id || null,++ created_at: t.created_at, last_used_at: t.last_used_at || null, revoked_at: t.revoked_at || null, active: !t.revoked_at,++ };++ }++++ // ── Configs ──────────────────────────────────────────────++ const getConfig = (id) => db.prepare('SELECT * FROM overlay_configs WHERE id = ?').get(String(id || '')) || null;++ const listConfigs = (creator) => db.prepare('SELECT * FROM overlay_configs WHERE creator_subject = ? ORDER BY created_at').all(creator);++++ function settingsOf(kind, input = {}, base = {}) {++ const s = { ...base };++ const int = (k, lo, hi) => {++ if (input[k] === undefined || input[k] === '') return;++ const n = Number(input[k]);++ if (!Number.isInteger(n) || n < lo || n > hi) fail(422, 'tips.invalid_input', `${k} must be between ${lo} and ${hi}`);++ s[k] = n;++ };++ const bool = (k) => { if (input[k] !== undefined) s[k] = input[k] === true || input[k] === 'on' || input[k] === '1' || input[k] === 1; };++ const url = (k) => {++ if (input[k] === undefined) return;++ if (!input[k]) { s[k] = null; return; }++ const u = safeUrl(input[k]);++ if (!u) fail(422, 'tips.invalid_input', `${k} must be an https URL`);++ s[k] = u;++ };++ if (kind === 'alerts') {++ int('min_amount', 1, 10_000_000); int('duration_ms', 1000, 60_000);++ bool('show_message'); bool('show_amount'); bool('speak_message');++ url('sound_url'); url('image_url');++ if (input.template !== undefined) s.template = text(input.template, 'template', 120);++ } else {++ bool('show_amounts'); bool('show_percent');++ }++ return s;++ }++ const DEFAULTS = {++ alerts: { min_amount: 1, duration_ms: 8000, show_message: true, show_amount: true, speak_message: false, sound_url: null, image_url: null, template: '{name} tipped {amount} Vibes' },++ goal: { show_amounts: true, show_percent: true },++ };++++ function createConfig(creator, input = {}) {++ const kind = input.kind === 'goal' ? 'goal' : input.kind === 'alerts' ? 'alerts' : fail(422, 'tips.invalid_input', "kind must be 'alerts' or 'goal'");++ const name = text(input.name, 'name', 80) || (kind === 'alerts' ? 'Alerts' : 'Goal');++ let goalId = null;++ if (kind === 'goal' && input.goal_id) {++ const g = ctx.goals.get(input.goal_id);++ if (!g || g.creator_subject !== creator) fail(404, 'tips.goal_not_found', 'no such goal for this creator');++ goalId = g.id;++ }++ const id = prefixedId('tovc', ctx.now());++ const at = iso(ctx.now());++ db.prepare(`INSERT INTO overlay_configs (id, creator_subject, kind, name, settings, goal_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)++ .run(id, creator, kind, name, JSON.stringify(settingsOf(kind, input, DEFAULTS[kind])), goalId, at, at);++ return presentConfig(getConfig(id));++ }++++ function updateConfig(c, input = {}) {++ if (input.revision != null && Number(input.revision) !== c.revision) fail(409, 'tips.revision_conflict', `the config is at revision ${c.revision}`);++ const settings = settingsOf(c.kind, input, json(c.settings, {}));++ let goalId = c.goal_id;++ if (c.kind === 'goal' && input.goal_id !== undefined) {++ if (!input.goal_id) goalId = null;++ else {++ const g = ctx.goals.get(input.goal_id);++ if (!g || g.creator_subject !== c.creator_subject) fail(404, 'tips.goal_not_found', 'no such goal for this creator');++ goalId = g.id;++ }++ }++ const name = input.name !== undefined ? (text(input.name, 'name', 80) || c.name) : c.name;++ db.prepare('UPDATE overlay_configs SET name = ?, settings = ?, goal_id = ?, revision = revision + 1, updated_at = ? WHERE id = ?')++ .run(name, JSON.stringify(settings), goalId, iso(ctx.now()), c.id);++ const out = presentConfig(getConfig(c.id));++ // Open overlays using this config pick the change up at once.++ for (const cl of clients.get(c.creator_subject) || []) if (cl.configId === c.id) write(cl, 'config', null, { config: out });++ return out;++ }++++ function presentConfig(c) {++ if (!c) return null;++ return { id: c.id, creator: { type: 'user', id: c.creator_subject }, kind: c.kind, name: c.name, settings: json(c.settings, {}), goal_id: c.goal_id || null, revision: c.revision, created_at: c.created_at, updated_at: c.updated_at };++ }++++ // ── Deliveries ───────────────────────────────────────────++ function insertDelivery(creator, kind, { interactionId = null, goalId = null, payload, test = false, dedupe }) {++ const id = prefixedId('tovd', ctx.now());++ const r = db.prepare(`INSERT OR IGNORE INTO overlay_deliveries (id, creator_subject, kind, interaction_id, goal_id, payload, test, status, dedupe_key, created_at)++ VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)`).run(id, creator, kind, interactionId, goalId, JSON.stringify(payload), test ? 1 : 0, dedupe, iso(ctx.now()));++ if (r.changes) ctx.afterCommit(() => notify(creator));++ return r.changes ? id : null;++ }++++ /** Inside the settlement transaction (or a simulation). */++ function addAlert(interaction, view) {++ const payload = {++ interaction_id: interaction.id, kind: interaction.kind, amount: interaction.amount, currency: interaction.currency,++ supporter_name: interaction.supporter_name || 'Someone', message: view.message, tts: view.tts || null,++ media: view.media || null, settlement: interaction.settlement, test: !!interaction.test, at: iso(ctx.now()),++ };++ return insertDelivery(interaction.creator_subject, 'alert', { interactionId: interaction.id, payload, test: interaction.test, dedupe: `alert:${interaction.id}` });++ }++++ function addGoalDelivery(creator, goalView, { reason, interactionId, by, dedupe, test = false }) {++ return insertDelivery(creator, 'goal', { interactionId, goalId: goalView.id, payload: { goal: goalView, reason, by: by || null, test }, test, dedupe });++ }++++ /** First write of a pending row: delivered + the event (never for tests). */++ function markDelivered(row) {++ ctx.tx(() => {++ const r = db.prepare("UPDATE overlay_deliveries SET status = 'delivered', delivered_at = ? WHERE seq = ? AND status = 'pending'").run(iso(ctx.now()), row.seq);++ if (r.changes && !row.test) {++ ctx.outbox.emit('tips.overlay.delivered', { type: 'overlay_delivery', id: row.id }, {++ delivery_id: row.id, creator: { type: 'user', id: row.creator_subject }, kind: row.kind, interaction_id: row.interaction_id, goal_id: row.goal_id,++ });++ }++ });++ ctx.outboxKick();++ }++++ /** Pending rows older than the window → failed (tips.overlay.failed). Returns how many. */++ function sweepFailed() {++ const cutoff = iso(ctx.now() - ctx.config.overlays.ttlMs);++ const rows = db.prepare("SELECT * FROM overlay_deliveries WHERE status = 'pending' AND created_at < ? ORDER BY seq LIMIT 500").all(cutoff);++ if (!rows.length) return 0;++ ctx.tx(() => {++ for (const row of rows) {++ const r = db.prepare("UPDATE overlay_deliveries SET status = 'failed', failed_at = ? WHERE seq = ? AND status = 'pending'").run(iso(ctx.now()), row.seq);++ if (r.changes && !row.test) {++ ctx.outbox.emit('tips.overlay.failed', { type: 'overlay_delivery', id: row.id }, {++ delivery_id: row.id, creator: { type: 'user', id: row.creator_subject }, kind: row.kind, interaction_id: row.interaction_id,++ goal_id: row.goal_id, reason: 'no overlay showed it within the delivery window',++ });++ }++ }++ });++ ctx.outboxKick();++ return rows.length;++ }++++ // ── SSE hub ──────────────────────────────────────────────++ const scopeOf = (kind) => (kind === 'alert' ? 'alerts' : 'goals');++++ function write(client, event, id, data) {++ if (client.closed) return false;++ try {++ client.res.write(`${id != null ? `id: ${id}\n` : ''}event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);++ return true;++ } catch { return false; }++ }++++ function send(client, row) {++ client.lastSeq = Math.max(client.lastSeq, row.seq);++ if (!client.scopes.includes(scopeOf(row.kind))) return;++ const payload = json(row.payload, {});++ if (row.kind === 'alert' && client.minAmount && payload.amount < client.minAmount && !row.test) return;++ if (!write(client, row.kind, row.seq, { delivery_id: row.id, seq: row.seq, ...payload })) return;++ db.prepare('UPDATE overlay_deliveries SET sends = sends + 1 WHERE seq = ?').run(row.seq);++ if (row.status === 'pending') markDelivered(row);++ }++++ function pushNew(client) {++ const rows = db.prepare("SELECT * FROM overlay_deliveries WHERE creator_subject = ? AND seq > ? AND status != 'failed' ORDER BY seq LIMIT 200").all(client.creator, client.lastSeq);++ for (const row of rows) send(client, row);++ }++++ function notify(creator) {++ for (const c of clients.get(creator) || []) pushNew(c);++ }++++ /**++ * Attach an SSE response. lastEventId (Last-Event-ID): replay what came after it (bounded).++ * Without it: what is still pending within the window, then everything new.++ */++ function attach(token, res, { lastEventId, config } = {}) {++ const settings = config ? json(config.settings, {}) : {};++ const client = {++ id: nextClient++, creator: token.creator_subject, tokenId: token.id, scopes: token.scopes, configId: token.config_id || null,++ minAmount: config && config.kind === 'alerts' ? Number(settings.min_amount) || 0 : 0, res, lastSeq: 0, closed: false,++ };++ if (!clients.has(client.creator)) clients.set(client.creator, new Set());++ clients.get(client.creator).add(client);++ db.prepare('UPDATE overlay_tokens SET last_used_at = ? WHERE id = ?').run(iso(ctx.now()), token.id);++++ const maxSeq = db.prepare('SELECT COALESCE(MAX(seq), 0) AS n FROM overlay_deliveries WHERE creator_subject = ?').get(client.creator).n;++ write(client, 'hello', null, {++ creator: { type: 'user', id: client.creator }, scopes: client.scopes, config: config ? presentConfig(config) : null,++ goals: client.scopes.includes('goals') ? ctx.goals.list(client.creator, { status: 'active' }).map((g) => ctx.goals.present(g)) : [],++ resumed_from: lastEventId != null ? lastEventId : null,++ });++ const last = Number(lastEventId);++ if (lastEventId != null && Number.isInteger(last) && last >= 0) {++ const limit = ctx.config.overlays.replayLimit;++ const rows = db.prepare("SELECT * FROM overlay_deliveries WHERE creator_subject = ? AND seq > ? AND status != 'failed' ORDER BY seq DESC LIMIT ?").all(client.creator, last, limit).reverse();++ client.lastSeq = rows.length ? rows[0].seq - 1 : maxSeq;++ for (const row of rows) send(client, row);++ client.lastSeq = Math.max(client.lastSeq, maxSeq);++ } else {++ const cutoff = iso(ctx.now() - ctx.config.overlays.ttlMs);++ const pending = db.prepare("SELECT * FROM overlay_deliveries WHERE creator_subject = ? AND status = 'pending' AND created_at >= ? ORDER BY seq LIMIT 100").all(client.creator, cutoff);++ for (const row of pending) send(client, row);++ client.lastSeq = Math.max(client.lastSeq, maxSeq);++ }++ pushNew(client);++ const beat = setInterval(() => { if (!write(client, 'ping', null, { t: Date.now() })) detach(client); }, ctx.config.overlays.heartbeatMs);++ if (beat.unref) beat.unref();++ client.beat = beat;++ res.on('close', () => detach(client));++ return client;++ }++++ function detach(client) {++ if (client.closed) return;++ client.closed = true;++ clearInterval(client.beat);++ const set = clients.get(client.creator);++ if (set) { set.delete(client); if (!set.size) clients.delete(client.creator); }++ try { client.res.end(); } catch { /* already gone */ }++ }++++ function closeToken(tokenId) {++ for (const set of clients.values()) {++ for (const c of [...set]) {++ if (c.tokenId === tokenId) { write(c, 'revoked', null, { reason: 'this overlay token was revoked' }); detach(c); }++ }++ }++ }++++ function connected(creator) { return (clients.get(creator) || new Set()).size; }++ function closeAll() { for (const set of clients.values()) for (const c of [...set]) detach(c); }++++ function recentDeliveries(creator, limit = 50) {++ return db.prepare('SELECT seq, id, kind, interaction_id, goal_id, test, status, sends, created_at, delivered_at, failed_at FROM overlay_deliveries WHERE creator_subject = ? ORDER BY seq DESC LIMIT ?').all(creator, limit);++ }++++ return {++ SCOPES, createToken, getToken, listTokens, authenticate, revokeToken, presentToken,++ getConfig, listConfigs, createConfig, updateConfig, presentConfig,++ addAlert, addGoalDelivery, sweepFailed, attach, detach, closeToken, notify, connected, closeAll, recentDeliveries,++ };++}++++module.exports = { createOverlays, SCOPES, TOKEN_RE };nnew file mode 100644@@@ -0,0 +1,127 @@++'use strict';++++/**++ * Creator tip profiles: one per creator subject. A profile is created the first time the creator++ * opens the dashboard (or a service creates it for them); the handle and display name are a++ * projection of the Network account, refreshed on every dashboard visit.++ *++ * page_enabled is the creator's switch for the public page at openvibe.tips/<handle>: while it is++ * off the page answers 404 to everyone else and nothing about it is indexable.++ */++const { fail, iso, text } = require('../util');++++const HANDLE_RE = /^[a-z0-9][a-z0-9_.-]{0,39}$/;++// Paths the site itself uses; no creator page can take them.++const RESERVED = new Set(['api', 'auth', 'overlay', 'internal', 'dashboard', 'receipts', 'assets', 'css', 'js', 'img', 'favicon.svg',++ 'robots.txt', 'sitemap.xml', 'release.json', 'manifest.webmanifest', 'terms', 'privacy', 'dmca', 'tos', 'about', 'help', 'goals', 'tips', 'admin', 'www']);++const VOICES = ['gary', 'brian', 'amy', 'emma', 'joey', 'justin', 'matthew', 'salli', 'kimberly', 'kendra', 'ivy', 'joanna'];++++function normalizeHandle(v) {++ const h = String(v || '').trim().toLowerCase().replace(/^@/, '');++ return HANDLE_RE.test(h) && !RESERVED.has(h) ? h : null;++}++++function parse(row) {++ if (!row) return null;++ const b = (k) => !!row[k];++ return { ...row, page_enabled: b('page_enabled'), accepting: b('accepting'), tts_enabled: b('tts_enabled'), media_requests_enabled: b('media_requests_enabled') };++}++++function createProfiles(ctx) {++ const { db } = ctx;++++ const bySubject = (s) => parse(db.prepare('SELECT * FROM creator_tip_profiles WHERE creator_subject = ?').get(s));++ const byHandle = (h) => { const n = normalizeHandle(h); return n ? parse(db.prepare('SELECT * FROM creator_tip_profiles WHERE handle = ?').get(n)) : null; };++ /** usr_… or a handle (with or without @). */++ const resolve = (ref) => (/^usr_/.test(String(ref || '')) ? bySubject(String(ref)) : byHandle(ref));++++ /** A handle not held by another creator: the username, else username-<suffix of the subject>. */++ function freeHandle(subject, username) {++ let h = normalizeHandle(username) || `creator-${subject.slice(-8).toLowerCase()}`;++ const holder = db.prepare('SELECT creator_subject FROM creator_tip_profiles WHERE handle = ?').get(h);++ if (holder && holder.creator_subject !== subject) h = `${h.slice(0, 30)}-${subject.slice(-6).toLowerCase()}`;++ return h;++ }++++ /** Create or refresh the profile from the creator's own Network account (claims). */++ function ensure(subject, { username, displayName, avatarUrl } = {}) {++ const at = iso(ctx.now());++ const existing = bySubject(subject);++ const name = String(displayName || username || 'Creator').replace(/[<>\u0000-\u001f]/g, '').trim().slice(0, 80) || 'Creator';++ if (!existing) {++ db.prepare(`INSERT INTO creator_tip_profiles (creator_subject, handle, display_name, avatar_url, created_at, updated_at)++ VALUES (?, ?, ?, ?, ?, ?)`).run(subject, freeHandle(subject, username), name, safeUrl(avatarUrl), at, at);++ return bySubject(subject);++ }++ const handle = normalizeHandle(username) && normalizeHandle(username) !== existing.handle ? freeHandle(subject, username) : existing.handle;++ if (handle !== existing.handle || name !== existing.display_name || (safeUrl(avatarUrl) || null) !== (existing.avatar_url || null)) {++ db.prepare('UPDATE creator_tip_profiles SET handle = ?, display_name = ?, avatar_url = ?, updated_at = ? WHERE creator_subject = ?')++ .run(handle, name, safeUrl(avatarUrl), at, subject);++ }++ return bySubject(subject);++ }++++ const LIMITS = {++ min_amount: [1, 1_000_000], paid_message_min: [1, 1_000_000], tts_min_amount: [1, 1_000_000],++ media_request_min: [1, 1_000_000], media_max_seconds: [10, 3 * 3600],++ };++ const BOOLS = ['page_enabled', 'accepting', 'tts_enabled', 'media_requests_enabled'];++++ function update(subject, patch = {}, { expectedRevision } = {}) {++ const p = bySubject(subject);++ if (!p) fail(404, 'tips.profile_not_found', 'no tip profile for this creator');++ if (expectedRevision != null && Number(expectedRevision) !== p.revision) fail(409, 'tips.revision_conflict', `the profile is at revision ${p.revision}`);++ const set = {};++ for (const k of BOOLS) if (patch[k] !== undefined) set[k] = patch[k] === true || patch[k] === 'on' || patch[k] === '1' || patch[k] === 1 ? 1 : 0;++ for (const [k, [lo, hi]] of Object.entries(LIMITS)) {++ if (patch[k] === undefined || patch[k] === '') continue;++ const n = Number(patch[k]);++ if (!Number.isInteger(n) || n < lo || n > hi) fail(422, 'tips.invalid_input', `${k} must be a whole number between ${lo} and ${hi}`);++ set[k] = n;++ }++ if (patch.tts_max_chars !== undefined && patch.tts_max_chars !== '') {++ const n = Number(patch.tts_max_chars);++ if (!Number.isInteger(n) || n < 20 || n > ctx.config.limits.ttsHardCap) fail(422, 'tips.invalid_input', `tts_max_chars must be between 20 and ${ctx.config.limits.ttsHardCap}`);++ set.tts_max_chars = n;++ }++ if (patch.tts_voice !== undefined) {++ if (!VOICES.includes(String(patch.tts_voice))) fail(422, 'tips.invalid_input', `tts_voice must be one of ${VOICES.join(', ')}`);++ set.tts_voice = String(patch.tts_voice);++ }++ if (patch.headline !== undefined) set.headline = text(patch.headline, 'headline', 280);++ if (patch.display_name !== undefined) {++ const n = text(patch.display_name, 'display_name', 80);++ if (!n) fail(422, 'tips.invalid_input', 'display_name cannot be empty');++ set.display_name = n.replace(/[<>]/g, '');++ }++ const keys = Object.keys(set);++ if (!keys.length) return p;++ db.prepare(`UPDATE creator_tip_profiles SET ${keys.map((k) => `${k} = @${k}`).join(', ')}, revision = revision + 1, updated_at = @at WHERE creator_subject = @s`)++ .run({ ...set, at: iso(ctx.now()), s: subject });++ return bySubject(subject);++ }++++ /** Public shape; `owner` adds the settings only the creator sees. */++ function present(p, { owner = false } = {}) {++ if (!p) return null;++ const out = {++ creator: { type: 'user', id: p.creator_subject }, handle: p.handle, display_name: p.display_name, avatar_url: p.avatar_url || null,++ headline: p.headline || null, page_enabled: p.page_enabled, accepting: p.accepting, url: `${ctx.config.baseUrl}/${p.handle}`,++ minimums: { tip: p.min_amount, paid_message: p.paid_message_min, tts: p.tts_enabled ? p.tts_min_amount : null, media_request: p.media_requests_enabled ? p.media_request_min : null },++ tts: { enabled: p.tts_enabled, max_chars: p.tts_max_chars, voice: p.tts_voice },++ media_requests: { enabled: p.media_requests_enabled, max_seconds: p.media_max_seconds },++ currency: 'vibes-bits',++ };++ if (owner) Object.assign(out, { revision: p.revision, created_at: p.created_at, updated_at: p.updated_at });++ return out;++ }++++ return { bySubject, byHandle, resolve, ensure, update, present, normalizeHandle };++}++++function safeUrl(u) {++ if (!u) return null;++ try { const x = new URL(String(u)); return x.protocol === 'https:' ? x.toString().slice(0, 500) : null; } catch { return null; }++}++++module.exports = { createProfiles, normalizeHandle, RESERVED, VOICES, safeUrl };nnew file mode 100644@@@ -0,0 +1,71 @@++'use strict';++++/**++ * Billing → Tips: POST /internal/events, the endpoint of Tips' OpenVibe.Events subscription++ * (topic pattern `billing.transaction.*`, created by scripts/subscribe.js).++ *++ * billing.transaction.settled a donation targeting a Tips interaction settles it; a donation Tips++ * did not start is recorded once; a purchase that funds a Tips++ * checkout triggers the transfer++ * billing.transaction.reversed flips the interaction's payment state; undelivered effects are++ * cancelled, delivered ones stay on record++ *++ * Exactly once, twice over: the openvibe-sdk inbox claims (consumer, event_id) in the same SQLite++ * transaction as the change, so a redelivered event does nothing; and the Billing transaction id is++ * UNIQUE on tip_interactions, so the same transaction arriving under another event id (a replay, a++ * republish) still yields one logical interaction.++ *++ * The signature (X-OpenVibe-Signature, HMAC-SHA256 of the raw body with TIPS_EVENTS_SECRET) is++ * verified with openvibe-sdk's parseDelivery. Only events whose source is `billing` are applied.++ */++const express = require('express');++const { http } = require('openvibe-contracts');++const { parseDelivery, createInbox } = require('openvibe-sdk/events');++++const CONSUMER = 'tips-billing';++++function consumerRouter({ domain, config, log = console }) {++ const router = express.Router();++ const inbox = createInbox(domain.db, { now: domain.now });++ inbox.ensureSchema();++++ /** Apply one envelope (also used by tests and the replay tool). Returns { duplicate, outcome }. */++ function apply(event) {++ return domain.tx(() => {++ const r = inbox.once(CONSUMER, event.event_id, () => {++ if (event.source !== 'billing') return 'ignored:source';++ const payload = event.payload && typeof event.payload === 'object' ? event.payload : {};++ if (event.event_type === 'billing.transaction.settled') return domain.interactions.onBillingSettled(payload);++ if (event.event_type === 'billing.transaction.reversed') return domain.interactions.onBillingReversed(payload);++ return 'ignored:type';++ });++ return r.duplicate ? { duplicate: true, outcome: null } : { duplicate: false, outcome: r.result };++ });++ }++++ router.post('/events', express.raw({ type: () => true, limit: '256kb' }), (req, res) => {++ const secrets = config.events.webhookSecrets;++ if (!secrets.length) return http.sendProblem(res, 503, 'tips.webhook_disabled', { detail: 'TIPS_EVENTS_SECRET is not set', ctx: req.ov });++ const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);++ let delivery = null;++ for (const s of secrets) { delivery = parseDelivery(raw, req.headers, s); if (delivery) break; }++ if (!delivery) return http.sendProblem(res, 401, 'tips.bad_signature', { detail: 'X-OpenVibe-Signature does not verify', ctx: req.ov });++ const event = delivery.event;++ if (!event || typeof event.event_id !== 'string' || !/^evt_[0-9A-HJKMNP-TV-Z]{26}$/.test(event.event_id)) {++ return http.sendProblem(res, 400, 'tips.bad_delivery', { detail: 'body must be { event: <envelope>, seq }', ctx: req.ov });++ }++ let out;++ try {++ out = apply(event);++ } catch (e) {++ // Not acknowledged: Events retries it, and the inbox claim rolled back with the change.++ log.error(`[Tips] event ${event.event_id} (${event.event_type}) failed:`, e.message);++ return http.sendProblem(res, 500, 'tips.event_failed', { detail: 'processing failed; it will be retried', ctx: req.ov });++ }++ res.status(200).json({ event_id: event.event_id, duplicate: out.duplicate, outcome: out.outcome });++ });++++ return { router, apply };++}++++module.exports = { consumerRouter, CONSUMER };nnew file mode 100644@@@ -0,0 +1,66 @@++'use strict';++++/**++ * Tips → OpenVibe.Events through the openvibe-sdk transactional outbox (ADR-004).++ *++ * tips.interaction.ready settled: the interaction is active and its effects are queued++ * tips.interaction.failed an effect gave up after its retries (payment state untouched)++ * tips.interaction.cancelled undelivered effects cancelled (payment reversed/failed, or the creator)++ * tips.goal.updated a goal's settled total, target or status changed++ * tips.overlay.delivered an overlay delivery reached at least one overlay (first time only)++ * tips.overlay.failed no overlay received it within the delivery window++ *++ * enqueue() runs inside the SQLite transaction that makes the change, so an event exists if and++ * only if its change committed. Simulations (test) never produce events. The relay publishes with++ * Tips' service token (events.event.publish, audience openvibe.events) only when EVENTS_URL and++ * OV_OAUTH_CLIENT_SECRET are set; otherwise rows wait in event_outbox.++ */++const { createClient } = require('openvibe-sdk/core');++const { createServiceTokenClient } = require('openvibe-sdk/auth');++const { createEventsClient, createOutbox } = require('openvibe-sdk/events');++++const ACTOR = { type: 'service', id: 'tips' };++++function createTipsOutbox({ db, config, fetchImpl, now, log = console }) {++ const enabled = !!(config.events.url && config.oauth.clientSecret);++ const clientOpts = { baseUrls: { events: config.events.url || 'http://127.0.0.1:4300' }, retries: 0 };++ if (fetchImpl) clientOpts.fetch = fetchImpl;++ if (enabled) {++ clientOpts.tokenProvider = createServiceTokenClient({++ tokenUrl: `${config.network.internalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,++ scope: { 'openvibe.events': 'events.event.publish' }, ...(fetchImpl ? { fetch: fetchImpl } : {}),++ });++ } else {++ clientOpts.getToken = async () => { throw new Error('events relay disabled (EVENTS_URL / OV_OAUTH_CLIENT_SECRET unset)'); };++ }++ const events = createEventsClient(createClient(clientOpts), { source: 'tips' });++ let lastError = null;++ const outbox = createOutbox(db, {++ events,++ intervalMs: config.events.intervalMs,++ now,++ onError: (err) => {++ const msg = err && err.message;++ if (msg !== lastError) log.warn('[Tips] event publish failed (will retry):', msg);++ lastError = msg;++ },++ });++ outbox.ensureSchema();++++ /** Inside the caller's transaction. */++ function emit(eventType, subject, payload, { visibility = 'internal', priority = 'important', traceparent } = {}) {++ return outbox.enqueue({ event_type: eventType, actor: ACTOR, subject, payload, visibility, priority }, { traceparent });++ }++++ return {++ emit,++ outbox,++ enabled,++ start() { if (enabled) outbox.start(); },++ stop: () => outbox.stop(),++ kick() { if (enabled) outbox.kick(); },++ status: () => ({ enabled, pending: outbox.pending(), rejected: outbox.rejected(), last_error: lastError }),++ };++}++++module.exports = { createTipsOutbox };nnew file mode 100644@@@ -0,0 +1,289 @@++'use strict';++++/**++ * Import tip history from a SNAPSHOT COPY of OpenVibe.Live's database (opened read-only), linked to++ * OpenVibe.Billing's imported journal, with a reconciliation report.++ *++ * 1. identities every Live user id in a tip row → Network subject (resolve-batch). A tip whose++ * CREATOR has no subject is held (migration_maps status 'held'), never dropped; a++ * later run imports it once the Network knows the creator. An unmapped supporter is++ * imported with no subject and their Live username as the display name.++ * 2. donations transactions (type 'donation') → tip_interactions, settlement 'imported':++ * completed → payment settled; anything else → excluded with the reason (no balance++ * moved on Live). Rows before site_settings.stats_vibes_reset_at are imported flagged++ * test, exactly as Billing flags them. Media-request charges ("Media request: …")++ * become kind media_request. Each row is linked to Billing's transaction for it++ * (Billing's importer key `import:live:txn:<live id>`).++ * 3. refunds transactions (type 'refund', streamer → donor) are applied to the latest earlier++ * donation from that donor to that streamer that still has room (reversed_bits), so a++ * creator's total nets them out the way Billing's journal does; none left → held.++ * 4. external chat_messages of kind donation with source powerchat — the only record Live kept of++ * tips on a streamer's OWN PowerChat (EXTERNAL, no Billing liability). A message that++ * matches a site-routed "PowerChat tip via site account" donation row (same streamer,++ * same amount, within 10 minutes) is that row's celebration and is excluded, not++ * counted twice.++ * 5. goals donation_goals → tip_goals (Live's current_amount carried over as opening_amount:++ * Live never recorded which donations counted toward which goal).++ *++ * Reconciliation: per creator subject, Tips' settled-via-Billing total (non-test, minus refunds) must++ * equal Billing's (donations to the creator minus refunds from them, non-test). Any difference is++ * listed; nothing is absorbed.++ *++ * Idempotent: every row is keyed by legacy_source / migration_maps (live, table, id); a re-run changes++ * nothing, and a later snapshot only adds what is new or releases what was held. --dry-run does it all++ * inside a transaction that is rolled back.++ */++const { iso, prefixedId, json } = require('../util');++++const liveTs = (s) => {++ if (!s) return null;++ const str = String(s);++ const t = Date.parse(str.includes('T') ? str : `${str.replace(' ', 'T')}Z`);++ return Number.isFinite(t) ? new Date(t).toISOString() : null;++};++const tableExists = (db, name) => !!db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name);++const columns = (db, t) => (tableExists(db, t) ? db.prepare(`PRAGMA table_info(${t})`).all().map((c) => c.name) : []);++++class DryRun extends Error {}++++/**++ * Billing's side, from a read-only SNAPSHOT of billing.db (operator tool, like Billing reading Live's++ * snapshot): the transaction Billing's importer made for each Live row, and per-creator totals.++ */++function billingSnapshotSource(bdb) {++ const byKey = bdb.prepare("SELECT id, test FROM transactions WHERE idempotency_key = ?");++ return {++ name: 'billing snapshot',++ linkLiveTxn(liveId) { const r = byKey.get(`import:live:txn:${liveId}`); return r ? r.id : null; },++ creatorTotals() {++ const totals = new Map();++ const add = (s, n) => { if (s && n) totals.set(s, (totals.get(s) || 0) + n); };++ const rows = bdb.prepare('SELECT id, type, status, from_subject, to_subject, reverses_txn, metadata FROM transactions WHERE test = 0').all();++ const donations = new Set(rows.filter((t) => t.type === 'donation').map((t) => t.id));++ for (const t of rows) {++ const m = json(t.metadata, {});++ if (t.type === 'import' && m.live && m.live.status === 'completed' && !m.note) {++ // Billing's replay of Live's history: donation → the creator's payable; refund → out of it.++ if (m.live.type === 'donation') add(t.to_subject, Number(m.live.amount) || 0);++ if (m.live.type === 'refund') add(t.from_subject, -(Number(m.live.amount) || 0));++ } else if (t.type === 'donation') {++ add(t.to_subject, Number(m.amount_bits) || 0);++ } else if (t.type === 'refund' && donations.has(t.reverses_txn) && m.amount_bits != null) {++ // A credit-funded transfer given back (the creator's payable → the giver's credit).++ add(t.from_subject, -(Number(m.amount_bits) || 0));++ }++ }++ return totals;++ },++ };++}++++async function importLive(domain, { live, billingSource = null, resolveLiveUsers, dryRun = false, log = console }) {++ const { db } = domain;++ const started = domain.now();++ const runId = prefixedId('timp', started);++ const at = iso(started);++ const report = {++ run_id: runId, dry_run: !!dryRun, started_at: at, source: 'openvibe-live snapshot', billing_source: billingSource ? billingSource.name : null,++ counts: { imported: 0, held: 0, excluded: 0, refunds_applied: 0, external: 0, goals: 0, unchanged: 0, released: 0, linked_to_billing: 0, unlinked: 0 },++ holds: [], excluded: [], unlinked: [], reconciliation: null,++ };++++ // ── Read the snapshot ────────────────────────────────────++ const txns = tableExists(live, 'transactions') ? live.prepare("SELECT * FROM transactions WHERE type IN ('donation', 'refund') ORDER BY id").all() : [];++ const chatCols = columns(live, 'chat_messages');++ const chatDonations = chatCols.includes('metadata')++ ? live.prepare("SELECT id, channel_user_id, username, message, metadata, created_at FROM chat_messages WHERE message_type = 'donation' AND metadata LIKE '%powerchat%' ORDER BY id").all()++ : [];++ const goalRows = tableExists(live, 'donation_goals') ? live.prepare('SELECT * FROM donation_goals ORDER BY id').all() : [];++ const users = new Map((tableExists(live, 'users') ? live.prepare('SELECT id, username, display_name FROM users').all() : []).map((u) => [String(u.id), u]));++ let resetAt = null;++ if (tableExists(live, 'site_settings')) {++ const r = live.prepare("SELECT value FROM site_settings WHERE key = 'stats_vibes_reset_at'").get();++ if (r && r.value && !Number.isNaN(Date.parse(r.value))) resetAt = new Date(r.value).toISOString();++ }++ report.test_before = resetAt;++ report.counts.live = { tip_transactions: txns.length, powerchat_chat_donations: chatDonations.length, goals: goalRows.length };++++ // ── 1. identities ────────────────────────────────────────++ const ids = new Set();++ for (const t of txns) { if (t.from_user_id) ids.add(String(t.from_user_id)); if (t.to_user_id) ids.add(String(t.to_user_id)); }++ for (const c of chatDonations) if (c.channel_user_id) ids.add(String(c.channel_user_id));++ for (const g of goalRows) if (g.user_id) ids.add(String(g.user_id));++ const map = ids.size ? await resolveLiveUsers([...ids]) : new Map();++ const subjectOf = (liveId) => { const v = liveId == null ? null : map.get(String(liveId)); return v ? (typeof v === 'string' ? v : v.subject) : null; };++ const nameOf = (liveId) => { const u = users.get(String(liveId)); return u ? (u.display_name || u.username) : null; };++ report.counts.identities = { resolved: [...ids].filter((i) => subjectOf(i)).length, unmapped: [...ids].filter((i) => !subjectOf(i)).length };++++ const mapRow = db.prepare('SELECT * FROM migration_maps WHERE source = ? AND source_table = ? AND source_id = ?');++ const upsertMap = db.prepare(`INSERT INTO migration_maps (source, source_table, source_id, target_type, target_id, status, reason, billing_txn_id, run_id, created_at, updated_at)++ VALUES ('live', @table, @id, @target_type, @target_id, @status, @reason, @billing, @run, @at, @at)++ ON CONFLICT (source, source_table, source_id) DO UPDATE SET target_type = excluded.target_type, target_id = excluded.target_id, status = excluded.status,++ reason = excluded.reason, billing_txn_id = COALESCE(excluded.billing_txn_id, migration_maps.billing_txn_id), run_id = excluded.run_id, updated_at = excluded.updated_at`);++ const record = (table, id, fields) => upsertMap.run({ table, id: String(id), target_type: null, target_id: null, reason: null, billing: null, run: runId, at, ...fields });++++ const run = () => {++ // ── 2. donations ─────────────────────────────────────++ for (const t of txns.filter((x) => x.type === 'donation')) {++ const prev = mapRow.get('live', 'transactions', String(t.id));++ if (prev && prev.status !== 'held') {++ // Re-link to Billing when an earlier run had no Billing transaction for it yet.++ if (prev.status === 'imported' && !prev.billing_txn_id && billingSource) {++ const b = billingSource.linkLiveTxn(t.id);++ if (b) { db.prepare('UPDATE tip_interactions SET billing_txn_id = ? WHERE id = ? AND billing_txn_id IS NULL').run(b, prev.target_id); record('transactions', t.id, { ...pick(prev), billing: b }); report.counts.linked_to_billing++; }++ }++ report.counts.unchanged++;++ continue;++ }++ const amount = Math.round(Number(t.amount) || 0);++ const creator = subjectOf(t.to_user_id);++ if (t.status !== 'completed' || amount <= 0) {++ const reason = amount <= 0 ? 'non-positive amount' : `status ${t.status}: no balance moved on Live`;++ record('transactions', t.id, { status: 'excluded', reason });++ report.counts.excluded++; report.excluded.push({ live_txn: t.id, reason });++ continue;++ }++ if (!creator) {++ const reason = `creator (Live user ${t.to_user_id}) has no Network subject yet`;++ record('transactions', t.id, { status: 'held', reason });++ report.counts.held++; report.holds.push({ live_txn: t.id, live_creator: t.to_user_id, amount });++ continue;++ }++ const created = liveTs(t.created_at) || at;++ const test = !!(resetAt && created < resetAt);++ const billingTxn = billingSource ? billingSource.linkLiveTxn(t.id) : null;++ const message = t.message ? String(t.message).slice(0, 500) : null;++ const media = /^Media request:/i.test(message || '');++ const site = /^PowerChat tip via site account/i.test(message || '');++ const i = domain.interactions.insert({++ id: prefixedId('tint', Date.parse(created)), creator_subject: creator, supporter_subject: subjectOf(t.from_user_id),++ supporter_name: nameOf(t.from_user_id) || (site ? 'PowerChat supporter' : null), kind: media ? 'media_request' : 'tip', amount,++ message: media || site ? null : message, request: { legacy_message: message, live_stream_id: t.stream_id || null },++ funding: site ? 'provider' : 'credit', settlement: 'imported', payment_state: 'settled', delivery_state: 'delivered',++ test: test ? 1 : 0, billing_txn_id: billingTxn, provider: site ? 'powerchat' : null, legacy_source: `live:transactions:${t.id}`,++ origin: 'import', created_at: created,++ target: t.stream_id ? { service: 'live', type: 'stream', id: String(t.stream_id) } : null,++ });++ db.prepare('UPDATE tip_interactions SET settled_at = ?, delivered_at = ? WHERE id = ?').run(created, created, i.id);++ record('transactions', t.id, { status: 'imported', target_type: 'interaction', target_id: i.id, billing: billingTxn, reason: prev ? 'released from hold' : null });++ if (prev) report.counts.released++;++ report.counts.imported++;++ if (billingTxn) report.counts.linked_to_billing++;++ else { report.counts.unlinked++; report.unlinked.push({ live_txn: t.id, interaction: i.id }); }++ }++++ // ── 3. refunds (streamer → donor) ────────────────────++ for (const t of txns.filter((x) => x.type === 'refund')) {++ const prev = mapRow.get('live', 'transactions', String(t.id));++ if (prev && prev.status !== 'held') { report.counts.unchanged++; continue; }++ const amount = Math.round(Number(t.amount) || 0);++ if (t.status !== 'completed' || amount <= 0) { record('transactions', t.id, { status: 'excluded', reason: `refund with status ${t.status}` }); report.counts.excluded++; continue; }++ const creator = subjectOf(t.from_user_id);++ const donor = subjectOf(t.to_user_id);++ const created = liveTs(t.created_at) || at;++ const candidates = creator ? db.prepare(`SELECT * FROM tip_interactions WHERE creator_subject = ? AND settlement = 'imported' AND legacy_source LIKE 'live:transactions:%'++ AND (supporter_subject IS ? ) AND created_at <= ? AND amount - reversed_bits > 0 ORDER BY created_at DESC`).all(creator, donor, created) : [];++ // Plan first; apply only when the whole refund can be placed.++ let left = amount;++ const plan = [];++ for (const c of candidates) {++ if (!left) break;++ const take = Math.min(left, c.amount - c.reversed_bits);++ plan.push({ c, take });++ left -= take;++ }++ if (left > 0) {++ record('transactions', t.id, { status: 'held', reason: `refund of ${amount} from Live user ${t.from_user_id} to ${t.to_user_id}: no earlier imported donation left to apply it to` });++ report.counts.held++; report.holds.push({ live_txn: t.id, refund: true, amount });++ continue;++ }++ const applied = [];++ for (const { c, take } of plan) {++ const ids = json(c.reversal_txn_ids, []); ids.push(`live:transactions:${t.id}`);++ db.prepare("UPDATE tip_interactions SET reversed_bits = reversed_bits + ?, payment_state = 'reversed', reversed_at = ?, reversal_txn_ids = ? WHERE id = ?").run(take, created, JSON.stringify(ids), c.id);++ applied.push({ interaction: c.id, bits: take });++ }++ record('transactions', t.id, { status: 'imported', target_type: 'refund', target_id: applied.map((a) => a.interaction).join(','), billing: billingSource ? billingSource.linkLiveTxn(t.id) : null });++ report.counts.refunds_applied++;++ }++++ // ── 4. external PowerChat tips (chat record only) ────++ const siteRows = txns.filter((x) => x.type === 'donation' && /^PowerChat tip via site account/i.test(x.message || ''));++ const usedSite = new Set();++ for (const c of chatDonations) {++ const m = json(c.metadata, {});++ if (m.source !== 'powerchat' || m.kind !== 'donation') continue;++ const prev = mapRow.get('live', 'chat_messages', String(c.id));++ if (prev && prev.status !== 'held') { report.counts.unchanged++; continue; }++ const amount = Math.round(Number(m.amount) || 0);++ const created = liveTs(c.created_at) || at;++ const twin = siteRows.find((t) => !usedSite.has(t.id) && String(t.to_user_id) === String(c.channel_user_id) && Math.round(Number(t.amount)) === amount++ && Math.abs(Date.parse(liveTs(t.created_at) || at) - Date.parse(created)) <= 10 * 60 * 1000);++ if (twin) {++ usedSite.add(twin.id);++ record('chat_messages', c.id, { status: 'excluded', reason: `celebration of site-routed tip (Live transaction ${twin.id})` });++ report.counts.excluded++;++ continue;++ }++ if (amount <= 0) { record('chat_messages', c.id, { status: 'excluded', reason: 'no amount' }); report.counts.excluded++; continue; }++ const creator = subjectOf(c.channel_user_id);++ if (!creator) {++ record('chat_messages', c.id, { status: 'held', reason: `creator (Live user ${c.channel_user_id}) has no Network subject yet` });++ report.counts.held++; report.holds.push({ live_chat_message: c.id, live_creator: c.channel_user_id, amount });++ continue;++ }++ const i = domain.interactions.insert({++ id: prefixedId('tint', Date.parse(created)), creator_subject: creator, supporter_name: m.username ? String(m.username).slice(0, 80) : 'PowerChat supporter',++ kind: 'tip', amount, amount_cents: amount, message: m.message ? String(m.message).slice(0, 500) : null, funding: 'external', settlement: 'external',++ payment_state: 'settled', delivery_state: 'delivered', test: resetAt && created < resetAt ? 1 : 0, provider: 'powerchat',++ provider_ref: `live-chat:${c.id}`, legacy_source: `live:chat_messages:${c.id}`, origin: 'import', created_at: created,++ });++ db.prepare('UPDATE tip_interactions SET settled_at = ?, delivered_at = ? WHERE id = ?').run(created, created, i.id);++ record('chat_messages', c.id, { status: 'imported', target_type: 'interaction', target_id: i.id });++ report.counts.external++;++ }++++ // ── 5. goals ─────────────────────────────────────────++ for (const g of goalRows) {++ const prev = mapRow.get('live', 'donation_goals', String(g.id));++ if (prev && prev.status !== 'held') { report.counts.unchanged++; continue; }++ const creator = subjectOf(g.user_id);++ if (!creator) { record('donation_goals', g.id, { status: 'held', reason: `creator (Live user ${g.user_id}) has no Network subject yet` }); report.counts.held++; continue; }++ const target = Math.round(Number(g.target_amount) || 0);++ if (target <= 0 || !g.title) { record('donation_goals', g.id, { status: 'excluded', reason: 'no title or target' }); report.counts.excluded++; continue; }++ const created = liveTs(g.created_at) || at;++ const id = prefixedId('tgoal', Date.parse(created));++ db.prepare(`INSERT INTO tip_goals (id, creator_subject, title, target_amount, image_url, status, sort_order, reached_at, closed_at, opening_amount, legacy_source, created_at, updated_at)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, creator, String(g.title).slice(0, 120), target,++ /^https:\/\//.test(String(g.image_url || '')) ? g.image_url : null, g.is_active ? 'active' : 'closed', Number(g.sort_order) || 0,++ liveTs(g.reached_at), g.is_active ? null : at, Math.max(0, Math.round(Number(g.current_amount) || 0)), `live:donation_goals:${g.id}`, created, at);++ record('donation_goals', g.id, { status: 'imported', target_type: 'goal', target_id: id, reason: `opening_amount ${Math.round(Number(g.current_amount) || 0)} carried over from Live's current_amount` });++ report.counts.goals++;++ }++++ if (dryRun) { report.reconciliation = reconcile(domain, billingSource); throw new DryRun(); }++ };++++ try { db.transaction(run)(); } catch (e) { if (!(e instanceof DryRun)) throw e; }++ if (!dryRun) report.reconciliation = reconcile(domain, billingSource);++ report.finished_at = iso(domain.now());++ db.prepare('INSERT INTO import_runs (id, source, dry_run, started_at, finished_at, report) VALUES (?, ?, ?, ?, ?, ?)').run(runId, 'live', dryRun ? 1 : 0, report.started_at, report.finished_at, JSON.stringify(report));++ if (log && log.log) log.log(`[Tips] import ${runId}${dryRun ? ' (dry run, rolled back)' : ''}: ${report.counts.imported} tips, ${report.counts.external} external, ${report.counts.goals} goals, ${report.counts.held} held, ${report.counts.excluded} excluded; reconciliation ${report.reconciliation ? (report.reconciliation.ok ? 'OK' : 'MISMATCH') : 'skipped (no Billing source)'}`);++ return report;++}++++function pick(row) { return { status: row.status, target_type: row.target_type, target_id: row.target_id, reason: row.reason }; }++++/** Per-creator totals: Tips (settled via Billing or imported, non-test, minus refunds) vs Billing. */++function reconcile(domain, billingSource) {++ if (!billingSource) return null;++ const billing = billingSource.creatorTotals();++ const tips = new Map(domain.db.prepare(`SELECT creator_subject AS s, SUM(amount - reversed_bits) AS n FROM tip_interactions++ WHERE test = 0 AND settlement IN ('billing', 'imported') AND payment_state IN ('settled', 'reversed') GROUP BY creator_subject`).all().map((r) => [r.s, r.n]));++ const creators = [...new Set([...billing.keys(), ...tips.keys()])].sort();++ const rows = creators.map((s) => ({ creator: s, tips: tips.get(s) || 0, billing: billing.get(s) || 0, difference: (tips.get(s) || 0) - (billing.get(s) || 0) }));++ const mismatches = rows.filter((r) => r.difference !== 0);++ return { ok: mismatches.length === 0, creators: rows.length, mismatches, rows };++}++++module.exports = { importLive, reconcile, billingSnapshotSource, liveTs };nnew file mode 100644@@@ -0,0 +1,45 @@++'use strict';++++/**++ * OpenVibe.Tips — process entry. `node server/index.js`++ * Listens on PORT (4610) behind nginx (openvibe.tips); see deploy/.++ *++ * Background jobs (TIPS_JOBS=off disables them): the effects worker (chat delivery with retries),++ * due Billing transfers (funded checkouts, retries after Billing was unreachable), the overlay++ * delivery window (pending → failed), and the events outbox relay when EVENTS_URL is set.++ */++const { loadConfig } = require('./config');++const { createApp } = require('./app');++++const config = loadConfig();++const app = createApp({ config });++const { domain, keys, outbox } = app.locals;++keys.start();++++const timers = [];++if (config.jobs.enabled) {++ const every = (ms, fn) => { const t = setInterval(() => { Promise.resolve().then(fn).catch((e) => console.warn('[Tips] job:', e.message)); }, ms); t.unref(); timers.push(t); };++ every(config.jobs.intervalMs, () => domain.effects.drain());++ every(Math.max(config.jobs.intervalMs, 5000), () => domain.interactions.processDueTransfers());++ every(30_000, () => domain.overlays.sweepFailed());++ every(6 * 3600 * 1000, () => outbox.outbox.prune());++ outbox.start();++}++++const server = app.listen(config.port, config.host, () => {++ console.log(`[Tips] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${config.dbPath})`);++ console.log(`[Tips] billing ${config.billing.url}; chat adapter ${config.chat.adapter}; events relay ${outbox.enabled ? `→ ${config.events.url}` : 'off (outbox accumulates)'}; billing events ${config.events.webhookSecrets.length ? 'accepted' : 'not accepted (TIPS_EVENTS_SECRET unset)'}`);++});++server.keepAliveTimeout = 65_000;++++function shutdown(signal) {++ console.log(`[Tips] ${signal} — closing`);++ timers.forEach(clearInterval);++ domain.overlays.closeAll();++ outbox.stop();++ keys.stop();++ server.close(() => { try { domain.db.close(); } catch { /* */ } process.exit(0); });++ setTimeout(() => process.exit(0), 5000).unref();++}++process.on('SIGTERM', () => shutdown('SIGTERM'));++process.on('SIGINT', () => shutdown('SIGINT'));nnew file mode 100644@@@ -0,0 +1,120 @@++'use strict';++++/**++ * OpenVibe.Network client.++ *++ * keys the Network's RS256 public key (OV_NETWORK_PUBLIC_KEY, else GET /api/.well-known/jwks,++ * refreshed every 6 h and retried every 30 s until it loads). It verifies service tokens++ * (audience openvibe.tips) and the browser's Network user JWT, both offline.++ * identity Live user ids → canonical subjects via POST /internal/identity/resolve-batch++ * (capability identity.subject.resolve, audience openvibe.network) — the importer only.++ */++const crypto = require('crypto');++const { serviceAuth } = require('openvibe-contracts');++const jwt = require('jsonwebtoken');++const { OpenVibeAuthClient } = require('openvibe-shared/auth-client');++++const BATCH = 500;++++function createKeyProvider(config, { fetchImpl = globalThis.fetch, log = console } = {}) {++ let pem = config.network.publicKey ? crypto.createPublicKey(config.network.publicKey).export({ type: 'spki', format: 'pem' }) : null;++ let timer = null;++ async function load() {++ const url = `${config.network.internalUrl}/api/.well-known/jwks`;++ try {++ const res = await fetchImpl(url, { signal: AbortSignal.timeout(8000) });++ if (!res.ok) throw new Error(`JWKS ${res.status}`);++ const body = await res.json();++ const jwk = (body.keys || []).find((k) => k.kty === 'RSA');++ if (jwk) pem = crypto.createPublicKey({ key: jwk, format: 'jwk' }).export({ type: 'spki', format: 'pem' });++ else if (typeof body.public_key === 'string') pem = crypto.createPublicKey(body.public_key).export({ type: 'spki', format: 'pem' });++ else throw new Error('JWKS contained no keys');++ return pem;++ } catch (e) {++ log.warn(`[Tips] Network key not loaded from ${url}: ${e.message}`);++ return null;++ }++ }++ function start() {++ if (config.network.publicKey || timer) return;++ const retry = () => load().then((k) => { if (!k) setTimeout(retry, 30_000).unref(); });++ retry();++ timer = setInterval(() => { load(); }, 6 * 60 * 60 * 1000);++ timer.unref();++ }++ function stop() { if (timer) clearInterval(timer); timer = null; }++ return { get: () => pem, load, start, stop };++}++++/**++ * The OAuth client of the browser session layer plus offline verification of the Network user++ * JWT (RS256, issuer = the Network's public URL).++ */++function createUserAuth(config, keys) {++ const client = new OpenVibeAuthClient({++ clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret,++ redirectUri: config.oauth.redirectUri,++ publicKey: null,++ authBase: config.network.url,++ internalBase: config.network.internalUrl,++ });++ /** Claims of a valid user token, or null. Service tokens are never user tokens. */++ function verify(token) {++ if (!token) return null;++ const key = keys.get();++ if (!key) return null;++ let claims;++ try { claims = jwt.verify(token, key, { algorithms: ['RS256'], issuer: config.network.issuer }); } catch { return null; }++ if (!claims || typeof claims !== 'object') return null;++ if (typeof claims.sub === 'string' && /^(svc|app|mod):/.test(claims.sub)) return null;++ if (claims.actor_type === 'service') return null;++ return claims;++ }++ return { client, verify };++}++++function createIdentity(config, { fetchImpl = globalThis.fetch } = {}) {++ const base = config.network.internalUrl;++ let tokens = null;++ const tokenClient = () => {++ if (!tokens) {++ tokens = serviceAuth.createTokenClient({++ tokenUrl: `${base}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,++ audience: 'openvibe.network', scope: 'identity.subject.resolve', fetchImpl,++ });++ }++ return tokens;++ };++++ async function post(body, retried = false) {++ const res = await fetchImpl(`${base}/internal/identity/resolve-batch`, {++ method: 'POST',++ headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(await tokenClient().authHeaders()) },++ body: JSON.stringify(body),++ signal: AbortSignal.timeout(10000),++ });++ if (res.status === 401 && !retried) { tokenClient().invalidate(); return post(body, true); }++ const data = await res.json().catch(() => null);++ if (!res.ok || !data || typeof data.results !== 'object') throw new Error(`resolve-batch ${res.status}: ${(data && (data.detail || data.error)) || 'bad response'}`);++ return data.results;++ }++++ /** Live user ids → Map(String(id) → { subject, username } | null). */++ async function resolveLiveUsers(liveIds) {++ const out = new Map();++ const all = [...new Set(liveIds.map(String))];++ for (let i = 0; i < all.length; i += BATCH) {++ const chunk = all.slice(i, i + BATCH);++ const results = await post({ system: 'live', type: 'user', ids: chunk });++ for (const k of chunk) {++ const p = results[k];++ out.set(k, p && p.subject && p.subject.type === 'user' ? { subject: p.subject.id, username: p.username || null } : null);++ }++ }++ return out;++ }++ return { resolveLiveUsers };++}++++module.exports = { createKeyProvider, createUserAuth, createIdentity };nnew file mode 100644@@@ -0,0 +1,62 @@++'use strict';++++/** Small shared pieces: ids, time, the error type, input checks. */++const crypto = require('crypto');++const { ids, validate } = require('openvibe-contracts');++++class TipsError extends Error {++ constructor(status, code, detail, extra) {++ super(detail || code);++ this.status = status;++ this.code = code;++ this.detail = detail;++ this.extra = extra;++ }++}++++function fail(status, code, detail, extra) { throw new TipsError(status, code, detail, extra); }++++const prefixedId = (prefix, ms = Date.now()) => `${prefix}_${ids.ulid(ms)}`;++const iso = (ms) => new Date(ms).toISOString();++const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');++const json = (v, d) => { if (v == null || v === '') return d; try { return JSON.parse(v); } catch { return d; } };++++function positiveInt(v, field, max) {++ const n = Number(v);++ if (!Number.isInteger(n) || n <= 0) fail(422, 'tips.invalid_amount', `${field} must be a positive whole number of bits`);++ if (max && n > max) fail(422, 'tips.invalid_amount', `${field} must be at most ${max}`);++ return n;++}++++/** Optional trimmed text: null when empty, refused when longer than max. */++function text(v, field, max) {++ if (v == null) return null;++ const s = String(v).replace(/\r\n?/g, '\n').trim();++ if (!s) return null;++ if (s.length > max) fail(422, 'tips.text_too_long', `${field} must be at most ${max} characters`);++ return s;++}++++/** A user SubjectRef or bare usr_ id → the subject id. */++function userSubject(v, field = 'subject') {++ const ref = typeof v === 'string' ? { type: 'user', id: v } : v;++ if (!ref || !validate('identity.subject-ref@1', ref).valid || ref.type !== 'user' || !ids.isSubjectId('user', ref.id)) {++ fail(422, 'tips.invalid_subject', `${field} must be a user SubjectRef ({ type: 'user', id: 'usr_…' })`);++ }++ return ref.id;++}++++function entityRef(v, field = 'target') {++ if (v == null) return null;++ if (!validate('common.entity-ref@1', v).valid) fail(422, 'tips.invalid_input', `${field} must be an EntityRef ({ service, type, id })`);++ return { service: v.service, type: v.type, id: String(v.id) };++}++++/** The display name a supporter chose, kept short, without markup characters. */++function displayName(v) {++ if (v == null) return null;++ const s = String(v).replace(/[<>\u0000-\u001f]/g, '').trim().slice(0, 80);++ return s || null;++}++++module.exports = { TipsError, fail, prefixedId, iso, sha256, json, positiveInt, text, userSubject, entityRef, displayName };nnew file mode 100644@@@ -0,0 +1,108 @@++'use strict';++++/**++ * Page shell for every server-rendered page: full <head> SEO (title, description, canonical,++ * robots, Open Graph), the shared OpenVibe chrome (inline critical canvas + app icon from++ * openvibe-shared, navbar.js from the Network, the SSR footer and a <noscript> navigation), this++ * site's stylesheet and its small progressive script. Everything is useful without JavaScript.++ */++const crypto = require('crypto');++const fs = require('fs');++const path = require('path');++const appIcon = require('openvibe-shared/app-icon');++const chrome = require('openvibe-shared/chrome-ssr');++++const SITE_NAME = 'OpenVibe.Tips';++const NETWORK_URL = 'https://openvibe.network';++const DEFAULT_DESCRIPTION = 'Support the creators you watch on OpenVibe: tips, goals, paid messages, text-to-speech and media requests, with stream overlays for creators.';++const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');++++const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));++++const _hashes = new Map();++function asset(rel) {++ if (!_hashes.has(rel)) {++ let v = 'dev';++ try { v = crypto.createHash('sha256').update(fs.readFileSync(path.join(PUBLIC_DIR, rel))).digest('hex').slice(0, 10); } catch { /* missing in tests */ }++ _hashes.set(rel, v);++ }++ return `/${rel}?v=${_hashes.get(rel)}`;++}++const assetVersion = (rel) => { asset(rel); return _hashes.get(rel); };++++const NAV_LINKS = [++ { label: 'Dashboard', href: '/dashboard' },++ { label: 'Receipts', href: '/receipts' },++];++++function createLayout({ config, release }) {++ const abs = (p) => (/^https?:\/\//i.test(p) ? p : `${config.baseUrl}${p.startsWith('/') ? '' : '/'}${p}`);++++ /**++ * o: title, description, canonicalPath, robots ('index,follow' | 'noindex,nofollow'), body,++ * active ('home'|'dashboard'|'receipts'|'creator'), ogImage, jsonLd (array), viewer++ */++ function page(o) {++ const title = o.title ? `${o.title} · ${SITE_NAME}` : `${SITE_NAME} — support the creators you watch`;++ const description = (o.description || DEFAULT_DESCRIPTION).replace(/\s+/g, ' ').trim().slice(0, 300);++ const canonical = abs(o.canonicalPath || '/');++ const robots = o.robots || 'index,follow';++ const nav = {++ service: 'tips', apiBase: NETWORK_URL,++ links: NAV_LINKS.map((l) => ({ ...l, active: o.active === l.label.toLowerCase() })),++ history: { type: 'page', title: o.title || SITE_NAME },++ silentLogin: `${config.baseUrl}/auth/login?silent=1&next={url}`,++ sessionUrl: '/auth/me',++ loginUrl: `/auth/login?next=${encodeURIComponent(o.canonicalPath || '/')}`,++ };++ const jsonLd = (o.jsonLd || []).map((x) => `<script type="application/ld+json">${JSON.stringify(x).replace(/</g, '\\u003c')}</script>`).join('\n');++ const who = o.viewer++ ? `<span class="who">Signed in as <b>${esc(o.viewer.name || o.viewer.username || 'you')}</b> · <a href="/auth/logout?next=/">Sign out</a></span>`++ : `<a class="who" href="/auth/login?next=${encodeURIComponent(o.canonicalPath || '/')}">Sign in</a>`;++ return `<!DOCTYPE html>++<html lang="en" data-page="${esc(o.active || 'page')}">++<head>++<meta charset="utf-8">++<meta name="viewport" content="width=device-width, initial-scale=1">++<title>${esc(title)}</title>++<meta name="description" content="${esc(description)}">++<link rel="canonical" href="${esc(canonical)}">++<meta name="robots" content="${esc(robots)}">++<meta name="referrer" content="strict-origin-when-cross-origin">++<meta property="og:site_name" content="${SITE_NAME}">++<meta property="og:type" content="${o.ogType || 'website'}">++<meta property="og:title" content="${esc(o.title || SITE_NAME)}">++<meta property="og:description" content="${esc(description)}">++<meta property="og:url" content="${esc(canonical)}">++${o.ogImage ? `<meta property="og:image" content="${esc(o.ogImage)}">` : ''}++<meta name="twitter:card" content="summary">++${appIcon.headTags({ site: 'tips' })}++${release ? release.metaTag() : ''}++<script src="${NETWORK_URL}/shared/theme-loader.js" defer></script>++<link rel="stylesheet" href="${asset('css/tips.css')}">++${jsonLd}++<script src="${NETWORK_URL}/shared/navbar.js" defer></script>++<script src="${asset('js/tips.js')}" defer></script>++</head>++<body>++<div id="navbar-mount"></div>++${chrome.noscriptNav({ name: SITE_NAME, home: '/', links: NAV_LINKS })}++<header class="site-head"><a class="brand" href="/">${SITE_NAME}</a><nav>${NAV_LINKS.map((l) => `<a href="${l.href}"${o.active === l.label.toLowerCase() ? ' aria-current="page"' : ''}>${l.label}</a>`).join('')}</nav>${who}</header>++<main id="main" class="page">++${o.body || ''}++</main>++${chrome.footer({ service: 'tips', variant: 'full' })}++<script>++window.__OV_PAGE = ${JSON.stringify({ navbar: nav }).replace(/</g, '\\u003c')};++document.addEventListener('DOMContentLoaded', function () {++ try { if (window.OpenVibeNavbar) { OpenVibeNavbar.init(window.__OV_PAGE.navbar); document.documentElement.classList.add('ov-has-navbar'); } } catch (e) { /* the SSR header stays */ }++});++</script>++</body>++</html>`;++ }++++ return { page, abs };++}++++module.exports = { createLayout, esc, asset, assetVersion, SITE_NAME, DEFAULT_DESCRIPTION };nnew file mode 100644@@@ -0,0 +1,228 @@++'use strict';++++/**++ * Server-rendered page bodies. Plain HTML forms everywhere: a supporter can tip, and a creator can++ * run the dashboard, with JavaScript switched off. Copy rules: no pricing claims, no pleading copy —++ * the creator's own headline is the only pitch on their page.++ */++const { esc } = require('./layout');++const { VOICES } = require('../domain/profiles');++++const n = (v) => Number(v || 0).toLocaleString('en-US');++const when = (s) => (s ? esc(String(s).replace('T', ' ').slice(0, 16)) + ' UTC' : '');++const KIND_LABEL = { tip: 'Tip', paid_message: 'Paid message', tts: 'Text-to-speech', media_request: 'Media request' };++const PAY_LABEL = { pending: 'Payment pending', settled: 'Paid', reversed: 'Reversed', failed: 'Payment failed' };++const DELIVERY_LABEL = { awaiting_payment: 'Waiting for payment', queued: 'Being delivered', delivered: 'Delivered', failed: 'Delivery failed', cancelled: 'Cancelled' };++++function hidden(fields) {++ return Object.entries(fields).map(([k, v]) => `<input type="hidden" name="${esc(k)}" value="${esc(v)}">`).join('');++}++++function goalCard(g) {++ return `<article class="goal${g.reached ? ' reached' : ''}">++ <h3>${esc(g.title)}</h3>++ ${g.description ? `<p>${esc(g.description)}</p>` : ''}++ <div class="bar" role="progressbar" aria-valuemin="0" aria-valuemax="${g.target_amount}" aria-valuenow="${g.current_amount}"><span style="width:${g.percent}%"></span></div>++ <p class="meta">${n(g.current_amount)} of ${n(g.target_amount)} Vibes · ${g.percent}%${g.reached ? ' · reached' : ''}${g.status === 'closed' ? ' · closed' : ''}</p>++</article>`;++}++++function errorBox(msg) { return msg ? `<p class="notice error" role="alert">${esc(msg)}</p>` : ''; }++++function home({ creators }) {++ return `<section class="hero">++ <h1>Support the creators you watch</h1>++ <p>Tip a creator with Vibes, send a highlighted paid message, have your message read out, or request a video for their stream. Creators set goals and show every tip on stream with an overlay.</p>++ <p class="actions"><a class="btn" href="/dashboard">Open your creator dashboard</a> <a class="btn ghost" href="/receipts">Your receipts</a></p>++</section>++<section>++ <h2>How it works</h2>++ <ol class="steps">++ <li><b>Pick a creator.</b> Every creator who switched their page on has one at openvibe.tips/<i>name</i>.</li>++ <li><b>Choose what to send.</b> A tip, a paid message, text-to-speech or a media request, within the creator's own limits.</li>++ <li><b>Pay with Vibes.</b> From your Vibes balance, or through checkout. Payments are handled by OpenVibe.Billing; your receipt shows when the payment settled and when your message was delivered.</li>++ </ol>++</section>++${creators.length ? `<section><h2>Creators on OpenVibe.Tips</h2><ul class="creators">${creators.map((c) => `<li><a href="/${esc(c.handle)}">${c.avatar_url ? `<img src="${esc(c.avatar_url)}" alt="" width="40" height="40" loading="lazy">` : ''}<span>${esc(c.display_name)}</span></a></li>`).join('')}</ul></section>` : ''}`;++}++++/** The tip form: every field works as a plain POST. */++function tipForm({ profile, goals, viewer, csrf, idem, values = {}, providers, error }) {++ const kinds = [['tip', 'Tip', profile.min_amount]];++ kinds.push(['paid_message', 'Paid message (highlighted in chat)', Math.max(profile.min_amount, profile.paid_message_min)]);++ if (profile.tts_enabled) kinds.push(['tts', 'Text-to-speech (read out on stream)', Math.max(profile.min_amount, profile.tts_min_amount)]);++ if (profile.media_requests_enabled) kinds.push(['media_request', 'Media request', Math.max(profile.min_amount, profile.media_request_min)]);++ const kind = values.kind || 'tip';++ const active = goals.filter((g) => g.status === 'active');++ if (!profile.accepting) return `<section class="card"><p>${esc(profile.display_name)} is not accepting tips right now.</p></section>`;++ return `<form class="card tip-form" method="post" action="/${esc(profile.handle)}/tip">++ <h2>Send ${esc(profile.display_name)} something</h2>++ ${errorBox(error)}++ ${hidden({ csrf: csrf || '', idem })}++ <fieldset class="kinds"><legend>What to send</legend>++ ${kinds.map(([k, label, min]) => `<label><input type="radio" name="kind" value="${k}"${k === kind ? ' checked' : ''}> ${esc(label)} <small>from ${n(min)} Vibes</small></label>`).join('\n ')}++ </fieldset>++ <label>Amount (Vibes) <input type="number" name="amount" min="1" max="10000000" step="1" required value="${esc(values.amount || '')}" inputmode="numeric"></label>++ <label>Message <small>(optional for a tip, up to 300 characters)</small><textarea name="message" maxlength="300" rows="3">${esc(values.message || '')}</textarea></label>++ ${profile.tts_enabled ? `<div class="when-tts"><label>Text to read out <small>(up to ${profile.tts_max_chars} characters; the message is used when empty)</small><textarea name="tts_text" maxlength="${profile.tts_max_chars}" rows="2">${esc(values.tts_text || '')}</textarea></label>++ <label>Voice <select name="tts_voice">${VOICES.map((v) => `<option${v === (values.tts_voice || profile.tts_voice) ? ' selected' : ''}>${v}</option>`).join('')}</select></label></div>` : ''}++ ${profile.media_requests_enabled ? `<label class="when-media">Media link <small>(YouTube, up to ${Math.floor(profile.media_max_seconds / 60)} minutes)</small><input type="url" name="media_url" value="${esc(values.media_url || '')}" placeholder="https://www.youtube.com/watch?v=…"></label>` : ''}++ ${active.length ? `<label>Count toward <select name="goal_id">${active.length > 1 ? '<option value="">No goal</option>' : ''}${active.map((g) => `<option value="${esc(g.id)}"${values.goal_id === g.id ? ' selected' : ''}>${esc(g.title)}</option>`).join('')}</select></label>` : ''}++ <fieldset class="pay"><legend>Pay with</legend>++ <label><input type="radio" name="pay_with" value="credit"${values.pay_with !== 'checkout' ? ' checked' : ''}> My Vibes balance</label>++ ${providers.length ? `<label><input type="radio" name="pay_with" value="checkout"${values.pay_with === 'checkout' ? ' checked' : ''}> Checkout (${providers.map(esc).join(', ')}) <small>buys exactly these Vibes and gives them</small></label>` : ''}++ </fieldset>++ <label>Show my name as <input type="text" name="supporter_name" maxlength="80" value="${esc(values.supporter_name || (viewer ? viewer.name || viewer.username || '' : ''))}"></label>++ <button type="submit" class="btn">${viewer ? 'Send' : 'Sign in to send'}</button>++ <p class="fine">Tips are final once paid. Your receipt shows the payment and delivery status separately.</p>++</form>`;++}++++function creatorPage({ profile, goals, viewer, csrf, idem, values, providers, error, ownerPreview }) {++ const active = goals.filter((g) => g.status === 'active');++ return `${ownerPreview ? '<p class="notice">Your page is switched off: only you can see it, and search engines are told not to index it. Switch it on in the <a href="/dashboard">dashboard</a>.</p>' : ''}++<section class="creator-head">++ ${profile.avatar_url ? `<img class="avatar" src="${esc(profile.avatar_url)}" alt="" width="96" height="96">` : ''}++ <div><h1>${esc(profile.display_name)}</h1>${profile.headline ? `<p class="headline">${esc(profile.headline)}</p>` : ''}++ <p class="links"><a href="https://openvibe.live/${esc(profile.handle)}">Watch on OpenVibe.Live</a> · <a href="/${esc(profile.handle)}/goals">Goals</a></p></div>++</section>++${active.length ? `<section><h2>Goals</h2>${active.map(goalCard).join('')}</section>` : ''}++${tipForm({ profile, goals, viewer, csrf, idem, values, providers, error })}`;++}++++function goalsPage({ profile, goals }) {++ return `<h1>${esc(profile.display_name)} — goals</h1>++<p><a href="/${esc(profile.handle)}">Back to ${esc(profile.display_name)}</a></p>++${goals.length ? goals.map(goalCard).join('') : '<p>No goals yet.</p>'}++<p class="fine">Goal totals count settled payments only.</p>`;++}++++function receiptRow(i, as) {++ const who = as === 'creator' ? esc(i.supporter_name || 'Someone') : `<a href="/${esc(i.creator_handle || '')}">${esc(i.creator_name || 'creator')}</a>`;++ return `<tr${i.test ? ' class="test"' : ''}><td><a href="/receipts/${esc(i.id)}">${when(i.created_at)}</a></td><td>${who}</td><td>${esc(KIND_LABEL[i.kind])}${i.test ? ' <small>(simulation)</small>' : ''}</td><td class="num">${n(i.amount)}</td><td>${esc(PAY_LABEL[i.payment.state])}</td><td>${esc(DELIVERY_LABEL[i.delivery.state])}</td></tr>`;++}++++function receiptsPage({ rows, next }) {++ return `<h1>Your receipts</h1>++${rows.length ? `<table class="list"><thead><tr><th>When</th><th>Creator</th><th>What</th><th class="num">Vibes</th><th>Payment</th><th>Delivery</th></tr></thead><tbody>${rows.map((r) => receiptRow(r, 'supporter')).join('')}</tbody></table>` : '<p>No tips yet.</p>'}++${next ? `<p><a href="/receipts?cursor=${esc(next)}">Older</a></p>` : ''}`;++}++++function receiptPage({ i, profile, as, cancelled }) {++ const checkoutOpen = i.payment.state === 'pending' && i.checkout && i.checkout.url && as === 'supporter';++ return `<h1>Receipt</h1>++${cancelled && i.payment.state === 'pending' ? '<p class="notice">Checkout was cancelled. Nothing was charged; you can start again from the creator\'s page.</p>' : ''}++<dl class="receipt">++ <dt>Creator</dt><dd>${profile ? `<a href="/${esc(profile.handle)}">${esc(profile.display_name)}</a>` : 'unknown'}</dd>++ <dt>From</dt><dd>${esc(i.supporter_name || 'Someone')}</dd>++ <dt>What</dt><dd>${esc(KIND_LABEL[i.kind])}${i.test ? ' (simulation, not charged, not counted)' : ''}</dd>++ <dt>Amount</dt><dd>${n(i.amount)} Vibes</dd>++ ${i.message ? `<dt>Message</dt><dd>${esc(i.message)}</dd>` : ''}++ ${i.tts ? `<dt>Read out</dt><dd>${esc(i.tts.text)} <small>(${esc(i.tts.voice)})</small></dd>` : ''}++ ${i.media ? `<dt>Media</dt><dd>${esc(i.media.url)}</dd>` : ''}++ <dt>Payment</dt><dd>${esc(PAY_LABEL[i.payment.state])}${i.payment.settled_at ? ` · ${when(i.payment.settled_at)}` : ''}${i.payment.failure ? ` · ${esc(i.payment.failure)}` : ''}</dd>++ <dt>Delivery</dt><dd>${esc(DELIVERY_LABEL[i.delivery.state])}${i.delivery.delivered_at ? ` · ${when(i.delivery.delivered_at)}` : ''}</dd>++ ${i.payment.billing_txn_id ? `<dt>Billing reference</dt><dd><code>${esc(i.payment.billing_txn_id)}</code></dd>` : ''}++ <dt>Receipt id</dt><dd><code>${esc(i.id)}</code></dd>++</dl>++${checkoutOpen ? `<p><a class="btn" href="${esc(i.checkout.url)}" rel="noopener">Continue to checkout</a></p>` : ''}++${i.payment.state === 'pending' && i.funding === 'checkout' && !i.checkout?.url ? '<p class="notice">This checkout has no payment link yet. Nothing is charged until the payment completes.</p>' : ''}++<p class="fine">The payment state comes from OpenVibe.Billing. Delivery is tracked separately: a delivery problem never undoes a payment.</p>`;++}++++function dashboard({ profile, goals, tokens, configs, totals, recent, deliveries, csrf, idem, flash, error, connected, chatAdapter }) {++ const p = profile;++ const f = (k) => (p[k] ? ' checked' : '');++ const alertCfg = configs.find((c) => c.kind === 'alerts');++ const s = alertCfg ? alertCfg.settings : {};++ return `<h1>Creator dashboard</h1>++${flash ? `<p class="notice">${esc(flash)}</p>` : ''}${errorBox(error)}++<section class="grid">++ <div class="card"><h2>Totals</h2>++ <p class="big">${n(totals.settled_via_billing)} <small>Vibes settled through Billing</small></p>++ <p>${n(totals.external)} Vibes-equivalent tipped on your own PowerChat · ${n(totals.interactions)} tips · ${n(totals.pending)} pending</p>++ <p class="fine">Simulations (${n(totals.simulations)}) are never counted. Your balance and cash-outs are on OpenVibe.Live until Billing takes over.</p>++ </div>++ <div class="card"><h2>Your page</h2>++ <p><a href="/${esc(p.handle)}">openvibe.tips/${esc(p.handle)}</a> — ${p.page_enabled ? 'on and indexable' : 'off (only you can see it)'}</p>++ <form method="post" action="/dashboard/profile">${hidden({ csrf, idem: `${idem}-p` })}++ <label><input type="checkbox" name="page_enabled" value="1"${f('page_enabled')}> Public page on (search engines may index it)</label>++ <label><input type="checkbox" name="accepting" value="1"${f('accepting')}> Accepting tips</label>++ <label>Headline <input type="text" name="headline" maxlength="280" value="${esc(p.headline || '')}"></label>++ <label>Minimum tip <input type="number" name="min_amount" min="1" value="${p.min_amount}"></label>++ <label>Minimum paid message <input type="number" name="paid_message_min" min="1" value="${p.paid_message_min}"></label>++ <label><input type="checkbox" name="tts_enabled" value="1"${f('tts_enabled')}> Text-to-speech</label>++ <label>Minimum for text-to-speech <input type="number" name="tts_min_amount" min="1" value="${p.tts_min_amount}"></label>++ <label>Text-to-speech length <input type="number" name="tts_max_chars" min="20" max="1200" value="${p.tts_max_chars}"></label>++ <label>Default voice <select name="tts_voice">${VOICES.map((v) => `<option${v === p.tts_voice ? ' selected' : ''}>${v}</option>`).join('')}</select></label>++ <label><input type="checkbox" name="media_requests_enabled" value="1"${f('media_requests_enabled')}> Media requests</label>++ <label>Minimum media request <input type="number" name="media_request_min" min="1" value="${p.media_request_min}"></label>++ <label>Longest media (seconds) <input type="number" name="media_max_seconds" min="10" max="10800" value="${p.media_max_seconds}"></label>++ <input type="hidden" name="revision" value="${p.revision}">++ <button class="btn" type="submit">Save</button>++ </form>++ </div>++</section>++<section class="card"><h2>Goals</h2>++ ${goals.length ? goals.map((g) => `${goalCard(g)}${g.status === 'active' ? `<form class="inline" method="post" action="/dashboard/goals/${esc(g.id)}">${hidden({ csrf, idem: `${idem}-g-${g.id}`, revision: g.revision })}++ <label>Title <input name="title" maxlength="120" value="${esc(g.title)}"></label><label>Target <input type="number" name="target_amount" min="1" value="${g.target_amount}"></label>++ <button class="btn ghost" type="submit">Update</button></form>++ <form class="inline" method="post" action="/dashboard/goals/${esc(g.id)}/close">${hidden({ csrf, idem: `${idem}-c-${g.id}` })}<button class="btn ghost" type="submit">Close goal</button></form>` : ''}`).join('') : '<p>No goals yet.</p>'}++ <form method="post" action="/dashboard/goals">${hidden({ csrf, idem: `${idem}-ng` })}++ <h3>New goal</h3>++ <label>Title <input name="title" maxlength="120" required></label>++ <label>Target (Vibes) <input type="number" name="target_amount" min="1" required></label>++ <label>Description <input name="description" maxlength="1000"></label>++ <button class="btn" type="submit">Add goal</button>++ </form>++</section>++<section class="card"><h2>Overlays</h2>++ <p>Add an overlay to OBS as a Browser Source. Each overlay link carries its own token: it shows alerts and goals, nothing else, and you can revoke it at any time. ${connected ? `<b>${connected}</b> overlay${connected === 1 ? '' : 's'} connected now.` : 'No overlay is connected right now.'}</p>++ ${tokens.length ? `<table class="list"><thead><tr><th>Label</th><th>Shows</th><th>Created</th><th>Last used</th><th></th></tr></thead><tbody>${tokens.map((t) => `<tr${t.active ? '' : ' class="revoked"'}><td>${esc(t.label || '—')}</td><td>${esc(t.scopes.join(', '))}</td><td>${when(t.created_at)}</td><td>${when(t.last_used_at) || 'never'}</td><td>${t.active ? `<form method="post" action="/dashboard/overlay-tokens/${esc(t.id)}/revoke">${hidden({ csrf, idem: `${idem}-r-${t.id}` })}<button class="btn ghost" type="submit">Revoke</button></form>` : 'revoked'}</td></tr>`).join('')}</tbody></table>` : ''}++ <form method="post" action="/dashboard/overlay-tokens">${hidden({ csrf, idem: `${idem}-t` })}++ <label>Label <input name="label" maxlength="80" placeholder="OBS main scene"></label>++ <label><input type="checkbox" name="scope_alerts" value="1" checked> Alerts</label>++ <label><input type="checkbox" name="scope_goals" value="1" checked> Goals</label>++ <button class="btn" type="submit">Create overlay link</button>++ </form>++ ${alertCfg ? `<form method="post" action="/dashboard/overlay-configs/${esc(alertCfg.id)}">${hidden({ csrf, idem: `${idem}-a`, revision: alertCfg.revision })}++ <h3>Alert settings</h3>++ <label>Show alerts from <input type="number" name="min_amount" min="1" value="${s.min_amount}"> Vibes</label>++ <label>Alert time (ms) <input type="number" name="duration_ms" min="1000" max="60000" value="${s.duration_ms}"></label>++ <label><input type="checkbox" name="show_message" value="1"${s.show_message ? ' checked' : ''}> Show the message</label>++ <label><input type="checkbox" name="speak_message" value="1"${s.speak_message ? ' checked' : ''}> Read text-to-speech requests in the overlay (browser voice)</label>++ <label>Sound (https link) <input type="url" name="sound_url" value="${esc(s.sound_url || '')}"></label>++ <label>Image (https link) <input type="url" name="image_url" value="${esc(s.image_url || '')}"></label>++ <input type="hidden" name="show_amount" value="1">++ <button class="btn ghost" type="submit">Save alert settings</button>++ </form>` : ''}++</section>++<section class="card"><h2>Test your setup</h2>++ <p>Runs the whole path — overlay alert, goal widget, delivery — marked as a simulation. Nothing is charged and nothing is counted. ${chatAdapter === 'none' ? '' : 'Chat delivery of simulations stays inside Tips.'}</p>++ <form method="post" action="/dashboard/simulate">${hidden({ csrf, idem: `${idem}-s` })}++ <label>Kind <select name="kind"><option value="tip">Tip</option><option value="paid_message">Paid message</option>${p.tts_enabled ? '<option value="tts">Text-to-speech</option>' : ''}${p.media_requests_enabled ? '<option value="media_request">Media request</option>' : ''}</select></label>++ <label>Amount <input type="number" name="amount" min="1" value="${Math.max(p.min_amount, p.paid_message_min, 100)}"></label>++ <label>Name <input name="supporter_name" value="Test supporter" maxlength="80"></label>++ <label>Message <input name="message" value="This is a test alert" maxlength="300"></label>++ <label class="when-media">Media link (for a media request) <input type="url" name="media_url" placeholder="https://www.youtube.com/watch?v=…"></label>++ <button class="btn" type="submit">Send a test</button>++ </form>++</section>++<section class="card"><h2>Recent</h2>++ ${recent.length ? `<table class="list"><thead><tr><th>When</th><th>From</th><th>What</th><th class="num">Vibes</th><th>Payment</th><th>Delivery</th></tr></thead><tbody>${recent.map((r) => receiptRow(r, 'creator')).join('')}</tbody></table>` : '<p>Nothing yet.</p>'}++ ${deliveries.length ? `<details><summary>Overlay deliveries</summary><table class="list"><thead><tr><th>#</th><th>Kind</th><th>Status</th><th>Sent</th><th>Created</th></tr></thead><tbody>${deliveries.map((d) => `<tr${d.test ? ' class="test"' : ''}><td>${d.seq}</td><td>${esc(d.kind)}${d.test ? ' (test)' : ''}</td><td>${esc(d.status)}</td><td>${d.sends}×</td><td>${when(d.created_at)}</td></tr>`).join('')}</tbody></table></details>` : ''}++</section>`;++}++++function tokenCreated({ out }) {++ return `<h1>Overlay link created</h1>++<p class="notice">Copy it now: this is the only time it is shown. Anyone with the link can see your alerts and goals (nothing else), so treat it like a password and revoke it if it leaks.</p>++<p><label>Overlay URL (OBS Browser Source, 800 × 600, transparent)<input class="copy" type="text" readonly value="${esc(out.overlay_url)}" onclick="this.select()"></label></p>++<p><a class="btn" href="/dashboard">Back to the dashboard</a></p>`;++}++++function errorPage({ status, title, message }) {++ return `<section class="card"><h1>${esc(title)}</h1><p>${esc(message)}</p><p><a href="/">OpenVibe.Tips home</a></p></section><!-- ${status} -->`;++}++++module.exports = { home, creatorPage, goalsPage, receiptsPage, receiptPage, dashboard, tokenCreated, errorPage, tipForm };nnew file mode 100644@@@ -0,0 +1,286 @@++'use strict';++++/**++ * Pages and overlay endpoints.++ *++ * GET / home++ * GET /dashboard creator dashboard (sign-in; opens the profile on first visit)++ * POST /dashboard/… profile, goals, overlay tokens/configs, simulation (anti-forgery token)++ * GET /receipts, /receipts/:id the signed-in supporter's receipts (a creator may open theirs too)++ * GET /:handle creator page: goals + tip form (indexable only when the page is on)++ * GET /:handle/goals goals++ * POST /:handle/tip the no-JS tip form → Billing (credit or checkout)++ * GET /overlay/:token the overlay page for OBS (token = scoped, revocable, never a cookie)++ * GET /overlay/:token/events SSE: alerts and goal updates (Last-Event-ID resumes/replays)++ * GET /overlay/:token/state JSON: active goals and recent alerts (read-only, for other overlay clients)++ * GET /robots.txt, /sitemap.xml++ */++const crypto = require('crypto');++const express = require('express');++const { TipsError, json: parseJson } = require('../util');++const { viewerMiddleware } = require('./session');++const pages = require('./pages');++const { esc, asset } = require('./layout');++++const FLASH = new Set(['Saved', 'Page settings saved', 'Goal added', 'Goal updated', 'Goal closed', 'Overlay link revoked', 'Alert settings saved', 'Test sent — check your overlay']);++++function createWebRoutes({ domain, config, layout, userAuth }) {++ const r = express.Router();++ const { profiles, goals, interactions, overlays, db } = domain;++ const withViewer = viewerMiddleware(userAuth);++ const form = express.urlencoded({ extended: false, limit: '32kb' });++ const secret = config.formSecret || crypto.randomBytes(32).toString('hex');++++ const html = (res, body, status = 200, extraHeaders = {}) => res.status(status).set({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache', ...extraHeaders }).send(body);++ const pageFor = (req, o) => layout.page({ viewer: req.viewer, ...o });++ const notFound = (req, res) => html(res, pageFor(req, { title: 'Not found', robots: 'noindex', body: pages.errorPage({ status: 404, title: 'Nothing here', message: 'That page does not exist, or its creator has not switched it on.' }) }), 404);++++ // Anti-forgery for signed-in forms: HMAC(subject, day), valid today and yesterday.++ const day = () => Math.floor(domain.now() / 86_400_000);++ const csrfFor = (subject, d = day()) => crypto.createHmac('sha256', secret).update(`${subject}|${d}`).digest('base64url');++ function csrfOk(req) {++ if (!req.viewer) return false;++ const origin = req.get('origin');++ if (origin && origin !== new URL(config.baseUrl).origin) return false;++ const given = String((req.body && req.body.csrf) || '');++ return [day(), day() - 1].some((d) => {++ const want = csrfFor(req.viewer.subject, d);++ return given.length === want.length && crypto.timingSafeEqual(Buffer.from(given), Buffer.from(want));++ });++ }++ const idem = () => `form-${crypto.randomBytes(12).toString('base64url')}`;++++ // ── Home, robots, sitemap ────────────────────────────────++ r.get('/', withViewer, (req, res) => {++ const creators = db.prepare(`SELECT handle, display_name, avatar_url FROM creator_tip_profiles WHERE page_enabled = 1 ORDER BY updated_at DESC LIMIT 24`).all();++ html(res, pageFor(req, { active: 'home', canonicalPath: '/', body: pages.home({ creators }) }));++ });++ r.get('/robots.txt', (req, res) => res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(++ `User-agent: *\nAllow: /\nDisallow: /dashboard\nDisallow: /receipts\nDisallow: /overlay/\nDisallow: /auth/\nDisallow: /api/\nDisallow: /internal/\nSitemap: ${config.baseUrl}/sitemap.xml\n`));++ r.get('/sitemap.xml', (req, res) => {++ const rows = db.prepare('SELECT handle, updated_at FROM creator_tip_profiles WHERE page_enabled = 1 ORDER BY handle').all();++ const urls = [`<url><loc>${esc(config.baseUrl)}/</loc></url>`, ...rows.map((p) => `<url><loc>${esc(`${config.baseUrl}/${p.handle}`)}</loc><lastmod>${esc(p.updated_at.slice(0, 10))}</lastmod></url>`)];++ res.type('application/xml').set('Cache-Control', 'public, max-age=900').send(`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls.join('')}</urlset>\n`);++ });++++ // ── Receipts ─────────────────────────────────────────────++ const decorate = (i) => {++ const p = profiles.bySubject(i.creator_subject);++ return { ...interactions.present(i, { viewer: 'supporter' }), creator_handle: p && p.handle, creator_name: p && p.display_name };++ };++ r.get('/receipts', withViewer, (req, res) => {++ if (!req.viewer) return res.redirect(`/auth/login?next=${encodeURIComponent('/receipts')}`);++ const out = interactions.list({ supporter: req.viewer.subject, cursor: req.query.cursor, limit: 50 });++ html(res, pageFor(req, { title: 'Receipts', active: 'receipts', robots: 'noindex,nofollow', canonicalPath: '/receipts', body: pages.receiptsPage({ rows: out.rows.map(decorate), next: out.next_cursor }) }));++ });++ r.get('/receipts/:id', withViewer, (req, res) => {++ if (!req.viewer) return res.redirect(`/auth/login?next=${encodeURIComponent(req.originalUrl)}`);++ const i = interactions.get(req.params.id);++ const as = i && (i.supporter_subject === req.viewer.subject ? 'supporter' : i.creator_subject === req.viewer.subject ? 'creator' : null);++ if (!i || !as) return notFound(req, res);++ const view = interactions.present(i, { viewer: as === 'creator' ? 'owner' : 'supporter' });++ html(res, pageFor(req, { title: 'Receipt', active: 'receipts', robots: 'noindex,nofollow', canonicalPath: `/receipts/${i.id}`, body: pages.receiptPage({ i: view, profile: profiles.bySubject(i.creator_subject), as, cancelled: req.query.cancelled === '1' }) }));++ });++++ // ── Dashboard ────────────────────────────────────────────++ function ensureProfile(req) {++ const v = req.viewer;++ const claims = req.viewerClaims || {};++ return profiles.ensure(v.subject, { username: v.username, displayName: v.name, avatarUrl: claims.avatar_url });++ }++ function ensureAlertsConfig(subject) {++ if (!overlays.listConfigs(subject).some((c) => c.kind === 'alerts')) overlays.createConfig(subject, { kind: 'alerts', name: 'Alerts' });++ }++ function renderDashboard(req, res, { flash, error, status = 200 } = {}) {++ const p = ensureProfile(req);++ ensureAlertsConfig(p.creator_subject);++ const recent = interactions.list({ creator: p.creator_subject, limit: 20 }).rows.map((i) => interactions.present(i, { viewer: 'owner' }));++ html(res, pageFor(req, {++ title: 'Dashboard', active: 'dashboard', robots: 'noindex,nofollow', canonicalPath: '/dashboard',++ body: pages.dashboard({++ profile: p, goals: goals.list(p.creator_subject).map((g) => goals.present(g)), tokens: overlays.listTokens(p.creator_subject).map(overlays.presentToken),++ configs: overlays.listConfigs(p.creator_subject).map(overlays.presentConfig), totals: interactions.totals(p.creator_subject), recent,++ deliveries: overlays.recentDeliveries(p.creator_subject, 20), csrf: csrfFor(req.viewer.subject), idem: idem(), flash, error,++ connected: overlays.connected(p.creator_subject), chatAdapter: config.chat.adapter,++ }),++ }), status);++ }++ r.get('/dashboard', withViewer, (req, res) => {++ if (!req.viewer) return res.redirect(`/auth/login?next=${encodeURIComponent('/dashboard')}`);++ // Only our own confirmations are shown: a crafted ?done= link cannot put words on the page.++ renderDashboard(req, res, { flash: FLASH.has(String(req.query.done || '')) ? String(req.query.done) : null });++ });++++ /** A dashboard form action: sign-in + anti-forgery, errors re-render the dashboard. */++ function action(path, fn) {++ r.post(path, withViewer, form, (req, res) => {++ if (!req.viewer) return res.redirect(`/auth/login?next=${encodeURIComponent('/dashboard')}`);++ if (!csrfOk(req)) return renderDashboard(req, res, { error: 'That form expired. Please try again.', status: 403 });++ try {++ const p = ensureProfile(req);++ const out = fn(req, p);++ if (out && out.render) return out.render(res);++ return res.redirect(303, `/dashboard?done=${encodeURIComponent((out && out.done) || 'Saved')}`);++ } catch (e) {++ if (e instanceof TipsError) return renderDashboard(req, res, { error: e.detail || e.message, status: e.status >= 500 ? 500 : 400 });++ throw e;++ }++ });++ }++ const bool = (v) => v === '1' || v === 'on';++ action('/dashboard/profile', (req, p) => {++ const b = req.body;++ profiles.update(p.creator_subject, {++ page_enabled: bool(b.page_enabled), accepting: bool(b.accepting), tts_enabled: bool(b.tts_enabled), media_requests_enabled: bool(b.media_requests_enabled),++ headline: b.headline, min_amount: b.min_amount, paid_message_min: b.paid_message_min, tts_min_amount: b.tts_min_amount, tts_max_chars: b.tts_max_chars,++ tts_voice: b.tts_voice, media_request_min: b.media_request_min, media_max_seconds: b.media_max_seconds,++ }, { expectedRevision: b.revision });++ return { done: 'Page settings saved' };++ });++ action('/dashboard/goals', (req, p) => { goals.create(p.creator_subject, req.body); return { done: 'Goal added' }; });++ const ownGoal = (req, p) => {++ const g = goals.get(req.params.id);++ if (!g || g.creator_subject !== p.creator_subject) throw new TipsError(404, 'tips.goal_not_found', 'no such goal');++ return g;++ };++ action('/dashboard/goals/:id', (req, p) => { goals.update(ownGoal(req, p), { title: req.body.title, target_amount: req.body.target_amount, revision: req.body.revision }); return { done: 'Goal updated' }; });++ action('/dashboard/goals/:id/close', (req, p) => { goals.close(ownGoal(req, p)); return { done: 'Goal closed' }; });++ action('/dashboard/overlay-tokens', (req, p) => {++ const scopes = [bool(req.body.scope_alerts) && 'alerts', bool(req.body.scope_goals) && 'goals'].filter(Boolean);++ const alertCfg = overlays.listConfigs(p.creator_subject).find((c) => c.kind === 'alerts');++ const out = overlays.createToken(p.creator_subject, { scopes, label: req.body.label, configId: alertCfg ? alertCfg.id : null, createdBy: req.viewer.subject });++ return { render: (res) => html(res, pageFor(req, { title: 'Overlay link', active: 'dashboard', robots: 'noindex,nofollow', canonicalPath: '/dashboard', body: pages.tokenCreated({ out }) }), 201, { 'Cache-Control': 'no-store' }) };++ });++ action('/dashboard/overlay-tokens/:id/revoke', (req, p) => {++ const t = overlays.getToken(req.params.id);++ if (!t || t.creator_subject !== p.creator_subject) throw new TipsError(404, 'tips.overlay_token_not_found', 'no such overlay token');++ overlays.revokeToken(t);++ return { done: 'Overlay link revoked' };++ });++ action('/dashboard/overlay-configs/:id', (req, p) => {++ const c = overlays.getConfig(req.params.id);++ if (!c || c.creator_subject !== p.creator_subject) throw new TipsError(404, 'tips.overlay_config_not_found', 'no such overlay config');++ const b = req.body;++ overlays.updateConfig(c, { ...b, show_message: bool(b.show_message), speak_message: bool(b.speak_message), show_amount: bool(b.show_amount) });++ return { done: 'Alert settings saved' };++ });++ action('/dashboard/simulate', (req, p) => {++ const b = req.body;++ interactions.simulate(p, {++ kind: b.kind, amount: b.amount, message: b.message, supporter_name: b.supporter_name,++ tts: b.kind === 'tts' ? { text: b.message } : undefined, media: b.kind === 'media_request' ? { url: b.media_url } : undefined,++ }, { by: req.viewer.subject });++ return { done: 'Test sent — check your overlay' };++ });++++ // ── Overlays (token in the path; no cookies involved) ────++ const overlayHeaders = { 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer', 'X-Robots-Tag': 'noindex, nofollow' };++ function tokenOr404(req, res) {++ const t = overlays.authenticate(req.params.token);++ if (!t) { res.status(404).set(overlayHeaders).type('text/plain').send('This overlay link is not valid (it may have been revoked).'); return null; }++ return t;++ }++ r.get('/overlay/:token', (req, res) => {++ const t = tokenOr404(req, res);++ if (!t) return;++ res.status(200).set({ ...overlayHeaders, 'Content-Type': 'text/html; charset=utf-8' }).send(`<!DOCTYPE html>++<html lang="en"><head><meta charset="utf-8"><meta name="robots" content="noindex,nofollow"><meta name="referrer" content="no-referrer">++<meta name="viewport" content="width=device-width, initial-scale=1"><title>OpenVibe.Tips overlay</title>++<link rel="stylesheet" href="${asset('css/overlay.css')}"></head>++<body class="overlay"><div id="goals" aria-live="polite"></div><div id="alert" aria-live="assertive"></div>++<noscript><p class="nojs">This overlay needs JavaScript (OBS Browser Sources have it on).</p></noscript>++<script src="${asset('js/overlay.js')}" defer></script></body></html>`);++ });++ r.get('/overlay/:token/events', (req, res) => {++ const t = tokenOr404(req, res);++ if (!t) return;++ res.status(200).set({ ...overlayHeaders, 'Content-Type': 'text/event-stream; charset=utf-8', Connection: 'keep-alive', 'X-Accel-Buffering': 'no' });++ res.flushHeaders();++ res.write('retry: 3000\n\n');++ const config = t.config_id ? overlays.getConfig(t.config_id) : null;++ const lastEventId = req.get('last-event-id') != null ? req.get('last-event-id') : req.query.last_event_id;++ overlays.attach(t, res, { lastEventId: lastEventId != null && lastEventId !== '' ? lastEventId : undefined, config });++ });++ r.get('/overlay/:token/state', (req, res) => {++ const t = tokenOr404(req, res);++ if (!t) return;++ const out = { creator: { type: 'user', id: t.creator_subject }, scopes: t.scopes };++ if (t.scopes.includes('goals')) out.goals = goals.list(t.creator_subject, { status: 'active' }).map((g) => goals.present(g));++ if (t.scopes.includes('alerts')) {++ out.alerts = db.prepare("SELECT seq, payload, test, created_at FROM overlay_deliveries WHERE creator_subject = ? AND kind = 'alert' ORDER BY seq DESC LIMIT 20")++ .all(t.creator_subject).map((d) => ({ seq: d.seq, ...parseJson(d.payload, {}), test: !!d.test, created_at: d.created_at }));++ }++ res.set(overlayHeaders).json(out);++ });++++ // ── Creator pages (last: /:handle catches everything else) ──++ const providers = config.billing.providers;++ function creatorView(req, res, { values, error, status = 200 } = {}) {++ const p = profiles.byHandle(req.params.handle);++ const owner = !!(p && req.viewer && req.viewer.subject === p.creator_subject);++ if (!p || (!p.page_enabled && !owner)) return notFound(req, res);++ if (p.handle !== String(req.params.handle)) return res.redirect(301, `/${p.handle}`); // @name, Name → the canonical path++ const list = goals.list(p.creator_subject).map((g) => goals.present(g));++ const csrf = req.viewer ? csrfFor(req.viewer.subject) : '';++ const jsonLd = p.page_enabled ? [{ '@context': 'https://schema.org', '@type': 'ProfilePage', name: `${p.display_name} on ${'OpenVibe.Tips'}`, url: `${config.baseUrl}/${p.handle}`, mainEntity: { '@type': 'Person', name: p.display_name, alternateName: p.handle, image: p.avatar_url || undefined } }] : [];++ return html(res, pageFor(req, {++ title: `Support ${p.display_name}`, active: 'creator', canonicalPath: `/${p.handle}`,++ description: p.headline || `Tip ${p.display_name} on OpenVibe.Tips: tips, goals, paid messages${p.tts_enabled ? ', text-to-speech' : ''}${p.media_requests_enabled ? ', media requests' : ''}.`,++ robots: p.page_enabled ? 'index,follow' : 'noindex,nofollow', ogImage: p.avatar_url || undefined, jsonLd,++ body: pages.creatorPage({ profile: p, goals: list, viewer: req.viewer, csrf, idem: idem(), values, providers, error, ownerPreview: !p.page_enabled }),++ }), status, p.page_enabled ? {} : { 'X-Robots-Tag': 'noindex, nofollow' });++ }++ r.get('/:handle', withViewer, (req, res, next) => (profiles.normalizeHandle(req.params.handle) ? creatorView(req, res) : next()));++ r.get('/:handle/goals', withViewer, (req, res, next) => {++ const p = profiles.byHandle(req.params.handle);++ if (!p) return next();++ const owner = req.viewer && req.viewer.subject === p.creator_subject;++ if (!p.page_enabled && !owner) return notFound(req, res);++ html(res, pageFor(req, {++ title: `${p.display_name} — goals`, canonicalPath: `/${p.handle}/goals`, robots: p.page_enabled ? 'index,follow' : 'noindex,nofollow',++ body: pages.goalsPage({ profile: p, goals: goals.list(p.creator_subject).map((g) => goals.present(g)) }),++ }));++ });++++ r.post('/:handle/tip', withViewer, form, async (req, res, next) => {++ const p = profiles.byHandle(req.params.handle);++ if (!p) return next();++ if (!req.viewer) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/${p.handle}`)}`);++ const b = req.body || {};++ const values = { kind: b.kind, amount: b.amount, message: b.message, tts_text: b.tts_text, tts_voice: b.tts_voice, media_url: b.media_url, goal_id: b.goal_id, pay_with: b.pay_with, supporter_name: b.supporter_name };++ if (!csrfOk(req)) return creatorView(req, res, { values, error: 'That form expired. Please send it again.', status: 403 });++ const owner = req.viewer.subject === p.creator_subject;++ if (!p.page_enabled && !owner) return notFound(req, res);++ const key = /^form-[A-Za-z0-9_-]{8,40}$/.test(String(b.idem || '')) ? `form:${req.viewer.subject}:${b.idem}` : null;++ try {++ const input = {++ kind: b.kind || 'tip', amount: b.amount, message: b.message, goal_id: b.goal_id || undefined,++ tts: b.kind === 'tts' ? { text: b.tts_text || b.message, voice: b.tts_voice } : undefined,++ media: b.kind === 'media_request' ? { url: b.media_url } : undefined,++ };++ const funding = b.pay_with === 'checkout' ? 'checkout' : 'credit';++ const { interaction, replay } = interactions.request(p, input, { supporter: req.viewer.subject, supporterName: b.supporter_name || req.viewer.name, funding, idempotencyKey: key });++ let i = interaction;++ if (!replay) {++ if (funding === 'credit') {++ const out = await interactions.transfer(i);++ if (out.refused) {++ const msg = out.refused === 'billing.insufficient_funds' ? 'Your Vibes balance is too low for this. Pay by checkout instead, or top up on OpenVibe.Live.' : `Billing refused this tip (${out.refused}).`;++ return creatorView(req, res, { values, error: msg, status: 409 });++ }++ } else {++ i = (await interactions.startCheckout(i, { provider: b.provider })).interaction;++ if (i.checkout_url) return res.redirect(303, i.checkout_url);++ }++ }++ return res.redirect(303, `/receipts/${i.id}`);++ } catch (e) {++ if (e instanceof TipsError) return creatorView(req, res, { values, error: e.detail || e.message, status: e.status >= 500 ? 502 : 400 });++ return next(e);++ }++ });++++ return r;++}++++module.exports = { createWebRoutes };nnew file mode 100644@@@ -0,0 +1,213 @@++'use strict';++++/**++ * openvibe.tips — OAuth2 client session layer (identity provider: OpenVibe.Network), the same++ * shape as OpenVibe.Community's server/auth/routes.js. Registered in the Network as client `tips`++ * with redirect https://openvibe.tips/auth/callback.++ *++ * GET /auth/login → Network /oauth/authorize (?silent=1 → prompt=none; ?next= same-site path)++ * GET /auth/callback → server-side code exchange, session cookies++ * POST /auth/fedcm → the shared navbar's FedCM assertion → tokens (jwt-bearer grant)++ * GET /auth/logout → clear cookies (best-effort refresh revoke), hint=guest++ * GET /auth/me → the signed-in user (offline JWT verification)++ * POST /auth/refresh → rotate tokens++ *++ * Cookies (host-only): ov_token (access JWT, Lax, JS-readable for the navbar), ov_refresh (httpOnly,++ * Path=/auth), ov_sso_hint. These cookies only ever sign a person into the pages of this site;++ * overlays never use them (overlays hold their own revocable tokens).++ */++const express = require('express');++const crypto = require('crypto');++const { userPrincipal } = require('../api/auth');++++const ACCESS_COOKIE = 'ov_token';++const REFRESH_COOKIE = 'ov_refresh';++const HINT_COOKIE = 'ov_sso_hint';++const STATE_COOKIE = 'ov_oauth_state';++const NEXT_COOKIE = 'ov_oauth_next';++const SILENT_COOKIE = 'ov_oauth_silent';++++function claimsToUser(claims) {++ if (!claims) return null;++ const { iat, exp, aud, iss, nbf, jti, ...user } = claims; // eslint-disable-line no-unused-vars++ return user;++}++++function decodeJwtPayload(token) {++ const parts = String(token || '').split('.');++ if (parts.length !== 3) return null;++ try { const c = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); return c && typeof c === 'object' ? c : null; } catch { return null; }++}++++function sanitizeNext(next, config) {++ if (!next || typeof next !== 'string') return '/';++ if (/^\/(?!\/|\\)/.test(next)) return next;++ try {++ const u = new URL(next);++ if (u.protocol !== 'https:') return '/';++ const allowed = [config.baseUrl, config.network.url].map((b) => { try { return new URL(b).hostname; } catch { return null; } }).filter(Boolean);++ if (allowed.includes(u.hostname)) return u.toString();++ } catch { /* fall through */ }++ return '/';++}++++function withParam(target, key, value) {++ const hashAt = target.indexOf('#');++ const hash = hashAt >= 0 ? target.slice(hashAt) : '';++ const base = hashAt >= 0 ? target.slice(0, hashAt) : target;++ return `${base}${base.includes('?') ? '&' : '?'}${encodeURIComponent(key)}=${encodeURIComponent(value)}${hash}`;++}++++/** Page middleware: req.viewer = the signed-in person (principal shape) or null. Never blocks. */++function viewerMiddleware(userAuth) {++ return (req, _res, next) => {++ const token = req.cookies && req.cookies[ACCESS_COOKIE];++ const claims = token ? userAuth.verify(token) : null;++ req.viewer = claims ? userPrincipal(claims) : null;++ req.viewerClaims = claims ? claimsToUser(claims) : null;++ next();++ };++}++++function createSessionRoutes(config, userAuth, { fetchImpl = globalThis.fetch } = {}) {++ const router = express.Router();++ const secure = config.cookies.secure;++ const accessOpts = () => ({ sameSite: 'lax', secure, httpOnly: false, path: '/', maxAge: 24 * 3600 * 1000 });++ const refreshOpts = () => ({ sameSite: 'lax', secure, httpOnly: true, path: '/auth', maxAge: 30 * 24 * 3600 * 1000 });++ const flowOpts = () => ({ sameSite: 'lax', secure, httpOnly: true, path: '/auth', maxAge: 10 * 60 * 1000 });++ const hintOpts = () => ({ sameSite: 'lax', secure, httpOnly: false, path: '/', maxAge: 365 * 24 * 3600 * 1000 });++++ function setSession(res, access, refresh) {++ res.cookie(ACCESS_COOKIE, access, accessOpts());++ if (refresh) res.cookie(REFRESH_COOKIE, refresh, refreshOpts());++ res.cookie(HINT_COOKIE, 'account', hintOpts());++ }++ function clearSession(res) {++ res.clearCookie(ACCESS_COOKIE, { ...accessOpts(), maxAge: undefined });++ res.clearCookie(REFRESH_COOKIE, { ...refreshOpts(), maxAge: undefined });++ }++ function clearFlow(res) { for (const c of [STATE_COOKIE, NEXT_COOKIE, SILENT_COOKIE]) res.clearCookie(c, { path: '/auth' }); }++++ async function tokenGrant(body) {++ let lastErr = null;++ for (const base of [config.network.internalUrl, config.network.url]) {++ if (!base) continue;++ try {++ const res = await fetchImpl(`${base}/oauth/token`, {++ method: 'POST', headers: { 'Content-Type': 'application/json' },++ body: JSON.stringify({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, ...body }),++ signal: AbortSignal.timeout(10_000),++ });++ const data = await res.json().catch(() => ({}));++ if (!res.ok) {++ const err = new Error(data.error_description || data.error || `token grant failed (${res.status})`);++ err.status = res.status; err.error = data.error || 'invalid_grant';++ throw err;++ }++ return data;++ } catch (err) {++ lastErr = err;++ if (err.status && err.status < 500) throw err;++ }++ }++ throw lastErr || new Error('Network unreachable');++ }++++ router.get('/login', (req, res) => {++ const silent = !!req.query.silent && req.query.silent !== '0';++ if (silent && userAuth.verify(req.cookies && req.cookies[ACCESS_COOKIE])) { clearFlow(res); return res.redirect(sanitizeNext(req.query.next, config)); }++ const { url, state } = userAuth.client.getAuthorizationUrl(config.oauth.scope);++ let target = url;++ if (silent) { const u = new URL(url); u.searchParams.set('prompt', 'none'); target = u.toString(); }++ res.cookie(STATE_COOKIE, state, flowOpts());++ const next = sanitizeNext(req.query.next, config);++ if (next !== '/') res.cookie(NEXT_COOKIE, next, flowOpts()); else res.clearCookie(NEXT_COOKIE, { path: '/auth' });++ if (silent) res.cookie(SILENT_COOKIE, '1', flowOpts()); else res.clearCookie(SILENT_COOKIE, { path: '/auth' });++ res.redirect(target);++ });++++ router.get('/callback', async (req, res) => {++ const { code, state, error } = req.query;++ const next = sanitizeNext(req.cookies && req.cookies[NEXT_COOKIE], config);++ const silent = req.cookies && req.cookies[SILENT_COOKIE] === '1';++ if (error) {++ clearFlow(res);++ if (silent || error === 'login_required') return res.redirect(withParam(next, 'sso', 'none'));++ return res.redirect(withParam('/', 'auth_error', String(error)));++ }++ if (!code) return res.status(400).type('text/plain').send('Missing authorization code');++ const expected = req.cookies && req.cookies[STATE_COOKIE];++ clearFlow(res);++ if (!expected || !state || !crypto.timingSafeEqual(Buffer.from(String(state).padEnd(64).slice(0, 64)), Buffer.from(String(expected).padEnd(64).slice(0, 64)))) {++ return res.status(400).type('text/plain').send('Sign-in state mismatch — please try signing in again.');++ }++ try {++ const data = await tokenGrant({ grant_type: 'authorization_code', redirect_uri: config.oauth.redirectUri, code });++ setSession(res, data.access_token, data.refresh_token);++ return res.redirect(next);++ } catch (err) {++ console.error('[Tips] code exchange failed:', err.message);++ return res.status(502).type('text/plain').send('Sign-in failed — could not reach OpenVibe.Network. Please try again.');++ }++ });++++ const fedcmBody = express.json({ limit: '16kb' });++ router.post('/fedcm', fedcmBody, async (req, res) => {++ const { token, nonce } = req.body || {};++ if (typeof token !== 'string' || !token || typeof nonce !== 'string' || !nonce || nonce.length > 256) {++ return res.status(400).json({ error: 'invalid_request', error_description: 'token and nonce are required' });++ }++ const claims = decodeJwtPayload(token);++ const a = Buffer.from(String((claims && claims.nonce) || '')); const b = Buffer.from(nonce);++ if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.status(400).json({ error: 'invalid_request', error_description: 'nonce mismatch' });++ try {++ const data = await tokenGrant({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: token });++ if (!data.access_token) throw Object.assign(new Error('no token'), { status: 401, error: 'invalid_grant' });++ setSession(res, data.access_token, data.refresh_token);++ return res.json({ ok: true, user: data.user || claimsToUser(userAuth.verify(data.access_token)) });++ } catch (err) {++ if (err.status && err.status < 500) return res.status(401).json({ error: err.error || 'invalid_grant' });++ return res.status(502).json({ error: 'server_error', error_description: 'Could not reach OpenVibe.Network' });++ }++ });++++ router.get('/logout', async (req, res) => {++ const refresh = req.cookies && req.cookies[REFRESH_COOKIE];++ if (refresh) {++ try {++ await fetchImpl(`${config.network.internalUrl}/oauth/revoke`, {++ method: 'POST', headers: { 'Content-Type': 'application/json' },++ body: JSON.stringify({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, token: refresh }),++ signal: AbortSignal.timeout(3000),++ });++ } catch { /* optional */ }++ }++ clearSession(res);++ res.cookie(HINT_COOKIE, 'guest', hintOpts());++ res.redirect(sanitizeNext(req.query.next, config));++ });++++ router.get('/me', (req, res) => {++ const token = (String(req.headers.authorization || '').startsWith('Bearer ') ? req.headers.authorization.slice(7) : null) || (req.cookies && req.cookies[ACCESS_COOKIE]);++ const claims = userAuth.verify(token);++ if (!claims) return res.status(401).json({ error: 'Not authenticated' });++ res.json({ user: claimsToUser(claims), expires_at: claims.exp ? claims.exp * 1000 : null });++ });++++ router.post('/refresh', async (req, res) => {++ const refresh = req.cookies && req.cookies[REFRESH_COOKIE];++ if (!refresh) return res.status(401).json({ error: 'No refresh token' });++ try {++ const data = await tokenGrant({ grant_type: 'refresh_token', refresh_token: refresh });++ setSession(res, data.access_token, data.refresh_token);++ return res.json({ token: data.access_token, user: claimsToUser(userAuth.verify(data.access_token)) });++ } catch (err) {++ if (err.status && err.status < 500) { clearSession(res); return res.status(401).json({ error: 'Refresh token rejected — please sign in again' }); }++ return res.status(502).json({ error: 'Could not reach OpenVibe.Network' });++ }++ });++++ return router;++}++++module.exports = { createSessionRoutes, viewerMiddleware, sanitizeNext, ACCESS_COOKIE };nnew file mode 100644@@@ -0,0 +1,176 @@++'use strict';++// /api/v1: service tokens need the one capability each route checks; people act only on their own++// things; idempotency keys; goals derive from settled interactions only.++const assert = require('assert');++const { boot, check, done } = require('./helpers/app');++++(async () => {++ const t = await boot();++ const { domain, billing } = t;++ const alex = await t.creator('alex');++ const bob = await t.creator('bob', { settings: { page_enabled: false } });++ const viewer = t.network.newUser('viewer');++ billing.fund(viewer.subject, 10_000);++++ await check('health, ready and release.json', async () => {++ const h = await t.call('GET', '/api/health', { token: null });++ assert.strictEqual(h.json.service, 'tips');++ const r = await t.call('GET', '/api/ready', { token: null });++ assert.strictEqual(r.json.ready, true);++ const rel = await t.call('GET', '/release.json', { token: null });++ assert.strictEqual(rel.json.service, 'tips');++ });++++ await check('a service token for another audience, or without the capability, is refused', async () => {++ const wrongAud = t.network.signService({ aud: ['openvibe.billing'], cap: ['tips.*'] });++ const a = await t.call('GET', '/api/v1/interactions?creator=alex', { token: wrongAud });++ assert.strictEqual(a.status, 401);++ const b = await t.call('GET', '/api/v1/interactions?creator=alex', { cap: ['tips.interaction.get'] });++ assert.strictEqual(b.status, 403);++ assert.strictEqual(b.headers.get('content-type'), 'application/problem+json');++ const c = await t.call('GET', '/api/v1/interactions?creator=alex', { cap: ['tips.interaction.list'] });++ assert.strictEqual(c.status, 200, c.text);++ const garbage = await t.call('GET', '/api/v1/interactions', { token: 'not.a.token' });++ assert.strictEqual(garbage.status, 401);++ });++++ await check('profiles: public when the page is on; a switched-off page exists only for its creator and granted services', async () => {++ assert.strictEqual((await t.call('GET', '/api/v1/profiles/alex', { token: null })).status, 200);++ assert.strictEqual((await t.call('GET', '/api/v1/profiles/bob', { token: null })).status, 404);++ assert.strictEqual((await t.call('GET', '/api/v1/profiles/bob', { user: viewer })).status, 404);++ const own = await t.call('GET', '/api/v1/profiles/bob', { user: bob });++ assert.strictEqual(own.status, 200);++ assert.strictEqual(own.json.profile.page_enabled, false);++ assert.ok(own.json.profile.revision >= 1);++ assert.strictEqual((await t.call('GET', `/api/v1/profiles/${bob.subject}`, { cap: ['tips.profile.get'] })).status, 200);++ const upd = await t.call('PATCH', '/api/v1/profiles/bob', { user: viewer, body: { accepting: false } });++ assert.strictEqual(upd.status, 403);++ const svc = await t.call('PATCH', `/api/v1/profiles/${bob.subject}`, { cap: ['tips.profile.update'], body: { headline: 'Streams at 8' } });++ assert.strictEqual(svc.status, 200, svc.text);++ assert.strictEqual(svc.json.profile.headline, 'Streams at 8');++ const stale = await t.call('PATCH', '/api/v1/profiles/me', { user: bob, body: { headline: 'x', revision: 1 } });++ assert.strictEqual(stale.status, 409);++ });++++ await check('a service can open a profile for a creator subject (tips.profile.update)', async () => {++ const carol = t.network.newUser('carol');++ const r = await t.call('PATCH', `/api/v1/profiles/${carol.subject}`, { cap: ['tips.profile.update'], body: { handle: 'carol', display_name: 'Carol', page_enabled: true } });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json.profile.handle, 'carol');++ const reserved = t.network.newUser('dashboard');++ const r2 = await t.call('PATCH', '/api/v1/profiles/me', { user: reserved, body: {} });++ assert.notStrictEqual(r2.json.profile.handle, 'dashboard', 'reserved paths are never handles');++ });++++ await check('a service tips on behalf of a supporter with tips.checkout.create; the capability is per kind', async () => {++ const r = await t.call('POST', '/api/v1/checkout', { cap: ['tips.checkout.create'], body: { creator: 'alex', supporter: { type: 'user', id: viewer.subject }, supporter_name: 'Viewer', amount: 10 } });++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual(r.json.interaction.payment.state, 'settled');++ const pm = await t.call('POST', '/api/v1/paid-messages', { cap: ['tips.checkout.create'], body: { creator: 'alex', supporter: viewer.subject, amount: 200, message: 'hi' } });++ assert.strictEqual(pm.status, 403, 'paid messages need tips.superchat.create');++ const ok = await t.call('POST', '/api/v1/paid-messages', { cap: ['tips.superchat.create'], body: { creator: 'alex', supporter: viewer.subject, amount: 200, message: 'hi' } });++ assert.strictEqual(ok.status, 201, ok.text);++ const tts = await t.call('POST', '/api/v1/tts-requests', { cap: ['tips.tts.request'], body: { creator: 'alex', supporter: viewer.subject, amount: 200, tts: { text: 'x' } } });++ assert.strictEqual(tts.status, 409, 'TTS is off on this page');++ assert.strictEqual(tts.json.code, 'tips.tts_disabled');++ });++++ await check('the creator\'s rules: minimums, message length, media hosts', async () => {++ await t.call('PATCH', '/api/v1/profiles/me', { user: alex, body: { min_amount: 5, media_requests_enabled: true, media_request_min: 50 } });++ const small = await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: 'alex', amount: 4 } });++ assert.strictEqual(small.json.code, 'tips.amount_too_small');++ const long = await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: 'alex', amount: 10, message: 'x'.repeat(301) } });++ assert.strictEqual(long.json.code, 'tips.text_too_long');++ const host = await t.call('POST', '/api/v1/media-requests', { user: viewer, body: { creator: 'alex', amount: 60, media: { url: 'https://evil.example/video' } } });++ assert.strictEqual(host.json.code, 'tips.invalid_media');++ const plain = await t.call('POST', '/api/v1/media-requests', { user: viewer, body: { creator: 'alex', amount: 60, media: { url: 'http://youtu.be/x' } } });++ assert.strictEqual(plain.json.code, 'tips.invalid_media');++ const smallCheckout = await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: 'alex', amount: 50, pay_with: 'checkout' } });++ assert.strictEqual(smallCheckout.json.code, 'tips.amount_too_small');++ await t.call('PATCH', '/api/v1/profiles/me', { user: alex, body: { min_amount: 1 } });++ });++++ await check('idempotency: a replay returns the same interaction and moves nothing; another body with the key is refused', async () => {++ const body = { creator: 'alex', amount: 33 };++ const a = await t.call('POST', '/api/v1/checkout', { user: viewer, body, key: 'tip-key-000001' });++ const before = billing.transfers().length;++ const b = await t.call('POST', '/api/v1/checkout', { user: viewer, body, key: 'tip-key-000001' });++ assert.strictEqual(b.headers.get('idempotent-replayed'), 'true');++ assert.strictEqual(b.json.interaction.id, a.json.interaction.id);++ assert.strictEqual(billing.transfers().length, before);++ const c = await t.call('POST', '/api/v1/checkout', { user: viewer, body: { ...body, amount: 34 }, key: 'tip-key-000001' });++ assert.strictEqual(c.status, 422);++ assert.strictEqual(c.json.code, 'idempotency.key_reused');++ const none = await t.call('POST', '/api/v1/checkout', { user: viewer, body, key: null });++ assert.strictEqual(none.status, 400);++ });++++ await check('receipts: supporters see their own, creators theirs, nobody else', async () => {++ const mine = await t.call('GET', '/api/v1/interactions', { user: viewer });++ assert.ok(mine.json.interactions.length >= 2);++ assert.ok(mine.json.interactions.every((i) => i.supporter.id === viewer.subject));++ assert.ok(mine.json.interactions.every((i) => i.effects === undefined), 'supporters do not see delivery internals');++ const theirs = await t.call('GET', '/api/v1/interactions?as=creator', { user: alex });++ assert.ok(theirs.json.interactions.every((i) => i.creator.id === alex.subject));++ const id = mine.json.interactions[0].id;++ assert.strictEqual((await t.call('GET', `/api/v1/interactions/${id}`, { user: bob })).status, 404);++ assert.strictEqual((await t.call('GET', `/api/v1/interactions/${id}`, { token: null })).status, 401);++ assert.strictEqual((await t.call('GET', `/api/v1/interactions/${id}`, { user: alex })).json.interaction.effects.length >= 1, true);++ const page1 = await t.call('GET', '/api/v1/interactions?limit=1', { user: viewer });++ assert.ok(page1.json.next_cursor);++ const page2 = await t.call('GET', `/api/v1/interactions?limit=1&cursor=${page1.json.next_cursor}`, { user: viewer });++ assert.notStrictEqual(page2.json.interactions[0].id, page1.json.interactions[0].id);++ });++++ await check('goals: CRUD by the owner only; contributions come from settled interactions only', async () => {++ const g = await t.call('POST', '/api/v1/goals', { user: alex, body: { title: 'Lights', target_amount: 100 } });++ assert.strictEqual(g.status, 201, g.text);++ const id = g.json.goal.id;++ assert.strictEqual((await t.call('PATCH', `/api/v1/goals/${id}`, { user: viewer, body: { title: 'mine now' } })).status, 403);++ assert.strictEqual((await t.call('POST', '/api/v1/goals', { cap: ['tips.goal.update'], body: { creator: 'alex', title: 'x', target_amount: 5 } })).status, 403);++ // Pending (checkout not paid) and failed payments never count.++ const buyer = t.network.newUser('buyer');++ await t.call('POST', '/api/v1/checkout', { user: buyer, body: { creator: 'alex', amount: 150, pay_with: 'checkout', provider: 'stripe', goal_id: id } });++ const broke = t.network.newUser('broke');++ await t.call('POST', '/api/v1/checkout', { user: broke, body: { creator: 'alex', amount: 70, goal_id: id } });++ let goal = (await t.call('GET', `/api/v1/goals/${id}`, { token: null })).json.goal;++ assert.strictEqual(goal.current_amount, 0);++ await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: 'alex', amount: 60, goal_id: id } });++ await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: 'alex', amount: 50, goal_id: id } });++ goal = (await t.call('GET', `/api/v1/goals/${id}`, { user: alex })).json.goal;++ assert.strictEqual(goal.current_amount, 110);++ assert.strictEqual(goal.reached, true);++ assert.strictEqual(goal.contributions.length, 2);++ assert.strictEqual(goal.carried_over_amount, 0);++ const edit = await t.call('PATCH', `/api/v1/goals/${id}`, { user: alex, body: { target_amount: 500, revision: goal.revision } });++ assert.strictEqual(edit.status, 200, edit.text);++ assert.strictEqual(edit.json.goal.reached, false);++ const stale = await t.call('PATCH', `/api/v1/goals/${id}`, { user: alex, body: { target_amount: 600, revision: goal.revision } });++ assert.strictEqual(stale.status, 409);++ const closed = await t.call('POST', `/api/v1/goals/${id}/close`, { user: alex });++ assert.strictEqual(closed.json.goal.status, 'closed');++ const list = await t.call('GET', '/api/v1/goals?creator=alex&status=closed', { token: null });++ assert.ok(list.json.goals.some((x) => x.id === id));++ assert.strictEqual((await t.call('GET', '/api/v1/goals?creator=bob', { token: null })).status, 404, 'a switched-off page shows no goals');++ });++++ await check('totals: derived from settled interactions, never from a stored balance', async () => {++ const r = await t.call('GET', '/api/v1/profiles/alex/totals', { user: alex });++ const sum = t.db.prepare("SELECT SUM(amount - reversed_bits) AS n FROM tip_interactions WHERE creator_subject = ? AND test = 0 AND settlement IN ('billing','imported') AND payment_state IN ('settled','reversed')").get(alex.subject).n;++ assert.strictEqual(r.json.totals.settled_via_billing, sum);++ assert.strictEqual(r.json.totals.settled_via_billing, billing.payable.get(alex.subject), 'equals what Billing holds for the creator');++ assert.strictEqual((await t.call('GET', '/api/v1/profiles/alex/totals', { user: viewer })).status, 403);++ });++++ await check('overlay configs are the owner\'s (tips.overlay.config.* for services)', async () => {++ const c = await t.call('POST', '/api/v1/overlay-configs', { user: alex, body: { kind: 'alerts', sound_url: 'http://insecure.example/a.mp3' } });++ assert.strictEqual(c.status, 422);++ const ok = await t.call('POST', '/api/v1/overlay-configs', { cap: ['tips.overlay.config.update'], body: { creator: 'alex', kind: 'goal', name: 'Goal bar' } });++ assert.strictEqual(ok.status, 201, ok.text);++ assert.strictEqual((await t.call('GET', '/api/v1/overlay-configs?creator=alex', { cap: ['tips.overlay.config.update'] })).status, 403);++ assert.strictEqual((await t.call('GET', '/api/v1/overlay-configs?creator=alex', { cap: ['tips.overlay.config.get'] })).status, 200);++ });++++ await t.close();++ done();++})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,81 @@++'use strict';++// The live-chat delivery adapter: Tips' service token for Live, one delivery id per effect, retries++// for outages, an immediate stop for refusals — and never a change to the payment.++const assert = require('assert');++const { boot, check, done } = require('./helpers/app');++++(async () => {++ const t = await boot({ live: true });++ const { domain, billing, live } = t;++ await t.creator('alex', { settings: { tts_enabled: true } });++ const viewer = t.network.newUser('viewer');++ billing.fund(viewer.subject, 5000);++++ await check('a settled tip is posted to Live with a service token (openvibe.live, live.tips_delivery.write)', async () => {++ const r = await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: 'alex', amount: 120, message: 'gg', target: { service: 'live', type: 'stream', id: '42' } } });++ await domain.effects.drain();++ const d = live.deliveries.find((x) => x.job.interaction.id === r.json.interaction.id);++ assert.ok(d, 'delivered to Live');++ assert.strictEqual(d.key, `${r.json.interaction.id}:chat_line`);++ assert.strictEqual(d.job.effect, 'chat_line');++ assert.strictEqual(d.job.text, 'Viewer tipped 120 Vibes: gg');++ assert.deepStrictEqual(d.job.target, { service: 'live', type: 'stream', id: '42' });++ assert.strictEqual(d.job.creator.handle, 'alex');++ assert.strictEqual(d.job.test, false);++ const grant = t.network.grants.find((g) => g.audience === 'openvibe.live');++ assert.strictEqual(grant.scope, 'live.tips_delivery.write');++ const e = t.db.prepare("SELECT * FROM interaction_effects WHERE interaction_id = ? AND effect = 'chat_line'").get(r.json.interaction.id);++ assert.deepStrictEqual(JSON.parse(e.result).ref, { chat_message_id: 1 });++ });++++ await check('TTS goes out as its own effect with the text and voice', async () => {++ const r = await t.call('POST', '/api/v1/tts-requests', { user: viewer, body: { creator: 'alex', amount: 150, tts: { text: 'hello https://x.example there' } } });++ assert.strictEqual(r.status, 201, r.text);++ await domain.effects.drain();++ const d = live.deliveries.find((x) => x.job.interaction.id === r.json.interaction.id && x.job.effect === 'tts');++ assert.deepStrictEqual(d.job.tts, { text: 'hello link there', voice: 'gary' });++ });++++ await check('Live down (503): retried with backoff; the payment stays settled throughout', async () => {++ live.state.fail = 503;++ const r = await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: 'alex', amount: 7 } });++ const id = r.json.interaction.id;++ await domain.effects.drain();++ let e = t.db.prepare("SELECT * FROM interaction_effects WHERE interaction_id = ? AND effect = 'chat_line'").get(id);++ assert.strictEqual(e.state, 'queued');++ assert.match(e.last_error, /Live 503/);++ assert.strictEqual(domain.interactions.get(id).payment_state, 'settled');++ live.state.fail = null;++ t.clock.offset += 5000;++ await domain.effects.drain();++ t.clock.offset = 0;++ e = t.db.prepare("SELECT * FROM interaction_effects WHERE interaction_id = ? AND effect = 'chat_line'").get(id);++ assert.strictEqual(e.state, 'delivered');++ assert.strictEqual(domain.interactions.get(id).delivery_state, 'delivered');++ });++++ await check('Live refuses (422): the effect fails at once, tips.interaction.failed, payment untouched', async () => {++ live.state.fail = 422;++ const r = await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: 'alex', amount: 8 } });++ const id = r.json.interaction.id;++ await domain.effects.drain();++ live.state.fail = null;++ const i = domain.interactions.get(id);++ assert.strictEqual(i.delivery_state, 'failed');++ assert.strictEqual(i.payment_state, 'settled');++ assert.strictEqual(billing.payable.get(t.domain.profiles.byHandle('alex').creator_subject) >= 8, true);++ assert.strictEqual(t.outboxRows('tips.interaction.failed').filter((ev) => ev.subject.id === id).length, 1);++ });++++ await check('simulations never reach Live: their chat effects stay in the test adapter', async () => {++ const alex = t.domain.profiles.byHandle('alex');++ const before = live.deliveries.length;++ const sim = domain.interactions.simulate(alex, { kind: 'tip', amount: 100 }, { by: alex.creator_subject });++ await domain.effects.drain();++ assert.strictEqual(live.deliveries.length, before);++ assert.ok(t.adapters.test.jobs.some((j) => j.interaction.id === sim.id && j.test));++ });++++ await t.close();++ done();++})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,156 @@++'use strict';++/**++ * Boots Tips against the stubs on a random port with a temp database. Jobs are off: tests drive the++ * effects worker and transfer retries explicitly (domain.effects.drain(), processDueTransfers()).++ *++ * t.call(method, path, { body, user, cap, sub, key }) user: a stub user → Bearer user JWT;++ * otherwise a service token with `cap`++ * t.deliver(envelope) a signed OpenVibe.Events delivery to /internal/events++ * t.sse(path, { lastEventId }) an SSE reader++ */++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const crypto = require('crypto');++const { signDelivery } = require('openvibe-sdk/events');++const { startNetwork, startBilling, startEvents, startLive } = require('./stubs');++++const EVENTS_SECRET = 'e'.repeat(48);++++async function boot(opts = {}) {++ const network = await startNetwork();++ const billing = await startBilling(network);++ const events = await startEvents();++ const live = opts.live ? await startLive(network) : null;++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tips-test-'));++ const env = {++ NODE_ENV: 'test',++ TIPS_DB_PATH: path.join(dir, 'tips.db'),++ BASE_URL: 'http://tips.test',++ OV_NETWORK_URL: network.url,++ OV_NETWORK_INTERNAL_URL: network.url,++ OV_NETWORK_ISSUER: network.url,++ OV_OAUTH_CLIENT_ID: 'tips',++ OV_OAUTH_CLIENT_SECRET: 'shh',++ TIPS_FORM_SECRET: 'form-secret-for-tests',++ BILLING_URL: billing.url,++ TIPS_CHECKOUT_PROVIDERS: 'powerchat,stripe',++ TIPS_EVENTS_SECRET: EVENTS_SECRET,++ EVENTS_URL: events.url,++ EVENTS_RELAY_INTERVAL_MS: '50',++ TIPS_JOBS: 'off',++ TIPS_CHAT_ADAPTER: live ? 'live-chat' : 'test',++ LIVE_INTERNAL_URL: live ? live.url : 'http://127.0.0.1:9',++ TIPS_OVERLAY_HEARTBEAT_MS: '60000',++ ...(opts.env || {}),++ };++ for (const k of Object.keys(require.cache)) if (k.includes(`${path.sep}server${path.sep}`)) delete require.cache[k];++ const { loadConfig } = require('../../server/config');++ const { createApp } = require('../../server/app');++ const config = loadConfig(env);++ const clock = { offset: 0 };++ const logs = [];++ const log = { log: (...a) => logs.push(a.join(' ')), warn: (...a) => logs.push(a.join(' ')), error: (...a) => logs.push(a.join(' ')) };++ const app = createApp({ config, now: () => Date.now() + clock.offset, log });++ await app.locals.keys.load();++ const server = await new Promise((resolve) => { const s = http.createServer(app); s.listen(0, '127.0.0.1', () => resolve(s)); });++ const base = `http://127.0.0.1:${server.address().port}`;++ const domain = app.locals.domain;++++ let n = 0;++ async function call(method, p, { body, user, cap = ['tips.*'], sub = 'svc:live', key, token, headers = {} } = {}) {++ const h = { ...headers };++ if (token !== null) h.Authorization = `Bearer ${token || (user ? network.signUser(user) : network.signService({ sub, cap }))}`;++ if (body !== undefined) h['Content-Type'] = 'application/json';++ if ((method === 'POST' || method === 'PATCH') && key !== null) h['Idempotency-Key'] = key || `test-${process.pid}-${++n}-${crypto.randomBytes(4).toString('hex')}`;++ const res = await fetch(base + p, { method, headers: h, body: body !== undefined ? JSON.stringify(body) : undefined });++ const text = await res.text();++ let json = null; try { json = JSON.parse(text); } catch { /* html */ }++ return { status: res.status, headers: res.headers, json, text };++ }++++ /** POST a signed Events delivery of `event` (as OpenVibe.Events would). */++ async function deliver(event, { secret = EVENTS_SECRET, seq = 1 } = {}) {++ const raw = JSON.stringify({ event, seq });++ const res = await fetch(`${base}/internal/events`, {++ method: 'POST', body: raw,++ headers: { 'Content-Type': 'application/json', 'X-OpenVibe-Signature': signDelivery(raw, secret), 'X-OpenVibe-Seq': String(seq) },++ });++ return { status: res.status, json: await res.json().catch(() => null) };++ }++++ /** Minimal SSE reader: .events [{ id, event, data }], .waitFor(pred), .close(), .ended */++ function sse(p, { lastEventId } = {}) {++ return new Promise((resolve, reject) => {++ const headers = { Accept: 'text/event-stream' };++ if (lastEventId != null) headers['Last-Event-ID'] = String(lastEventId);++ const req = http.get(base + p, { headers }, (res) => {++ const reader = { status: res.status || res.statusCode, events: [], ended: false, waiters: [] };++ let buf = '';++ const check = () => { reader.waiters = reader.waiters.filter((w) => { const hit = reader.events.find(w.pred); if (hit) { w.resolve(hit); return false; } return true; }); };++ res.setEncoding('utf8');++ res.on('data', (chunk) => {++ buf += chunk;++ let i;++ while ((i = buf.indexOf('\n\n')) >= 0) {++ const block = buf.slice(0, i); buf = buf.slice(i + 2);++ const e = { id: null, event: 'message', data: null };++ for (const line of block.split('\n')) {++ if (line.startsWith('id: ')) e.id = Number(line.slice(4));++ else if (line.startsWith('event: ')) e.event = line.slice(7);++ else if (line.startsWith('data: ')) e.data = JSON.parse(line.slice(6));++ }++ if (e.data != null) reader.events.push(e);++ }++ check();++ });++ res.on('end', () => { reader.ended = true; reader.waiters.forEach((w) => w.resolve(null)); reader.waiters = []; });++ reader.waitFor = (pred, ms = 3000) => new Promise((ok, fail) => {++ const hit = reader.events.find(pred);++ if (hit) return ok(hit);++ if (reader.ended) return ok(null);++ const w = { pred, resolve: ok };++ reader.waiters.push(w);++ setTimeout(() => { reader.waiters = reader.waiters.filter((x) => x !== w); fail(new Error(`SSE wait timed out; got ${JSON.stringify(reader.events.map((x) => x.event))}`)); }, ms).unref();++ });++ reader.close = () => req.destroy();++ reader.statusCode = res.statusCode;++ resolve(reader);++ });++ req.on('error', reject);++ });++ }++++ /** A creator with a profile (page on unless told otherwise). */++ async function creator(username, profile = {}) {++ const u = network.newUser(username, profile.liveId);++ const r = await call('PATCH', '/api/v1/profiles/me', { user: u, body: { page_enabled: true, ...profile.settings } });++ if (r.status !== 200) throw new Error(`creator setup: ${r.status} ${r.text}`);++ return u;++ }++++ const outboxRows = (type) => domain.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all().map((r) => JSON.parse(r.envelope)).filter((e) => !type || e.event_type === type);++++ return {++ app, base, call, deliver, sse, creator, domain, db: domain.db, config, clock, network, billing, events, live, logs, dir, outboxRows,++ adapters: app.locals.adapters,++ close: async () => {++ domain.overlays.closeAll();++ await new Promise((r) => server.close(r));++ await app.locals.outbox.stop();++ await Promise.all([network.close(), billing.close(), events.close(), live && live.close()]);++ try { domain.db.close(); } catch { /* */ }++ fs.rmSync(dir, { recursive: true, force: true });++ },++ };++}++++let failures = 0;++async function check(name, fn) {++ try { await fn(); console.log(' ✓', name); }++ catch (e) { failures++; console.log(' ✗', name, '\n ', (e.stack || String(e)).split('\n').slice(0, 8).join('\n ')); }++}++function done() { console.log(failures ? `\n${failures} failed` : '\nall passed'); process.exit(failures ? 1 : 0); }++++module.exports = { boot, check, done, EVENTS_SECRET };nnew file mode 100644@@@ -0,0 +1,217 @@++'use strict';++/**++ * Stand-ins for the services Tips talks to, each on a random port.++ *++ * startNetwork() JWKS, client-credentials token endpoint (scope → cap), user JWTs (signUser)++ * startBilling() /api/v1/intents, /transfers, /transfers/:id/refund with Billing's rules that++ * matter here (idempotency keys, funds, self-dealing, capability + audience checks),++ * and the billing.transaction.* envelopes it would publish (billing.events)++ * startEvents() POST /api/v1/events recording what Tips' outbox relays++ * startLive() POST /internal/tips/deliveries recording chat deliveries (the live-chat adapter)++ */++const http = require('http');++const crypto = require('crypto');++const jwt = require('jsonwebtoken');++const { serviceAuth, ids } = require('openvibe-contracts');++++function listen(server) {++ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(`http://127.0.0.1:${server.address().port}`)));++}++function readBody(req) {++ return new Promise((resolve) => { const c = []; req.on('data', (d) => c.push(d)); req.on('end', () => resolve(Buffer.concat(c).toString('utf8'))); });++}++const send = (res, status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };++++async function startNetwork() {++ const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });++ const publicPem = publicKey.export({ type: 'spki', format: 'pem' });++ const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });++ const grants = [];++ const legacy = {};++ let issuer = 'http://network.test';++ let n = 100;++++ function signService({ sub = 'svc:live', aud = ['openvibe.tips'], cap = [], expSec = 300 } = {}) {++ const now = Math.floor(Date.now() / 1000);++ return serviceAuth.signServiceToken({ iss: issuer, sub, actor_type: 'service', aud, cap, iat: now, exp: now + expSec, jti: crypto.randomBytes(8).toString('hex') }, privatePem);++ }++ /** A Network user access token (what the browser holds in ov_token). */++ function signUser(u) {++ return jwt.sign({ sub: String(u.networkId || ++n), subject_id: u.subject, username: u.username, display_name: u.display_name || u.username, role: u.role || 'user' },++ privatePem, { algorithm: 'RS256', issuer, expiresIn: '1h' });++ }++ function newUser(username, liveId) {++ const subject = ids.newId('user');++ if (liveId != null) legacy[String(liveId)] = { subject, username };++ return { subject, username, display_name: username[0].toUpperCase() + username.slice(1) };++ }++++ const server = http.createServer(async (req, res) => {++ const raw = await readBody(req);++ if (req.url === '/api/.well-known/jwks') return send(res, 200, { public_key: publicPem, algorithm: 'RS256' });++ if (req.url === '/oauth/token' && req.method === 'POST') {++ const body = Object.fromEntries(new URLSearchParams(raw));++ grants.push(body);++ if (body.client_secret !== 'shh') return send(res, 401, { error: 'invalid_client' });++ const cap = String(body.scope || '').split(/\s+/).filter(Boolean);++ return send(res, 200, { access_token: signService({ sub: `svc:${body.client_id}`, aud: [body.audience], cap }), token_type: 'Bearer', expires_in: 300 });++ }++ if (req.url === '/internal/identity/resolve-batch' && req.method === 'POST') {++ const v = serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: publicPem, issuer, audience: 'openvibe.network' });++ if (!v.ok) return send(res, 401, { code: v.code });++ if (!(v.claims.cap || []).includes('identity.subject.resolve')) return send(res, 403, { code: 'capability.denied' });++ const body = JSON.parse(raw || '{}');++ const results = {};++ for (const id of body.ids || []) {++ const u = body.system === 'live' ? legacy[String(id)] : null;++ results[String(id)] = u ? { subject: { type: 'user', id: u.subject }, username: u.username } : null;++ }++ return send(res, 200, { results });++ }++ send(res, 404, { error: 'not found' });++ });++ const url = await listen(server);++ issuer = url;++ return { url, publicPem, signService, signUser, newUser, legacy, grants, close: () => new Promise((r) => server.close(r)) };++}++++/** Billing as Tips sees it. */++async function startBilling(network) {++ const credit = new Map();++ const payable = new Map();++ const txns = new Map();++ const intents = new Map();++ const idem = new Map();++ const calls = [];++ const events = [];++ const state = { down: false };++ const bal = (m, s) => m.get(s) || 0;++ const move = (m, s, d) => m.set(s, bal(m, s) + d);++++ function envelope(type, payload, subjectId) {++ return {++ event_id: ids.newId('event'), event_type: type, version: 1, source: 'billing', actor: { type: 'service', id: 'billing' },++ timestamp: new Date().toISOString(), priority: 'important', visibility: 'internal', subject: { type: 'transaction', id: subjectId }, payload,++ };++ }++ const summary = (t) => ({ transaction_id: t.id, type: t.type, test: !!t.test, from_subject: t.from_subject, to_subject: t.to_subject, provider: t.provider || null, metadata: t.metadata, ...(t.reverses_txn ? { reverses_txn: t.reverses_txn } : {}) });++ function record(t, type) { txns.set(t.id, t); const e = envelope(type, summary(t), t.id); events.push(e); return e; }++ const newTxn = (fields) => ({ id: `txn_${ids.ulid()}`, status: 'settled', test: false, created_at: new Date().toISOString(), ...fields });++++ function fund(subject, bits) {++ move(credit, subject, bits);++ return record(newTxn({ type: 'purchase', to_subject: subject, provider: 'powerchat', metadata: { bits } }), 'billing.transaction.settled');++ }++ /** The provider paid a checkout: the purchase settles (credit to the buyer) → the settled event. */++ function settlePurchase(intentId, { test = false } = {}) {++ const i = intents.get(intentId);++ move(credit, i.subject.id, i.bits);++ i.status = 'settled';++ return record(newTxn({ type: 'purchase', to_subject: i.subject.id, provider: i.provider, test, metadata: { bits: i.bits, intent_id: i.id } }), 'billing.transaction.settled');++ }++ /** A donation Tips did not start (e.g. Live's own donate flow, or a site-routed PowerChat tip). */++ function foreignDonation({ from, to, amount, provider = null, target = null, message = null, test = false, kind = 'donation' }) {++ if (from) move(credit, from, -amount);++ move(payable, to, amount);++ return record(newTxn({ type: 'donation', from_subject: from || null, to_subject: to, provider, test, metadata: { kind, amount_bits: amount, target, message } }), 'billing.transaction.settled');++ }++ /** Refund a credit-funded transfer (payable → giver credit) outside Tips (operator / Live). */++ function refund(txnId, amount) {++ const o = txns.get(txnId);++ const n = amount || o.metadata.amount_bits;++ move(payable, o.to_subject, -n); move(credit, o.from_subject, n);++ return record(newTxn({ type: 'refund', from_subject: o.to_subject, to_subject: o.from_subject, reverses_txn: o.id, metadata: { amount_bits: n } }), 'billing.transaction.reversed');++ }++++ const server = http.createServer(async (req, res) => {++ const raw = await readBody(req);++ const body = raw ? JSON.parse(raw) : {};++ calls.push({ method: req.method, url: req.url, body, key: req.headers['idempotency-key'] || null });++ if (state.down) return send(res, 503, { code: 'billing.unavailable' });++ const v = serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: network.publicPem, issuer: network.url, audience: 'openvibe.billing' });++ if (!v.ok) return send(res, 401, { code: v.code });++ const need = req.url.startsWith('/api/v1/intents') ? 'billing.intent.create' : 'billing.transfer.create';++ if (!(v.claims.cap || []).includes(need)) return send(res, 403, { code: 'capability.denied', detail: `${need} not granted` });++ const key = req.headers['idempotency-key'];++ if (req.method === 'POST') {++ if (!key) return send(res, 400, { code: 'idempotency.key_required' });++ if (idem.has(key)) return send(res, idem.get(key).status, idem.get(key).body);++ }++ const ok = (status, out) => { idem.set(key, { status, body: out }); return send(res, status, out); };++ if (req.method === 'POST' && req.url === '/api/v1/intents') {++ if (body.kind !== 'purchase') return send(res, 422, { code: 'billing.invalid_input' });++ if (body.bits < 100) return send(res, 422, { code: 'billing.amount_too_small', detail: 'the minimum purchase is 100 bits' });++ const id = `pi_${ids.ulid()}`;++ const intent = { id, provider: body.provider, kind: 'purchase', subject: body.subject, bits: body.bits, amount_cents: Math.round(body.bits * 1.5), status: 'created', success_url: body.success_url };++ if (body.provider === 'powerchat') intent.checkout_ref = `pcorder:${id}`;++ intents.set(id, intent);++ return ok(201, { intent, checkout_url: body.provider === 'stripe' ? `https://checkout.stripe.test/${id}` : null });++ }++ if (req.method === 'POST' && req.url === '/api/v1/transfers') {++ const from = body.from.id; const to = body.to.id;++ if (from === to) return send(res, 422, { code: 'billing.self_dealing' });++ if (bal(credit, from) < body.amount) return send(res, 409, { code: 'billing.insufficient_funds', detail: `insufficient credit: ${bal(credit, from)} < ${body.amount} vibes-bits` });++ move(credit, from, -body.amount); move(payable, to, body.amount);++ const t = newTxn({ type: 'donation', from_subject: from, to_subject: to, metadata: { kind: body.kind, amount_bits: body.amount, target: body.target || null, message: body.message || null } });++ record(t, 'billing.transaction.settled');++ return ok(201, { transaction: { ...t, reverses_txn: null } });++ }++ const m = req.url.match(/^\/api\/v1\/transfers\/([^/]+)\/refund$/);++ if (req.method === 'POST' && m) {++ const o = txns.get(m[1]);++ if (!o) return send(res, 404, { code: 'billing.transaction_not_found' });++ refund(o.id, body.amount);++ return ok(201, { transaction: [...txns.values()].pop() });++ }++ send(res, 404, { code: 'not_found' });++ });++ const url = await listen(server);++ return {++ url, credit, payable, txns, intents, calls, events, state, fund, settlePurchase, foreignDonation, refund, envelope,++ transfers: () => calls.filter((c) => c.url === '/api/v1/transfers'),++ close: () => new Promise((r) => server.close(r)),++ };++}++++async function startEvents() {++ const published = [];++ const tokens = [];++ const server = http.createServer(async (req, res) => {++ const raw = await readBody(req);++ tokens.push(req.headers.authorization);++ if (req.url === '/api/v1/events' && req.method === 'POST') {++ const b = JSON.parse(raw);++ const list = b.events || [b];++ const results = list.map((e) => { published.push(e); return { event_id: e.event_id, seq: published.length, duplicate: false }; });++ return send(res, 201, b.events ? { results } : results[0]);++ }++ send(res, 404, {});++ });++ const url = await listen(server);++ return { url, published, tokens, close: () => new Promise((r) => server.close(r)) };++}++++async function startLive(network) {++ const deliveries = [];++ const state = { fail: null };++ const seen = new Map();++ const server = http.createServer(async (req, res) => {++ const raw = await readBody(req);++ if (req.url !== '/internal/tips/deliveries' || req.method !== 'POST') return send(res, 404, {});++ const v = serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: network.publicPem, issuer: network.url, audience: 'openvibe.live' });++ if (!v.ok) return send(res, 401, { code: v.code });++ if (!(v.claims.cap || []).includes('live.tips_delivery.write')) return send(res, 403, { code: 'capability.denied' });++ if (state.fail) return send(res, state.fail, { code: 'live.refused', detail: 'stub refusal' });++ const key = req.headers['idempotency-key'];++ if (seen.has(key)) return send(res, 200, seen.get(key));++ const job = JSON.parse(raw);++ deliveries.push({ key, job });++ const out = { ok: true, ref: { chat_message_id: deliveries.length } };++ seen.set(key, out);++ return send(res, 200, out);++ });++ const url = await listen(server);++ return { url, deliveries, state, close: () => new Promise((r) => server.close(r)) };++}++++module.exports = { startNetwork, startBilling, startEvents, startLive };nnew file mode 100644@@@ -0,0 +1,191 @@++'use strict';++// Import from a Live snapshot, linked to Billing's imported journal, with the reconciliation:++// creator totals must equal Billing's. Fixtures follow Live's schema and Billing's importer keys.++const assert = require('assert');++const path = require('path');++const Database = require('better-sqlite3');++const { boot, check, done } = require('./helpers/app');++++function liveFixture(file) {++ const db = new Database(file);++ db.exec(`++ CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT, display_name TEXT, openvibe_bucks_balance INTEGER DEFAULT 0);++ CREATE TABLE transactions (id INTEGER PRIMARY KEY, from_user_id INTEGER, to_user_id INTEGER, stream_id INTEGER, amount INTEGER NOT NULL,++ type TEXT NOT NULL, status TEXT DEFAULT 'completed', message TEXT, paypal_transaction_id TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);++ CREATE TABLE chat_messages (id INTEGER PRIMARY KEY, stream_id INTEGER, channel_user_id INTEGER, user_id INTEGER, username TEXT, message TEXT,++ message_type TEXT DEFAULT 'chat', metadata TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);++ CREATE TABLE donation_goals (id INTEGER PRIMARY KEY, user_id INTEGER, title TEXT, target_amount INTEGER, current_amount INTEGER DEFAULT 0,++ is_active INTEGER DEFAULT 1, image_url TEXT, media_type TEXT, reached_at DATETIME, sort_order INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);++ CREATE TABLE site_settings (key TEXT PRIMARY KEY, value TEXT);++ `);++ const u = db.prepare('INSERT INTO users (id, username, display_name) VALUES (?, ?, ?)');++ [[1, 'alex', 'Alex'], [2, 'viewer', 'Viewer'], [3, 'ghost', 'Ghost'], [4, 'held', 'Held'], [5, 'bob', 'Bob']].forEach((r) => u.run(...r));++ db.prepare("INSERT INTO site_settings (key, value) VALUES ('stats_vibes_reset_at', '2026-01-01T00:00:00.000Z')").run();++ const t = db.prepare('INSERT INTO transactions (id, from_user_id, to_user_id, stream_id, amount, type, status, message, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)');++ t.run(1, 2, 1, 7, 100, 'donation', 'completed', 'test era', '2025-12-01 10:00:00');++ t.run(2, 2, 1, 7, 500, 'donation', 'completed', 'nice', '2026-02-01 10:00:00');++ t.run(3, 3, 1, null, 200, 'donation', 'completed', null, '2026-02-02 10:00:00');++ t.run(4, 2, 4, null, 300, 'donation', 'completed', null, '2026-02-02 11:00:00');++ t.run(5, 2, 1, null, 50, 'donation', 'failed', null, '2026-02-02 12:00:00');++ t.run(6, 2, 1, 7, 250, 'donation', 'completed', 'Media request: Song', '2026-02-03 10:00:00');++ t.run(7, 1, 2, null, 250, 'refund', 'completed', 'Refund: Song', '2026-02-04 10:00:00');++ t.run(8, null, 5, null, 1000, 'donation', 'completed', 'PowerChat tip via site account ($10.00)', '2026-02-05 12:00:00');++ t.run(9, null, 2, null, 1000, 'purchase', 'completed', null, '2026-01-20 12:00:00');++ const c = db.prepare("INSERT INTO chat_messages (id, channel_user_id, username, message, message_type, metadata, created_at) VALUES (?, ?, ?, ?, 'donation', ?, ?)");++ c.run(1, 5, 'Fan', 'Fan tipped 1000 Vibes (PowerChat)', JSON.stringify({ kind: 'donation', amount: 1000, message: '', username: 'Fan', source: 'powerchat' }), '2026-02-05 12:00:30');++ c.run(2, 1, 'Direct', 'Direct tipped 700 Vibes (PowerChat)', JSON.stringify({ kind: 'donation', amount: 700, message: 'love it', username: 'Direct', source: 'powerchat' }), '2026-02-06 09:00:00');++ c.run(3, 1, 'Viewer', 'Viewer donated 500 Vibes: nice', JSON.stringify({ kind: 'donation', amount: 500, username: 'Viewer' }), '2026-02-01 10:00:01');++ const g = db.prepare('INSERT INTO donation_goals (id, user_id, title, target_amount, current_amount, is_active, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)');++ g.run(1, 1, 'Synth', 5000, 1200, 1, '2026-01-10 10:00:00');++ g.run(2, 4, 'Held goal', 100, 0, 1, '2026-01-10 10:00:00');++ return db;++}++++/** The rows OpenVibe.Billing's importer writes for that snapshot (server/importer/live.js keys). */++function billingFixture(file, subjects, { withNative = false } = {}) {++ const db = new Database(file);++ db.exec(`CREATE TABLE transactions (id TEXT PRIMARY KEY, type TEXT NOT NULL, status TEXT NOT NULL, idempotency_key TEXT UNIQUE, reverses_txn TEXT,++ test INTEGER NOT NULL DEFAULT 0, from_subject TEXT, to_subject TEXT, provider TEXT, receipt_ref TEXT, metadata TEXT, created_at TEXT)`);++ const ins = db.prepare('INSERT INTO transactions (id, type, status, idempotency_key, reverses_txn, test, from_subject, to_subject, metadata, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');++ const live = [++ [1, 2, 1, 100, 'donation', 'completed', true], [2, 2, 1, 500, 'donation', 'completed'], [3, 3, 1, 200, 'donation', 'completed'],++ [4, 2, 4, 300, 'donation', 'completed'], [5, 2, 1, 50, 'donation', 'failed'], [6, 2, 1, 250, 'donation', 'completed'],++ [7, 1, 2, 250, 'refund', 'completed'], [8, null, 5, 1000, 'donation', 'completed'], [9, null, 2, 1000, 'purchase', 'completed'],++ ];++ for (const [id, from, to, amount, type, status, test] of live) {++ const note = status === 'completed' ? null : `status ${status}: no balance moved`;++ ins.run(`txn_import_${id}`, 'import', 'imported', `import:live:txn:${id}`, null, test ? 1 : 0, from ? subjects[from] || null : null, to ? subjects[to] || null : null,++ JSON.stringify({ live: { id, type, status, amount, from_user_id: from, to_user_id: to }, note }), '2026-09-22T00:00:00Z');++ }++ if (withNative) {++ ins.run('txn_native_1', 'donation', 'settled', 'api:svc:live:k1', null, 0, subjects[2], subjects[1], JSON.stringify({ kind: 'donation', amount_bits: 40 }), '2026-09-22T01:00:00Z');++ ins.run('txn_native_test', 'donation', 'settled', 'api:svc:live:k2', null, 1, subjects[2], subjects[1], JSON.stringify({ kind: 'donation', amount_bits: 999 }), '2026-09-22T01:00:00Z');++ }++ return db;++}++++(async () => {++ const t = await boot();++ const { domain } = t;++ const { importLive, billingSnapshotSource } = require('../server/importer/live');++ const { createIdentity } = require('../server/network');++ const identity = createIdentity(t.config);++ const users = { 1: t.network.newUser('alex', 1), 2: t.network.newUser('viewer', 2), 5: t.network.newUser('bob', 5) };++ const subjects = { 1: users[1].subject, 2: users[2].subject, 5: users[5].subject };++ const live = liveFixture(path.join(t.dir, 'live-snapshot.db'));++ const bdb = billingFixture(path.join(t.dir, 'billing-snapshot.db'), subjects, { withNative: true });++ const quiet = { log() {} };++ const run = (opts = {}) => importLive(domain, { live, billingSource: billingSnapshotSource(bdb), resolveLiveUsers: identity.resolveLiveUsers, log: quiet, ...opts });++++ await check('a dry run reports and keeps nothing', async () => {++ const r = await run({ dryRun: true });++ assert.strictEqual(r.dry_run, true);++ assert.ok(r.counts.imported > 0);++ assert.strictEqual(t.db.prepare('SELECT COUNT(*) AS n FROM tip_interactions').get().n, 0);++ assert.strictEqual(t.db.prepare('SELECT COUNT(*) AS n FROM migration_maps').get().n, 0);++ });++++ let report;++ await check('donations import settled and linked to Billing; failed excluded; unmapped creators held; test era flagged', async () => {++ report = await run();++ const byLegacy = (id) => t.db.prepare('SELECT * FROM tip_interactions WHERE legacy_source = ?').get(`live:transactions:${id}`);++ const two = byLegacy(2);++ assert.strictEqual(two.payment_state, 'settled');++ assert.strictEqual(two.settlement, 'imported');++ assert.strictEqual(two.billing_txn_id, 'txn_import_2');++ assert.strictEqual(two.supporter_subject, subjects[2]);++ assert.strictEqual(two.message, 'nice');++ assert.strictEqual(two.created_at, '2026-02-01T10:00:00.000Z');++ assert.strictEqual(byLegacy(1).test, 1, 'before stats_vibes_reset_at');++ const ghost = byLegacy(3);++ assert.strictEqual(ghost.supporter_subject, null);++ assert.strictEqual(ghost.supporter_name, 'Ghost');++ assert.strictEqual(byLegacy(6).kind, 'media_request');++ assert.strictEqual(byLegacy(8).funding, 'provider');++ assert.strictEqual(byLegacy(4), undefined, 'held, not dropped');++ const held = t.db.prepare("SELECT * FROM migration_maps WHERE source_table = 'transactions' AND source_id = '4'").get();++ assert.strictEqual(held.status, 'held');++ const excluded = t.db.prepare("SELECT * FROM migration_maps WHERE source_table = 'transactions' AND source_id = '5'").get();++ assert.strictEqual(excluded.status, 'excluded');++ assert.match(excluded.reason, /status failed/);++ assert.strictEqual(report.counts.linked_to_billing, report.counts.imported);++ // Imports are history: no effects, no overlay alerts, no events.++ assert.strictEqual(t.db.prepare('SELECT COUNT(*) AS n FROM interaction_effects').get().n, 0);++ assert.strictEqual(t.db.prepare('SELECT COUNT(*) AS n FROM overlay_deliveries').get().n, 0);++ assert.strictEqual(t.outboxRows('tips.interaction.ready').length, 0);++ });++++ await check('the refund nets out against the donation it undoes', async () => {++ const six = t.db.prepare("SELECT * FROM tip_interactions WHERE legacy_source = 'live:transactions:6'").get();++ assert.strictEqual(six.reversed_bits, 250);++ assert.strictEqual(six.payment_state, 'reversed');++ assert.strictEqual(report.counts.refunds_applied, 1);++ });++++ await check('external PowerChat tips: the direct one is imported as EXTERNAL; the site-routed celebration is not counted twice', async () => {++ const ext = t.db.prepare("SELECT * FROM tip_interactions WHERE legacy_source = 'live:chat_messages:2'").get();++ assert.strictEqual(ext.settlement, 'external');++ assert.strictEqual(ext.amount, 700);++ assert.strictEqual(ext.supporter_name, 'Direct');++ const twin = t.db.prepare("SELECT * FROM migration_maps WHERE source_table = 'chat_messages' AND source_id = '1'").get();++ assert.strictEqual(twin.status, 'excluded');++ assert.match(twin.reason, /site-routed tip \(Live transaction 8\)/);++ assert.strictEqual(t.db.prepare("SELECT COUNT(*) AS n FROM migration_maps WHERE source_table = 'chat_messages' AND source_id = '3'").get().n, 0, 'on-site donation lines are not PowerChat');++ });++++ await check('goals import with Live\'s current amount carried over (not invented contributions)', async () => {++ const g = t.db.prepare("SELECT * FROM tip_goals WHERE legacy_source = 'live:donation_goals:1'").get();++ const view = domain.goals.present(g);++ assert.strictEqual(view.current_amount, 1200);++ assert.strictEqual(view.carried_over_amount, 1200);++ assert.strictEqual(view.supporters_count, 0);++ });++++ await check('reconciliation: a Billing donation Tips never saw is reported, not absorbed', async () => {++ const r = report.reconciliation;++ assert.strictEqual(r.ok, false);++ assert.deepStrictEqual(r.mismatches, [{ creator: subjects[1], tips: 700, billing: 740, difference: -40 }]);++ assert.ok(r.rows.find((x) => x.creator === subjects[5] && x.tips === 1000 && x.billing === 1000));++ });++++ await check('once the Billing event arrives, creator totals equal Billing exactly (test money excluded on both sides)', async () => {++ const ev = t.billing.envelope('billing.transaction.settled', { transaction_id: 'txn_native_1', type: 'donation', test: false, from_subject: subjects[2], to_subject: subjects[1], provider: null, metadata: { kind: 'donation', amount_bits: 40 } }, 'txn_native_1');++ assert.strictEqual((await t.deliver(ev)).json.outcome, 'recorded');++ const evTest = t.billing.envelope('billing.transaction.settled', { transaction_id: 'txn_native_test', type: 'donation', test: true, from_subject: subjects[2], to_subject: subjects[1], provider: null, metadata: { kind: 'donation', amount_bits: 999 } }, 'txn_native_test');++ await t.deliver(evTest);++ const { reconcile } = require('../server/importer/live');++ const r = reconcile(domain, billingSnapshotSource(bdb));++ assert.strictEqual(r.ok, true, JSON.stringify(r.mismatches));++ assert.strictEqual(domain.interactions.totals(subjects[1]).settled_via_billing, 740);++ assert.strictEqual(domain.interactions.totals(subjects[1]).external, 700);++ });++++ await check('a re-run changes nothing; a newly mapped creator is released from hold', async () => {++ const before = t.db.prepare('SELECT COUNT(*) AS n FROM tip_interactions').get().n;++ const again = await run();++ assert.strictEqual(again.counts.imported, 0);++ assert.strictEqual(again.counts.goals, 0);++ assert.ok(again.counts.unchanged > 0);++ assert.strictEqual(t.db.prepare('SELECT COUNT(*) AS n FROM tip_interactions').get().n, before);++ const heldUser = t.network.newUser('held', 4);++ const r = await run();++ assert.strictEqual(r.counts.released, 1);++ assert.strictEqual(r.counts.goals, 1);++ const four = t.db.prepare("SELECT * FROM tip_interactions WHERE legacy_source = 'live:transactions:4'").get();++ assert.strictEqual(four.creator_subject, heldUser.subject);++ assert.strictEqual(four.billing_txn_id, 'txn_import_4');++ assert.strictEqual(t.db.prepare("SELECT status FROM migration_maps WHERE source_table = 'transactions' AND source_id = '4'").get().status, 'imported');++ });++++ await check('every source row is imported, excluded with a reason, or held — none silently dropped', async () => {++ const tipRows = live.prepare("SELECT id FROM transactions WHERE type IN ('donation', 'refund')").all().map((x) => String(x.id));++ const mapped = new Set(t.db.prepare("SELECT source_id FROM migration_maps WHERE source_table = 'transactions'").all().map((x) => x.source_id));++ assert.deepStrictEqual(tipRows.filter((id) => !mapped.has(id)), []);++ const excluded = t.db.prepare("SELECT * FROM migration_maps WHERE status = 'excluded'").all();++ assert.ok(excluded.every((x) => x.reason));++ });++++ live.close(); bdb.close();++ await t.close();++ done();++})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,178 @@++'use strict';++// Overlays: scoped revocable tokens, SSE delivery, replay that never charges or re-counts, the++// delivery window, and simulations that are never counted.++const assert = require('assert');++const { boot, check, done } = require('./helpers/app');++++(async () => {++ const t = await boot();++ const { domain, billing } = t;++ const alex = await t.creator('alex');++ const viewer = t.network.newUser('viewer');++ billing.fund(viewer.subject, 10_000);++ const goal = (await t.call('POST', '/api/v1/goals', { user: alex, body: { title: 'Desk', target_amount: 1000 } })).json.goal;++++ let secret;++ let tokenId;++ await check('an overlay token is shown once, stored hashed, and scoped', async () => {++ const r = await t.call('POST', '/api/v1/overlay-tokens', { user: alex, body: { label: 'OBS', scopes: ['alerts', 'goals'] } });++ assert.strictEqual(r.status, 201, r.text);++ secret = r.json.secret;++ tokenId = r.json.token.id;++ assert.match(secret, /^tovl_[A-Za-z0-9_-]{43}$/);++ assert.strictEqual(r.json.overlay_url, `http://tips.test/overlay/${secret}`);++ const row = t.db.prepare('SELECT * FROM overlay_tokens WHERE id = ?').get(tokenId);++ assert.notStrictEqual(row.token_hash, secret);++ assert.ok(!JSON.stringify(row).includes(secret), 'the secret is not stored');++ const list = await t.call('GET', '/api/v1/overlay-tokens', { user: alex });++ assert.ok(!list.text.includes(secret), 'listing never shows the secret');++ const other = t.network.newUser('mallory');++ const denied = await t.call('POST', '/api/v1/overlay-tokens', { user: other, body: { creator: 'alex' } });++ assert.strictEqual(denied.status, 403);++ const bad = await t.sse('/overlay/tovl_not-a-real-token-at-all-000000000000000000/events');++ assert.strictEqual(bad.statusCode, 404);++ });++++ let firstAlertSeq;++ await check('a settled tip reaches a connected overlay once (tips.overlay.delivered once)', async () => {++ const s = await t.sse(`/overlay/${secret}/events`);++ assert.strictEqual(s.statusCode, 200);++ const hello = await s.waitFor((e) => e.event === 'hello');++ assert.strictEqual(hello.data.goals[0].id, goal.id);++ const r = await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: 'alex', amount: 300, message: 'hi' } });++ const alert = await s.waitFor((e) => e.event === 'alert');++ assert.strictEqual(alert.data.interaction_id, r.json.interaction.id);++ assert.strictEqual(alert.data.amount, 300);++ assert.strictEqual(alert.data.test, false);++ const g = await s.waitFor((e) => e.event === 'goal' && e.data.goal.current_amount === 300);++ assert.strictEqual(g.data.goal.id, goal.id);++ firstAlertSeq = alert.id;++ s.close();++ const d = t.db.prepare("SELECT * FROM overlay_deliveries WHERE kind = 'alert' AND interaction_id = ?").get(r.json.interaction.id);++ assert.strictEqual(d.status, 'delivered');++ assert.strictEqual(t.outboxRows('tips.overlay.delivered').filter((e) => e.payload.delivery_id === d.id).length, 1);++ });++++ await check('overlay replay (Last-Event-ID) resends but never charges, re-counts or re-emits', async () => {++ const before = {++ transfers: billing.transfers().length, contributions: t.db.prepare('SELECT COUNT(*) AS n FROM tip_goal_contributions').get().n,++ goal: domain.goals.present(domain.goals.get(goal.id)).current_amount, delivered: t.outboxRows('tips.overlay.delivered').length,++ interactions: t.db.prepare('SELECT COUNT(*) AS n FROM tip_interactions').get().n, payable: billing.payable.get(alex.subject),++ };++ for (let k = 0; k < 3; k++) {++ const s = await t.sse(`/overlay/${secret}/events`, { lastEventId: firstAlertSeq - 1 });++ const again = await s.waitFor((e) => e.event === 'alert');++ assert.strictEqual(again.id, firstAlertSeq);++ s.close();++ }++ assert.strictEqual(billing.transfers().length, before.transfers);++ assert.strictEqual(t.db.prepare('SELECT COUNT(*) AS n FROM tip_goal_contributions').get().n, before.contributions);++ assert.strictEqual(domain.goals.present(domain.goals.get(goal.id)).current_amount, before.goal);++ assert.strictEqual(t.outboxRows('tips.overlay.delivered').length, before.delivered);++ assert.strictEqual(t.db.prepare('SELECT COUNT(*) AS n FROM tip_interactions').get().n, before.interactions);++ assert.strictEqual(billing.payable.get(alex.subject), before.payable);++ assert.ok(t.db.prepare('SELECT sends FROM overlay_deliveries WHERE seq = ?').get(firstAlertSeq).sends >= 4);++ });++++ await check('a tip while no overlay is connected waits, is delivered on connect, else fails after the window', async () => {++ const r1 = await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: 'alex', amount: 11 } });++ const s = await t.sse(`/overlay/${secret}/events`);++ const late = await s.waitFor((e) => e.event === 'alert' && e.data.interaction_id === r1.json.interaction.id);++ assert.ok(late);++ s.close();++ await new Promise((ok) => setTimeout(ok, 50));++ const r2 = await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: 'alex', amount: 12 } });++ t.clock.offset += 11 * 60 * 1000;++ const n = domain.overlays.sweepFailed();++ t.clock.offset = 0;++ assert.ok(n >= 1);++ const d = t.db.prepare("SELECT * FROM overlay_deliveries WHERE kind = 'alert' AND interaction_id = ?").get(r2.json.interaction.id);++ assert.strictEqual(d.status, 'failed');++ assert.strictEqual(t.outboxRows('tips.overlay.failed').filter((e) => e.payload.delivery_id === d.id).length, 1);++ // The payment is untouched by the overlay failure.++ assert.strictEqual(domain.interactions.get(r2.json.interaction.id).payment_state, 'settled');++ });++++ await check('simulation runs the full path with test = 1, no Billing call, never counted', async () => {++ const before = {++ transfers: billing.calls.length, goal: domain.goals.present(domain.goals.get(goal.id)).current_amount,++ totals: domain.interactions.totals(alex.subject), events: t.outboxRows().length,++ };++ const s = await t.sse(`/overlay/${secret}/events`);++ await s.waitFor((e) => e.event === 'hello');++ const r = await t.call('POST', '/api/v1/simulate', { user: alex, body: { kind: 'paid_message', amount: 500, message: 'test run', supporter_name: 'Tester' } });++ assert.strictEqual(r.status, 201, r.text);++ const i = r.json.interaction;++ assert.strictEqual(i.test, true);++ assert.strictEqual(i.settlement, 'simulated');++ const alert = await s.waitFor((e) => e.event === 'alert' && e.data.interaction_id === i.id);++ assert.strictEqual(alert.data.test, true);++ const g = await s.waitFor((e) => e.event === 'goal' && e.data.test === true);++ assert.strictEqual(g.data.goal.current_amount, before.goal + 500, 'the widget shows the would-be total');++ s.close();++ await domain.effects.drain();++ const job = t.adapters.test.jobs.find((j) => j.interaction.id === i.id);++ assert.ok(job && job.test === true, 'chat effect delivered to the test adapter, flagged test');++ assert.strictEqual(billing.calls.length, before.transfers, 'no Billing call');++ assert.strictEqual(domain.goals.present(domain.goals.get(goal.id)).current_amount, before.goal, 'goal unchanged');++ assert.strictEqual(t.db.prepare('SELECT COUNT(*) AS n FROM tip_goal_contributions WHERE interaction_id = ?').get(i.id).n, 0);++ const after = domain.interactions.totals(alex.subject);++ assert.strictEqual(after.settled_via_billing, before.totals.settled_via_billing);++ assert.strictEqual(after.interactions, before.totals.interactions);++ assert.strictEqual(after.simulations, before.totals.simulations + 1);++ assert.strictEqual(t.outboxRows().length, before.events, 'no durable events for a simulation');++ const denied = await t.call('POST', '/api/v1/simulate', { user: viewer, body: { creator: 'alex' } });++ assert.strictEqual(denied.status, 403);++ });++++ await check('a config change reaches open overlays; minimum alert amount filters small tips', async () => {++ const cfg = (await t.call('POST', '/api/v1/overlay-configs', { user: alex, body: { kind: 'alerts', name: 'Big only', min_amount: 1000 } })).json.config;++ const tok = (await t.call('POST', '/api/v1/overlay-tokens', { user: alex, body: { scopes: ['alerts'], config_id: cfg.id } })).json;++ const s = await t.sse(`/overlay/${tok.secret}/events`);++ const hello = await s.waitFor((e) => e.event === 'hello');++ assert.strictEqual(hello.data.config.settings.min_amount, 1000);++ assert.deepStrictEqual(hello.data.goals, [], 'no goals scope, no goals');++ await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: 'alex', amount: 5 } });++ const big = await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: 'alex', amount: 1500 } });++ const a = await s.waitFor((e) => e.event === 'alert');++ assert.strictEqual(a.data.interaction_id, big.json.interaction.id, 'the small tip was filtered');++ assert.ok(!s.events.some((e) => e.event === 'goal'));++ await t.call('PATCH', `/api/v1/overlay-configs/${cfg.id}`, { user: alex, body: { duration_ms: 3000 } });++ const c = await s.waitFor((e) => e.event === 'config');++ assert.strictEqual(c.data.config.settings.duration_ms, 3000);++ s.close();++ });++++ await check('revocation is immediate: the open stream is closed and the link stops working', async () => {++ const s = await t.sse(`/overlay/${secret}/events`);++ await s.waitFor((e) => e.event === 'hello');++ const r = await t.call('POST', `/api/v1/overlay-tokens/${tokenId}/revoke`, { user: alex });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json.token.active, false);++ const rev = await s.waitFor((e) => e.event === 'revoked');++ assert.ok(rev);++ await new Promise((ok) => setTimeout(ok, 50));++ assert.strictEqual(s.ended, true, 'stream closed by the server');++ const again = await t.sse(`/overlay/${secret}/events`);++ assert.strictEqual(again.statusCode, 404);++ const page = await fetch(`${t.base}/overlay/${secret}`);++ assert.strictEqual(page.status, 404);++ const state = await fetch(`${t.base}/overlay/${secret}/state`);++ assert.strictEqual(state.status, 404);++ });++++ await check('the overlay page and state need only the token (no cookie) and are never indexed or cached', async () => {++ const tok = (await t.call('POST', '/api/v1/overlay-tokens', { user: alex, body: { scopes: ['goals'] } })).json;++ const page = await fetch(`${t.base}/overlay/${tok.secret}`);++ assert.strictEqual(page.status, 200);++ assert.strictEqual(page.headers.get('cache-control'), 'no-store');++ assert.match(page.headers.get('x-robots-tag'), /noindex/);++ assert.strictEqual(page.headers.get('referrer-policy'), 'no-referrer');++ const state = await (await fetch(`${t.base}/overlay/${tok.secret}/state`)).json();++ assert.strictEqual(state.goals[0].id, goal.id);++ assert.strictEqual(state.alerts, undefined, 'goals-only token sees no alerts');++ });++++ await t.close();++ done();++})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,159 @@++'use strict';++// Server-rendered pages: useful without JavaScript, indexable only when the creator switched the++// page on, forms protected against cross-site posts, and the copy rules.++const assert = require('assert');++const { boot, check, done } = require('./helpers/app');++++(async () => {++ const t = await boot();++ const { billing, domain } = t;++ const alex = await t.creator('alex', { settings: { headline: 'Late-night synth streams', tts_enabled: true } });++ const bob = await t.creator('bob', { settings: { page_enabled: false } });++ const viewer = t.network.newUser('viewer');++ billing.fund(viewer.subject, 1000);++ await t.call('POST', '/api/v1/goals', { user: alex, body: { title: 'New synth', target_amount: 5000 } });++++ const cookie = (u) => `ov_token=${t.network.signUser(u)}`;++ const get = async (p, u) => { const r = await fetch(t.base + p, { headers: u ? { Cookie: cookie(u) } : {}, redirect: 'manual' }); return { status: r.status, headers: r.headers, text: await r.text() }; };++ const post = async (p, u, form) => {++ const r = await fetch(t.base + p, { method: 'POST', redirect: 'manual', headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...(u ? { Cookie: cookie(u) } : {}) }, body: new URLSearchParams(form).toString() });++ return { status: r.status, headers: r.headers, text: await r.text() };++ };++ const field = (html, name) => { const m = html.match(new RegExp(`name="${name}" value="([^"]*)"`)); return m ? m[1] : null; };++ const COPY = /\bfree\b|\$0\b|no ads/i;++++ await check('home: server-rendered with the shared chrome, a noscript nav and the release meta', async () => {++ const r = await get('/');++ assert.strictEqual(r.status, 200);++ assert.match(r.text, /<noscript><nav/);++ assert.match(r.text, /https:\/\/openvibe\.network\/shared\/navbar\.js/);++ assert.match(r.text, /<meta name="ov-release"/);++ assert.match(r.text, /id="ov-footer"|class="ovf/);++ assert.match(r.text, /href="\/alex"/);++ assert.ok(!r.text.includes('href="/bob"'), 'switched-off pages are not listed');++ });++++ await check('creator page: indexable when on, goals and a no-JS tip form', async () => {++ const r = await get('/alex');++ assert.strictEqual(r.status, 200);++ assert.match(r.text, /<meta name="robots" content="index,follow">/);++ assert.match(r.text, /<link rel="canonical" href="http:\/\/tips\.test\/alex">/);++ assert.match(r.text, /New synth/);++ assert.match(r.text, /<form class="card tip-form" method="post" action="\/alex\/tip">/);++ assert.match(r.text, /Sign in to send/);++ assert.match(r.text, /Late-night synth streams/);++ assert.match(r.text, /"@type":"ProfilePage"/);++ assert.match((await get('/@alex')).headers.get('location') || '', /\/alex$/);++ });++++ await check('a switched-off page: 404 + noindex for everyone else; a noindex preview for its creator', async () => {++ const anon = await get('/bob');++ assert.strictEqual(anon.status, 404);++ assert.match(anon.text, /noindex/);++ const other = await get('/bob', viewer);++ assert.strictEqual(other.status, 404);++ const own = await get('/bob', bob);++ assert.strictEqual(own.status, 200);++ assert.match(own.text, /<meta name="robots" content="noindex,nofollow">/);++ assert.match(own.headers.get('x-robots-tag'), /noindex/);++ assert.match(own.text, /Your page is switched off/);++ });++++ await check('robots.txt and sitemap.xml list only switched-on pages', async () => {++ const robots = await get('/robots.txt');++ assert.match(robots.text, /Disallow: \/dashboard/);++ assert.match(robots.text, /Disallow: \/overlay\//);++ const sm = await get('/sitemap.xml');++ assert.match(sm.text, /<loc>http:\/\/tips\.test\/alex<\/loc>/);++ assert.ok(!sm.text.includes('/bob<'));++ });++++ await check('tipping without JavaScript: sign-in first, then a form post settles and lands on the receipt', async () => {++ const anon = await post('/alex/tip', null, { amount: '10' });++ assert.strictEqual(anon.status, 303);++ assert.match(anon.headers.get('location'), /^\/auth\/login\?next=%2Falex/);++ const page = await get('/alex', viewer);++ const csrf = field(page.text, 'csrf');++ const idem = field(page.text, 'idem');++ assert.ok(csrf && idem);++ const form = { csrf, idem, kind: 'tip', amount: '25', message: 'no js needed', pay_with: 'credit', supporter_name: 'Viewer' };++ const r = await post('/alex/tip', viewer, form);++ assert.strictEqual(r.status, 303, r.text.slice(0, 300));++ const loc = r.headers.get('location');++ assert.match(loc, /^\/receipts\/tint_/);++ const id = loc.split('/').pop();++ assert.strictEqual(domain.interactions.get(id).payment_state, 'settled');++ // Double submit (same form nonce): the same interaction, no second transfer.++ const before = billing.transfers().length;++ const again = await post('/alex/tip', viewer, form);++ assert.strictEqual(again.headers.get('location'), loc);++ assert.strictEqual(billing.transfers().length, before);++ const receipt = await get(loc, viewer);++ assert.strictEqual(receipt.status, 200);++ assert.match(receipt.text, /Paid/);++ assert.match(receipt.text, /no js needed/);++ assert.match(receipt.text, /noindex/);++ assert.strictEqual((await get(loc, bob)).status, 404, 'someone else\'s receipt');++ const list = await get('/receipts', viewer);++ assert.match(list.text, new RegExp(id));++ });++++ await check('a forged cross-site form (no valid token) is refused', async () => {++ const r = await post('/alex/tip', viewer, { csrf: 'forged', idem: 'form-abcdefghijkl', amount: '10' });++ assert.strictEqual(r.status, 403);++ const other = await get('/alex', bob);++ const r2 = await post('/alex/tip', viewer, { csrf: field(other.text, 'csrf'), idem: 'form-abcdefghijkm', amount: '10' });++ assert.strictEqual(r2.status, 403, 'a token minted for someone else does not work');++ const d = await post('/dashboard/profile', alex, { csrf: 'nope', page_enabled: '0' });++ assert.strictEqual(d.status, 403);++ assert.strictEqual(domain.profiles.byHandle('alex').page_enabled, true);++ });++++ await check('the form reports Billing\'s refusal in words; checkout redirects to the provider', async () => {++ const page = await get('/alex', viewer);++ const low = await post('/alex/tip', viewer, { csrf: field(page.text, 'csrf'), idem: 'form-low-balance-1', amount: '99999', pay_with: 'credit' });++ assert.strictEqual(low.status, 409);++ assert.match(low.text, /balance is too low/);++ const co = await post('/alex/tip', viewer, { csrf: field(page.text, 'csrf'), idem: 'form-checkout-0001', amount: '200', pay_with: 'checkout', provider: 'stripe' });++ assert.strictEqual(co.status, 303);++ assert.match(co.headers.get('location'), /^https:\/\/checkout\.stripe\.test\//);++ });++++ await check('dashboard: sign-in, settings, goals, a show-once overlay link, a simulation', async () => {++ assert.match((await get('/dashboard')).headers.get('location'), /^\/auth\/login/);++ const d = await get('/dashboard', alex);++ assert.strictEqual(d.status, 200);++ assert.match(d.text, /Creator dashboard/);++ assert.match(d.text, /noindex/);++ const csrf = field(d.text, 'csrf');++ const tok = await post('/dashboard/overlay-tokens', alex, { csrf, idem: 'x', label: 'Main', scope_alerts: '1', scope_goals: '1' });++ assert.strictEqual(tok.status, 201);++ assert.strictEqual(tok.headers.get('cache-control'), 'no-store');++ const url = tok.text.match(/value="(http:\/\/tips\.test\/overlay\/tovl_[^"]+)"/)[1];++ const secret = url.split('/').pop();++ assert.strictEqual((await fetch(`${t.base}/overlay/${secret}`)).status, 200);++ const d2 = await get('/dashboard', alex);++ assert.ok(!d2.text.includes(secret), 'the dashboard never shows it again');++ const g = await post('/dashboard/goals', alex, { csrf, idem: 'x', title: 'Second goal', target_amount: '300' });++ assert.strictEqual(g.status, 303);++ const sim = await post('/dashboard/simulate', alex, { csrf, idem: 'x', kind: 'tip', amount: '100', supporter_name: 'Test supporter', message: 'test' });++ assert.strictEqual(sim.status, 303);++ assert.strictEqual(t.db.prepare("SELECT COUNT(*) AS n FROM tip_interactions WHERE creator_subject = ? AND test = 1").get(alex.subject).n, 1);++ const off = await post('/dashboard/profile', alex, { csrf, idem: 'x', accepting: '1', revision: String(domain.profiles.byHandle('alex').revision) });++ assert.strictEqual(off.status, 303);++ assert.strictEqual(domain.profiles.byHandle('alex').page_enabled, false, 'an unchecked box switches the page off');++ assert.strictEqual((await get('/alex')).status, 404);++ });++++ await check('copy rules: no pricing claims ("free", "$0", "no ads") on any page', async () => {++ await t.call('PATCH', '/api/v1/profiles/me', { user: alex, body: { page_enabled: true } });++ for (const [p, u] of [['/', null], ['/alex', viewer], ['/alex/goals', null], ['/dashboard', alex], ['/receipts', viewer]]) {++ const r = await get(p, u);++ const visible = r.text.replace(/<style[\s\S]*?<\/style>/g, '').replace(/<script[\s\S]*?<\/script>/g, '');++ assert.ok(!COPY.test(visible), `${p} has forbidden copy: ${(visible.match(COPY) || [])[0]}`);++ }++ });++++ await t.close();++ done();++})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,57 @@++#!/usr/bin/env node++/**++ * Runs every test in test/ — the files named *.test.js — each in its own process, and fails++ * if any of them fails. They use in-process stubs of OpenVibe.Network, OpenVibe.Billing,++ * OpenVibe.Events and OpenVibe.Live, temp databases and random ports; none needs the network or a++ * running service.++ *++ * npm test # everything++ * npm test -- overlays import # only files whose name contains one of the words++ */++'use strict';++const { spawn } = require('child_process');++const fs = require('fs');++const path = require('path');++++const filters = process.argv.slice(2).filter((a) => !a.startsWith('-'));++const files = fs.readdirSync(__dirname)++ .filter((f) => f.endsWith('.test.js'))++ .filter((f) => !filters.length || filters.some((w) => f.includes(w)))++ .sort();++const TIMEOUT_MS = 60000;++++function runOne(file) {++ return new Promise((resolve) => {++ const started = Date.now();++ const child = spawn(process.execPath, [path.join(__dirname, file)], {++ cwd: path.join(__dirname, '..'),++ env: { ...process.env, NODE_ENV: 'test' },++ stdio: ['ignore', 'pipe', 'pipe'],++ });++ let output = '';++ child.stdout.on('data', (c) => { output += c; });++ child.stderr.on('data', (c) => { output += c; });++ const timer = setTimeout(() => { output += `\n[run] timed out after ${TIMEOUT_MS}ms`; child.kill('SIGKILL'); }, TIMEOUT_MS);++ child.on('close', (code, signal) => {++ clearTimeout(timer);++ resolve({ file, ok: code === 0, code: code ?? signal, ms: Date.now() - started, output });++ });++ });++}++++(async () => {++ if (!files.length) { console.error('no test files matched'); process.exit(1); }++ const results = [];++ for (const f of files) {++ const r = await runOne(f);++ results.push(r);++ console.log(`${r.ok ? '✓' : '✗'} ${r.file.padEnd(28)} ${String(r.ms).padStart(6)}ms`);++ }++ const failed = results.filter((r) => !r.ok);++ for (const r of failed) {++ console.log(`\n── ${r.file} (exit ${r.code}) ──`);++ console.log(r.output.split('\n').slice(-60).join('\n'));++ }++ console.log(`\n${results.length - failed.length}/${results.length} test files passed`);++ process.exit(failed.length ? 1 : 0);++})();nnew file mode 100644@@@ -0,0 +1,262 @@++'use strict';++// Payment settles through Billing; delivery is separate. Duplicate Billing events and provider++// redeliveries yield one logical interaction; reversals flip payment state without erasing delivery.++const assert = require('assert');++const { boot, check, done } = require('./helpers/app');++++(async () => {++ const t = await boot();++ const { domain, billing } = t;++ const alex = await t.creator('alex');++ const viewer = t.network.newUser('viewer');++ billing.fund(viewer.subject, 5000);++++ let tipId;++ await check('a tip from credit settles through a Billing transfer (target = the interaction)', async () => {++ const r = await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: 'alex', amount: 250, message: 'great stream' } });++ assert.strictEqual(r.status, 201, r.text);++ const i = r.json.interaction;++ tipId = i.id;++ assert.strictEqual(i.payment.state, 'settled');++ assert.strictEqual(i.delivery.state, 'queued');++ assert.match(i.payment.billing_txn_id, /^txn_/);++ const call = billing.transfers()[0];++ assert.strictEqual(call.key, `tips:transfer:${i.id}`);++ assert.deepStrictEqual(call.body.target, { service: 'tips', type: 'interaction', id: i.id });++ assert.strictEqual(call.body.kind, 'tip');++ assert.strictEqual(billing.payable.get(alex.subject), 250);++ });++++ await check('the settled event for the same transaction is a no-op; a redelivery is an inbox duplicate', async () => {++ const ev = billing.events.find((e) => e.payload.metadata && e.payload.metadata.target && e.payload.metadata.target.id === tipId);++ const a = await t.deliver(ev);++ assert.strictEqual(a.status, 200);++ assert.strictEqual(a.json.duplicate, false);++ assert.strictEqual(a.json.outcome, 'duplicate_transaction');++ const b = await t.deliver(ev);++ assert.strictEqual(b.json.duplicate, true);++ // Same transaction republished under a new event id (a replay): still one interaction.++ const c = await t.deliver({ ...ev, event_id: billing.envelope('x', {}, 'x').event_id });++ assert.strictEqual(c.json.duplicate, false);++ assert.strictEqual(t.db.prepare('SELECT COUNT(*) AS n FROM tip_interactions WHERE billing_txn_id = ?').get(ev.payload.transaction_id).n, 1);++ assert.strictEqual(t.db.prepare('SELECT COUNT(*) AS n FROM tip_interactions').get().n, 1);++ });++++ await check('a duplicate provider webhook (Billing settles once) → one logical interaction for a donation Tips did not start', async () => {++ // Billing turned one PowerChat site-routed tip into one transaction; Events may deliver it more than once.++ const ev = billing.foreignDonation({ from: null, to: alex.subject, amount: 300, provider: 'powerchat', message: 'from PowerChat' });++ for (let k = 0; k < 3; k++) await t.deliver(k === 2 ? { ...ev, event_id: billing.envelope('x', {}, 'x').event_id } : ev);++ const rows = t.db.prepare('SELECT * FROM tip_interactions WHERE billing_txn_id = ?').all(ev.payload.transaction_id);++ assert.strictEqual(rows.length, 1);++ assert.strictEqual(rows[0].origin, 'billing');++ assert.strictEqual(rows[0].funding, 'provider');++ assert.strictEqual(rows[0].payment_state, 'settled');++ // Tips did not start it, so Tips does not announce it in chat a second time — overlay only.++ const effects = t.db.prepare('SELECT effect FROM interaction_effects WHERE interaction_id = ?').all(rows[0].id).map((e) => e.effect);++ assert.deepStrictEqual(effects, ['overlay_alert']);++ assert.strictEqual(rows[0].delivery_state, 'delivered');++ });++++ await check('delivery runs after settlement; a delivery failure never touches payment state', async () => {++ const test = t.adapters.test;++ await domain.effects.drain(); // the first tip delivers normally++ assert.strictEqual(domain.interactions.get(tipId).delivery_state, 'delivered');++ test.state.failWith = 'chat is down';++ test.state.permanent = true;++ const r = await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: alex.subject, amount: 40 } });++ const id = r.json.interaction.id;++ await domain.effects.drain();++ const i = domain.interactions.get(id);++ assert.strictEqual(i.payment_state, 'settled');++ assert.strictEqual(i.delivery_state, 'failed');++ assert.strictEqual(t.outboxRows('tips.interaction.failed').filter((e) => e.subject.id === id).length, 1);++ test.state.failWith = null;++ assert.strictEqual(test.jobs.find((j) => j.interaction.id === tipId).text, 'Viewer tipped 250 Vibes: great stream');++ });++++ await check('a retryable delivery failure stays queued and is retried with backoff', async () => {++ const test = t.adapters.test;++ test.state.failWith = 'timeout'; test.state.permanent = false;++ const r = await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: alex.subject, amount: 10 } });++ const id = r.json.interaction.id;++ await domain.effects.drain();++ const e = t.db.prepare("SELECT * FROM interaction_effects WHERE interaction_id = ? AND effect = 'chat_line'").get(id);++ assert.strictEqual(e.state, 'queued');++ assert.strictEqual(e.attempts, 1);++ test.state.failWith = null;++ t.clock.offset += 60_000;++ await domain.effects.drain();++ assert.strictEqual(domain.interactions.get(id).delivery_state, 'delivered');++ t.clock.offset = 0;++ });++++ await check('reversal after delivery keeps the delivery record and flips payment state; goals drop by what Billing took back', async () => {++ const g = await t.call('POST', '/api/v1/goals', { user: alex, body: { title: 'New mic', target_amount: 1000 } });++ assert.strictEqual(g.status, 201, g.text);++ const r = await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: 'alex', amount: 400 } });++ const id = r.json.interaction.id;++ await domain.effects.drain();++ assert.strictEqual(domain.interactions.get(id).delivery_state, 'delivered');++ assert.strictEqual(domain.goals.present(domain.goals.get(g.json.goal.id)).current_amount, 400);++ const rev = billing.refund(r.json.interaction.payment.billing_txn_id);++ const d = await t.deliver(rev);++ assert.strictEqual(d.json.outcome, 'reversed');++ const i = domain.interactions.get(id);++ assert.strictEqual(i.payment_state, 'reversed');++ assert.strictEqual(i.delivery_state, 'delivered');++ assert.strictEqual(i.reversed_bits, 400);++ assert.ok(t.db.prepare("SELECT COUNT(*) AS n FROM interaction_effects WHERE interaction_id = ? AND state = 'delivered'").get(id).n >= 2);++ assert.strictEqual(domain.goals.present(domain.goals.get(g.json.goal.id)).current_amount, 0);++ // The same reversal again changes nothing.++ const again = await t.deliver({ ...rev, event_id: billing.envelope('x', {}, 'x').event_id });++ assert.strictEqual(again.json.outcome, 'duplicate_reversal');++ assert.strictEqual(domain.interactions.get(id).reversed_bits, 400);++ await t.call('POST', `/api/v1/goals/${g.json.goal.id}/close`, { user: alex });++ });++++ await check('reversal before delivery cancels the queued effects (tips.interaction.cancelled)', async () => {++ const r = await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: 'alex', amount: 20 } });++ const id = r.json.interaction.id;++ await t.deliver(billing.refund(r.json.interaction.payment.billing_txn_id));++ const i = domain.interactions.get(id);++ assert.strictEqual(i.payment_state, 'reversed');++ assert.strictEqual(i.delivery_state, 'cancelled');++ await domain.effects.drain();++ assert.ok(!t.adapters.test.jobs.some((j) => j.interaction.id === id), 'nothing delivered after the reversal');++ assert.strictEqual(t.outboxRows('tips.interaction.cancelled').filter((e) => e.subject.id === id).length, 1);++ });++++ await check('insufficient credit: payment failed, nothing delivered, a replay of the key answers the same', async () => {++ const poor = t.network.newUser('poor');++ const r = await t.call('POST', '/api/v1/checkout', { user: poor, body: { creator: 'alex', amount: 50 }, key: 'poor-key-00001' });++ assert.strictEqual(r.status, 409, r.text);++ assert.strictEqual(r.json.code, 'billing.insufficient_funds');++ assert.strictEqual(r.json.details.interaction.payment.state, 'failed');++ const again = await t.call('POST', '/api/v1/checkout', { user: poor, body: { creator: 'alex', amount: 50 }, key: 'poor-key-00001' });++ assert.strictEqual(again.status, 409);++ assert.strictEqual(t.db.prepare('SELECT COUNT(*) AS n FROM tip_interactions WHERE supporter_subject = ?').get(poor.subject).n, 1);++ });++++ await check('self-tipping is refused by subject', async () => {++ const r = await t.call('POST', '/api/v1/checkout', { user: alex, body: { creator: 'alex', amount: 50 } });++ assert.strictEqual(r.status, 422);++ assert.strictEqual(r.json.code, 'tips.self_dealing');++ });++++ await check('checkout: Billing intent → purchase settles → transfer → settled; goal counts only then', async () => {++ const g = await t.call('POST', '/api/v1/goals', { user: alex, body: { title: 'Camera', target_amount: 500 } });++ const buyer = t.network.newUser('buyer');++ const r = await t.call('POST', '/api/v1/checkout', { user: buyer, body: { creator: 'alex', amount: 300, pay_with: 'checkout', provider: 'stripe' } });++ assert.strictEqual(r.status, 201, r.text);++ assert.match(r.json.checkout_url, /^https:\/\/checkout\.stripe\.test\/pi_/);++ const id = r.json.interaction.id;++ assert.strictEqual(r.json.interaction.payment.state, 'pending');++ assert.strictEqual(domain.goals.present(domain.goals.get(g.json.goal.id)).current_amount, 0, 'pending never counts');++ const intentId = domain.interactions.get(id).billing_intent_id;++ assert.strictEqual(billing.calls.find((c) => c.url === '/api/v1/intents' && c.body.subject.id === buyer.subject).key, `tips:intent:${id}`);++ const purchase = billing.settlePurchase(intentId);++ const d = await t.deliver(purchase);++ assert.strictEqual(d.json.outcome, 'funded');++ await new Promise((ok) => setTimeout(ok, 100));++ await domain.interactions.processDueTransfers();++ const i = domain.interactions.get(id);++ assert.strictEqual(i.payment_state, 'settled');++ assert.strictEqual(i.funding_txn_id, purchase.payload.transaction_id);++ assert.strictEqual(domain.goals.present(domain.goals.get(g.json.goal.id)).current_amount, 300);++ // The purchase event again, and the transfer's own event: nothing new.++ assert.strictEqual((await t.deliver(purchase)).json.duplicate, true);++ const own = billing.events.find((e) => e.payload.metadata && e.payload.metadata.target && e.payload.metadata.target.id === id);++ assert.strictEqual((await t.deliver(own)).json.outcome, 'duplicate_transaction');++ assert.strictEqual(billing.transfers().filter((c) => c.key === `tips:transfer:${id}`).length, 1);++ });++++ await check('PowerChat checkout without a link template returns Billing\'s checkout_ref, no URL invented', async () => {++ const buyer = t.network.newUser('pcbuyer');++ const r = await t.call('POST', '/api/v1/checkout', { user: buyer, body: { creator: 'alex', amount: 150, pay_with: 'checkout', provider: 'powerchat' } });++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual(r.json.checkout_url, null);++ assert.match(r.json.checkout_ref, /^pcorder:pi_/);++ });++++ await check('Billing unreachable during a credit tip: pending, retried later with the same key, settles once', async () => {++ billing.state.down = true;++ const r = await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: 'alex', amount: 15 } });++ assert.strictEqual(r.status, 201);++ const id = r.json.interaction.id;++ assert.strictEqual(r.json.interaction.payment.state, 'pending');++ billing.state.down = false;++ t.clock.offset += 5000;++ await domain.interactions.processDueTransfers();++ t.clock.offset = 0;++ assert.strictEqual(domain.interactions.get(id).payment_state, 'settled');++ });++++ await check('paid messages, TTS and media requests exist only after settlement', async () => {++ await t.call('PATCH', '/api/v1/profiles/me', { user: alex, body: { tts_enabled: true, media_requests_enabled: true } });++ const buyer = t.network.newUser('supporter2');++ const kinds = [++ ['/api/v1/paid-messages', { amount: 500, message: 'hello chat' }],++ ['/api/v1/tts-requests', { amount: 150, tts: { text: 'read me please', voice: 'amy' } }],++ ['/api/v1/media-requests', { amount: 150, media: { url: 'https://www.youtube.com/watch?v=abc' } }],++ ];++ const ids = [];++ for (const [p, body] of kinds) {++ const r = await t.call('POST', p, { user: buyer, body: { creator: 'alex', pay_with: 'checkout', provider: 'stripe', ...body } });++ assert.strictEqual(r.status, 201, r.text);++ ids.push(r.json.interaction.id);++ }++ const count = () => t.db.prepare('SELECT (SELECT COUNT(*) FROM paid_messages WHERE interaction_id IN (?, ?, ?)) + (SELECT COUNT(*) FROM paid_media_requests WHERE interaction_id IN (?, ?, ?)) AS n').get(...ids, ...ids).n;++ assert.strictEqual(count(), 0);++ for (const id of ids) await t.deliver(billing.settlePurchase(domain.interactions.get(id).billing_intent_id));++ await new Promise((ok) => setTimeout(ok, 100));++ await domain.interactions.processDueTransfers();++ assert.strictEqual(count(), 3);++ const pm = t.db.prepare('SELECT * FROM paid_messages WHERE interaction_id = ?').get(ids[0]);++ assert.strictEqual(pm.highlight_seconds, 60);++ await domain.effects.drain();++ assert.strictEqual(t.db.prepare('SELECT status FROM paid_messages WHERE interaction_id = ?').get(ids[1]).status, 'delivered');++ assert.strictEqual(t.db.prepare('SELECT status FROM paid_media_requests WHERE interaction_id = ?').get(ids[2]).status, 'accepted');++ const tts = t.adapters.test.jobs.find((j) => j.effect === 'tts' && j.interaction.id === ids[1]);++ assert.deepStrictEqual(tts.tts, { text: 'read me please', voice: 'amy' });++ });++++ await check('an EXTERNAL PowerChat tip is recorded once by provider ref and kept out of the Billing total', async () => {++ const body = { creator: 'alex', provider: 'powerchat', provider_ref: 'evt-direct-1', amount_cents: 500, supporter_name: 'Fan', message: 'direct' };++ const a = await t.call('POST', '/api/v1/interactions/external', { cap: ['tips.interaction.record'], body });++ assert.strictEqual(a.status, 201, a.text);++ const b = await t.call('POST', '/api/v1/interactions/external', { cap: ['tips.interaction.record'], body });++ assert.strictEqual(b.status, 200);++ assert.strictEqual(b.json.duplicate, true);++ assert.strictEqual(a.json.interaction.id, b.json.interaction.id);++ const denied = await t.call('POST', '/api/v1/interactions/external', { cap: ['tips.checkout.create'], body: { ...body, provider_ref: 'x2' } });++ assert.strictEqual(denied.status, 403);++ const totals = domain.interactions.totals(alex.subject);++ assert.strictEqual(totals.external, 500);++ });++++ await check('the Billing webhook: bad signatures refused, proxied requests hidden, other sources ignored', async () => {++ const ev = billing.foreignDonation({ from: null, to: alex.subject, amount: 5 });++ const bad = await t.deliver(ev, { secret: 'x'.repeat(48) });++ assert.strictEqual(bad.status, 401);++ const raw = JSON.stringify({ event: ev, seq: 1 });++ const { signDelivery } = require('openvibe-sdk/events');++ const proxied = await fetch(`${t.base}/internal/events`, { method: 'POST', body: raw, headers: { 'Content-Type': 'application/json', 'X-OpenVibe-Signature': signDelivery(raw, 'e'.repeat(48)), 'X-Forwarded-For': '203.0.113.9' } });++ assert.strictEqual(proxied.status, 404);++ const forged = await t.deliver({ ...ev, source: 'live' });++ assert.strictEqual(forged.json.outcome, 'ignored:source');++ assert.strictEqual(t.db.prepare('SELECT COUNT(*) AS n FROM tip_interactions WHERE billing_txn_id = ?').get(ev.payload.transaction_id).n, 0);++ });++++ await check('events: ready/goal events are relayed to OpenVibe.Events with the tips service token', async () => {++ const flushed = await t.app.locals.outbox.outbox.flush();++ assert.ok(flushed.sent > 0, JSON.stringify(flushed));++ const types = new Set(t.events.published.map((e) => e.event_type));++ for (const ty of ['tips.interaction.ready', 'tips.goal.updated', 'tips.interaction.failed', 'tips.interaction.cancelled']) assert.ok(types.has(ty), `${ty} relayed`);++ assert.ok(t.events.published.every((e) => e.source === 'tips'));++ const grant = t.network.grants.find((g) => g.audience === 'openvibe.events');++ assert.strictEqual(grant.scope, 'events.event.publish');++ });++++ await t.close();++ done();++})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,75 @@
@@ -0,0 +1,75 @@
+# ═══════════════════════════════════════════════════════════════
+# openvibe.tips — Environment Configuration
+# Copy to .env for development. In production this lives at /etc/openvibe/tips.env (0600).
+# Names only here; never commit a value.
+# ═══════════════════════════════════════════════════════════════
+
+# Server
+PORT=4610
+HOST=127.0.0.1
+NODE_ENV=development
+BASE_URL=http://localhost:4610
+TRUST_PROXY=1
+
+# SQLite (one database per service). The systemd unit sets /var/lib/openvibe-tips/tips.db.
+TIPS_DB_PATH=./data/tips.db
+
+# ── Identity: OpenVibe.Network ──────────────────────────────
+OV_NETWORK_URL=https://openvibe.network
+OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
+#OV_NETWORK_ISSUER=https://openvibe.network
+# Optional: pin the Network's RS256 public key (PEM, \n escaped) instead of fetching the JWKS.
+#OV_NETWORK_PUBLIC_KEY=
+# Audience Tips accepts on incoming service tokens.
+TIPS_AUDIENCE=openvibe.tips
+# Tips' client in the Network (client `tips`): browser sign-in (redirect ${BASE_URL}/auth/callback)
+# and client-credentials tokens for Billing, Events, Live and Network.
+OV_OAUTH_CLIENT_ID=tips
+OV_OAUTH_CLIENT_SECRET=
+#OV_OAUTH_REDIRECT_URI=https://openvibe.tips/auth/callback
+#COOKIE_SECURE=true
+# Signs the anti-forgery tokens of the server-rendered forms (openssl rand -hex 32).
+TIPS_FORM_SECRET=
+
+# ── OpenVibe.Billing (money moves only there) ───────────────
+BILLING_URL=http://127.0.0.1:4600
+#BILLING_AUDIENCE=openvibe.billing
+# Checkout providers offered on tip pages (must be enabled on Billing).
+TIPS_CHECKOUT_PROVIDERS=powerchat
+# Billing's minimum purchase in bits; smaller tips can only be paid from a Vibes balance.
+#TIPS_MIN_CHECKOUT_BITS=100
+# Billing answers a PowerChat intent with a checkout_ref, not a URL. If set, Tips builds the link
+# from it: {ref}, {cents} and {bits} are replaced. Without it the receipt says no link is available.
+#TIPS_POWERCHAT_LINK_TEMPLATE=
+
+# ── OpenVibe.Events ─────────────────────────────────────────
+# Relay of Tips' outbox (tips.*); off when unset (rows wait in event_outbox).
+#EVENTS_URL=http://127.0.0.1:4300
+#EVENTS_RELAY_INTERVAL_MS=2000
+# Signing secret of Tips' subscription to billing.transaction.* (scripts/subscribe.js hands it to
+# Events). Comma-separated during a rotation. Without it /internal/events answers 503.
+TIPS_EVENTS_SECRET=
+
+# ── Chat delivery ───────────────────────────────────────────
+# live-chat (Live's /internal/tips/deliveries, docs/live-patch.diff) | test | none (production default)
+#TIPS_CHAT_ADAPTER=none
+#LIVE_INTERNAL_URL=http://127.0.0.1:3000
+#LIVE_AUDIENCE=openvibe.live
+#TIPS_DELIVERY_MAX_ATTEMPTS=6
+#TIPS_DELIVERY_BACKOFF_MS=2000,10000,60000,300000,900000
+
+# ── Overlays ────────────────────────────────────────────────
+#TIPS_OVERLAY_TTL_MS=600000
+#TIPS_OVERLAY_HEARTBEAT_MS=25000
+#TIPS_OVERLAY_REPLAY_LIMIT=50
+
+# ── Limits ──────────────────────────────────────────────────
+#TIPS_MAX_BITS=10000000
+#TIPS_MESSAGE_CHARS=300
+#TIPS_TTS_HARD_CAP=1200
+#TIPS_MEDIA_HOSTS=youtube.com,www.youtube.com,m.youtube.com,music.youtube.com,youtu.be
+
+# ── Background jobs: effects worker, due transfers, overlay window, outbox relay ──
+#TIPS_JOBS=on
+#TIPS_JOBS_INTERVAL_MS=2000
+#LIVE_URL=https://openvibe.live
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
+name: ci
+on:
+ push:
+ branches: [main]
+ pull_request:
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22.22.1 # production runs Node 22
+ cache: npm
+ - run: npm ci --no-audit --no-fund
+ - run: npm test
+ # The tips manifest in openvibe-contracts is still the charter placeholder and the tips.*
+ # capabilities are proposals (docs/), so this check cannot pass until the lead releases them
+ # in a contracts version. Remove continue-on-error in that release.
+ - run: npx openvibe-contracts-check --service tips --src server
+ continue-on-error: true
new file mode 100644
new file mode 100644
@@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
+node_modules/
+data/
+dist/
+.env
+.env.*
+!.env.example
+*.db
+*.db-wal
+*.db-shm
+*.log
+*.pem
+*.key
@@ -2,77 +2,235 @@
@@ -2,77 +2,235 @@
> Creator support: tips, goals, paid messages, TTS and media requests, overlays.
> Creator support: tips, goals, paid messages, TTS and media requests, overlays.
-**Status:** placeholder — planning only, no runnable code yet.
-**Domain:** `openvibe.tips`
-**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §11.2.
-**License:** AGPL-3.0 (same as every OpenVibe service).
+**Status:** alpha — runtime built and tested against stubs (roadmap Wave 9); not deployed. OpenVibe.Live
+remains where tips happen until the Billing cutover and the Live chat patch ship (see *What waits*).
+**Domain:** `openvibe.tips` (keeps its OpenVibe.Sites placeholder until the launch rule below holds)
+**Port / service id:** 4610 / `tips`
+**Decision:** [ADR-012](https://github.com/OpenVibers/OpenVibe.Contracts/blob/main/docs/adr/ADR-012-economic-classification.md) — 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
## Purpose
-A first-class 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*.
+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
## Owns
-- `tip_interactions`, `creator_tip_profiles`, `tip_goals`, `tip_goal_contributions`, `paid_messages`, `paid_media_requests`, `overlay_configs`, `overlay_deliveries`, `interaction_effects`, `migration_maps`
-- revocable scoped overlay tokens (never creator cookies)
+- `tip_interactions`, `creator_tip_profiles`, `tip_goals`, `tip_goal_contributions`, `paid_messages`,
+ `paid_media_requests`, `overlay_configs`, `overlay_deliveries`, `interaction_effects`, `migration_maps`
+ (its own SQLite, `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
## Does not own
-- monetary accounting (Billing)
-- chat/TTS queues (Chat)
-- media bytes (Media)
-
-## Planned surfaces
-
-- `apps/web` SSR creator page, tip page, goals, history/receipts, TTS/paid-message config, overlays, simulation/test mode
-- `apps/api`, `packages/domain`, `packages/overlays`, `workers/` post-settlement orchestration
-
-## Data (authority tables / families)
-
-- see above
-
-## Capabilities and events
-
-- `tips.profile.get|update`, `tips.checkout.create`, `tips.interaction.get|list`, `tips.goal.*`, `tips.superchat.create`, `tips.tts.request`, `tips.media_request.create`, `tips.overlay.token.create|revoke`, `tips.overlay.config.*`, `tips.simulate`
-
-Events: ``tips.interaction.ready|failed|cancelled``, ``tips.goal.updated``, ``tips.overlay.delivered|failed``
+- 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
## Depends on
-- OpenVibe.Billing
-- OpenVibe.Chat
-- OpenVibe.Media
-- OpenVibe.Events
-- OpenVibe.Live (overlay consumer only)
+- **OpenVibe.Billing** — `POST /api/v1/intents` (checkout), `POST /api/v1/transfers` (a tip from credit),
+ `billing.transaction.settled|reversed` events
+- **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 (importer)
+- **OpenVibe.Live** — chat delivery through `/internal/tips/deliveries` ([docs/live-patch.diff](docs/live-patch.diff)) until OpenVibe.Chat exposes paid messages; overlay consumer
+- **OpenVibe.Shared** v1.2.1 (app icon, SSR footer, noscript nav, release manifest, legal pages) and
+ the Network's `navbar.js`
+
+## Run it
+
+```bash
+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
+npm test # 6 files: stub Network/Billing/Events/Live, temp DBs, random ports
+npm run subscribe # create the billing.transaction.* subscription in OpenVibe.Events
+node scripts/import-live.js --live-db <live snapshot> --billing-db <billing snapshot> [--dry-run] [--json]
+```
+
+Production (when launched): `/opt/openvibe.tips`, env `/etc/openvibe/tips.env`, unit
+[deploy/systemd/openvibe-tips.service](deploy/systemd/openvibe-tips.service) (state in `/var/lib/openvibe-tips`),
+vhost [deploy/nginx/openvibe.tips.conf](deploy/nginx/openvibe.tips.conf).
+
+## 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 (Live 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`, 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 |
+
+**Exactly one logical interaction:** the inbox claims `(consumer, event_id)` in the same SQLite
+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.
+
+## API
+
+`/api/v1`, problem+json errors, `Idempotency-Key` on every POST/PATCH. 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?}` | `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 |
+| `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) | public when the page is on; owner sees contributions |
+| `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 | — |
+
+Capabilities are proposed in [docs/capabilities-proposal/](docs/capabilities-proposal/) (3-segment ids:
+the charter's `tips.simulate` is `tips.simulation.run`; `tips.interaction.record` is new, for EXTERNAL
+tips) with a service manifest in [docs/service-manifest-proposal.json](docs/service-manifest-proposal.json).
+Until a contracts release carries them, grants are matched with contracts' `capabilities.grants()` (exact
+id or a `.*` family), as Billing did before v0.8.0.
+
+## Events
+
+Produced through the openvibe-sdk outbox (table `event_outbox`, source `tips`, same transaction as the
+change; relayed 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`. Simulations and Billing test money produce none. Consumed:
+`billing.transaction.settled`, `billing.transaction.reversed` (only from source `billing`).
+
+## 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, `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). `live-chat` posts to Live's `/internal/tips/deliveries` with Tips' service token (audience
+`openvibe.live`, `live.tips_delivery.write`), `Idempotency-Key = <interaction>:<effect>`; Live does what
+`POST /api/funds/donate` does after the money moved (channel + global broadcast, a `donation` chat
+message, the alert sound), TTS through `synthesizeAndBroadcastTTS`, media requests into its queue at no
+charge. Live has no such route today: [docs/live-patch.diff](docs/live-patch.diff) adds it with a test
+(applies to Live `seadragon` with `git apply`; its test passes in a scratch copy of Live). `test`
+records jobs in memory (development; simulations always use it). `none` (the production default) creates
+no chat effects. When OpenVibe.Chat publishes a paid-message capability, a `chat` adapter replaces
+`live-chat`.
+
+## Pages (server-rendered, useful without JavaScript)
+
+`/` · `/<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` ·
+`/receipts`, `/receipts/:id` (payment and delivery shown separately) · `/dashboard` (page settings,
+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. Shared chrome: Network
+`navbar.js`, `openvibe-shared` app icon, SSR footer and `<noscript>` navigation.
+
+## Import and reconciliation
+
+`scripts/import-live.js` reads a **copy** of Live's database (read-only) after Billing's own import, and
+a copy of Billing's database for the links and totals:
+
+- `transactions` donations → interactions (`settlement imported`), linked to Billing by its importer key
+ `import:live:txn:<live id>`; non-completed rows excluded with the reason; rows before
+ `stats_vibes_reset_at` flagged test (as Billing flags them); unmapped creators **held**, released by a
+ later run; Live refunds applied to the donation they undo
+- PowerChat donations recorded only in `chat_messages` → EXTERNAL interactions; the celebration line of a
+ site-routed `pcdon` tip is matched to its transaction and not counted twice
+- `donation_goals` → goals, Live's `current_amount` carried over as `opening_amount`
+- reconciliation: per creator, Tips' `settled_via_billing` must equal Billing's (donations to them minus
+ refunds from them, non-test); mismatches are listed and the exit code is 1. Every source row ends up
+ imported, excluded with a reason, or held (`migration_maps`).
## Acceptance (must be true before "done")
## Acceptance (must be true before "done")
-- a duplicate provider webhook yields one Billing transaction and one logical interaction
-- overlay replay never charges again
-- the creator can use openvibe.tips with Live offline
-- creator totals reconcile exactly to Billing
-
-## Bootstrap / extraction source
-
-Live's Vibes/PayPal tipping, cashout, alert/overlay and paid-interaction behaviour, migrated only after Billing reconciliation.
+| 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); only the `live-chat` adapter talks to Live and its failure never touches payment (`test/delivery.test.js`) — **not yet demonstrated on the real host** |
+| creator totals reconcile exactly to Billing | `test/import.test.js` (fixtures in Live's schema and Billing's importer keys; a Billing donation Tips never saw is reported, then reconciles once its event arrives); `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` |
+
+## What waits
+
+- **Billing cutover** (Billing README runbook): until Live's money writes go through Billing, real tips
+ still happen on Live; Tips can only record them from Billing events once Live sends donations as
+ Billing transfers. For PowerChat checkout, Billing answers with a `checkout_ref` and no URL — Tips needs
+ `TIPS_POWERCHAT_LINK_TEMPLATE` or Billing returning the link.
+- **EXTERNAL PowerChat tips** reach Billing's webhook, which records them with no effect and no event;
+ until Billing emits one (or Live forwards them with `tips.interaction.record`), Tips does not see them.
+- **Chat**: `live-chat` needs the Live patch deployed and the grant below; OpenVibe.Chat has no public
+ paid-message/TTS capability yet. The Live patch's media-request path (yt-dlp/oEmbed) is not covered by
+ its test.
+- **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.
## Launch rule
## Launch rule
This repository does not make the product real, and the domain keeps its placeholder page on
This repository does not make the product real, and the domain keeps its placeholder page on
-[OpenVibers/OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites) until all of the
-following exist here (plan §12.12):
-
-1. an owning runtime with health/readiness endpoints and observability;
-2. canonical identity/auth integration (OpenVibe.Network subjects, scoped service principals);
-3. server-rendered or static public routes that are useful without JavaScript;
-4. real persistence and end-to-end workflows;
-5. capability and event registration against `OpenVibe.Contracts`;
-6. a migration/seed strategy, a security/threat review, and sitemap/robots/feed behaviour;
-7. acceptance tests proving the advertised functionality.
-
-The launch release removes the domain from `OpenVibe.Sites/sites.json`, switches routing and
-registers maturity in the ecosystem registry atomically. A placeholder is never counted as an
-implemented service.
+[OpenVibers/OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites) until all of the following
+exist here (plan §12.12):
+
+1. an owning runtime with health/readiness endpoints and observability — ✔ `/api/health`, `/api/ready`
+ (DB + Network key), outbox status in health;
+2. canonical identity/auth integration (Network subjects, scoped service principals) — ✔ in code;
+ the `tips` principal and grants are not provisioned yet;
+3. server-rendered public routes useful without JavaScript — ✔;
+4. real persistence and end-to-end workflows — ✔ against stubs; not yet against the real Billing and
+ Events;
+5. capability and event registration against OpenVibe.Contracts — proposed, not released;
+6. a migration/seed strategy ✔, a security/threat review (not done beyond the tests' refusals), and
+ sitemap/robots ✔ (no feed);
+7. acceptance tests proving the advertised functionality — ✔ (table above).
+
+The launch release removes the domain from `OpenVibe.Sites/sites.json`, switches routing and registers
+maturity in the ecosystem registry atomically. A placeholder is never counted as an implemented service.
---
---
@@ -1,7 +1,11 @@
@@ -1,7 +1,11 @@
{
{
"repository": "OpenVibers/OpenVibe.Tips",
"repository": "OpenVibers/OpenVibe.Tips",
- "stage": "placeholder",
- "code": false,
+ "stage": "alpha",
+ "code": true,
+ "wave": "W9",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
- "updated": "2026-09-21"
+ "adr": "ADR-012",
+ "authoritative": false,
+ "notes": "Runtime, API, SSR pages, overlays, Billing settlement consumer, events outbox, Live import and reconciliation are built and tested against stubs. Not deployed; the openvibe.tips placeholder stays on OpenVibe.Sites until the launch rule in README.md holds. Live stays the place tips happen until the Billing cutover and the Live chat patch ship.",
+ "updated": "2026-09-22"
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,115 @@
@@ -0,0 +1,115 @@
+## ═══════════════════════════════════════════════════════════════
+## openvibe.tips — Nginx Configuration (REFERENCE)
+##
+## Deployed to /etc/nginx/sites-available/openvibe.tips.conf once the launch rule in README.md
+## holds (until then the domain keeps its OpenVibe.Sites placeholder). The Node app (port 4610,
+## unit openvibe-tips) renders every page, serves the API and the overlay streams. nginx terminates
+## TLS, redirects www → apex, rate-limits auth and API, keeps overlay streams unbuffered, keeps
+## overlay tokens out of the access log, and never exposes /internal/ (OpenVibe.Events calls
+## 127.0.0.1:4610 directly).
+## ═══════════════════════════════════════════════════════════════
+
+limit_req_zone $binary_remote_addr zone=ovtips_api:10m rate=10r/s;
+limit_req_zone $binary_remote_addr zone=ovtips_auth:10m rate=10r/m;
+limit_req_zone $binary_remote_addr zone=ovtips_forms:10m rate=2r/s;
+
+server {
+ listen 80;
+ listen [::]:80;
+ server_name openvibe.tips www.openvibe.tips;
+ location /.well-known/acme-challenge/ { root /var/www/certbot; }
+ location / { return 301 https://openvibe.tips$request_uri; }
+}
+
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name www.openvibe.tips;
+ ssl_certificate /etc/letsencrypt/live/openvibe.tips/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/openvibe.tips/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+ return 301 https://openvibe.tips$request_uri;
+}
+
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name openvibe.tips;
+
+ ssl_certificate /etc/letsencrypt/live/openvibe.tips/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/openvibe.tips/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+
+ access_log /var/log/nginx/openvibe.tips.access.log;
+ error_log /var/log/nginx/openvibe.tips.error.log;
+ client_max_body_size 64k;
+
+ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
+
+ gzip on;
+ gzip_proxied any;
+ gzip_vary on;
+ gzip_types text/css application/javascript application/json image/svg+xml text/plain application/xml;
+
+ # Service-to-service only (Events → 127.0.0.1:4610/internal/events). Never public.
+ location /internal/ { return 404; }
+
+ location /auth/ {
+ limit_req zone=ovtips_auth burst=10 nodelay;
+ proxy_pass http://127.0.0.1:4610;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+
+ location /api/ {
+ limit_req zone=ovtips_api burst=30 nodelay;
+ proxy_pass http://127.0.0.1:4610;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+
+ # Overlays: the token is in the path, so these requests are not logged. Streams are long-lived
+ # server-sent events: no buffering, a long read timeout (the app sends a heartbeat every 25 s).
+ location /overlay/ {
+ access_log off;
+ proxy_pass http://127.0.0.1:4610;
+ proxy_http_version 1.1;
+ proxy_set_header Connection '';
+ proxy_set_header Host $host;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_buffering off;
+ proxy_cache off;
+ proxy_read_timeout 1h;
+ }
+
+ # Tip and dashboard form posts.
+ location ~ ^/(dashboard/|[^/]+/tip$) {
+ limit_req zone=ovtips_forms burst=10 nodelay;
+ proxy_pass http://127.0.0.1:4610;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+
+ location / {
+ proxy_pass http://127.0.0.1:4610;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+
+ location ~ /\.(?!well-known) { deny all; }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,48 @@
@@ -0,0 +1,48 @@
+[Unit]
+Description=OpenVibe.Tips — creator support: tips, goals, paid messages, TTS and media requests, overlays
+# Network signs the tokens Tips verifies (its key is fetched at boot and retried); Billing moves
+# the money; Events carries Billing's settlements in and Tips' events out. Tips starts without any
+# of them: pages and overlays keep working, payments wait.
+After=network.target
+Wants=openvibe-network.service openvibe-billing.service
+
+[Service]
+Type=simple
+User=ubuntu
+Group=ubuntu
+WorkingDirectory=/opt/openvibe.tips
+EnvironmentFile=/etc/openvibe/tips.env
+Environment=NODE_ENV=production
+Environment=PORT=4610
+Environment=HOST=127.0.0.1
+Environment=PATH=/usr/local/bin:/usr/bin:/bin
+ExecStart=/usr/bin/env node /opt/openvibe.tips/server/index.js
+Restart=always
+RestartSec=5
+# Tips' own database (ADR-007: one database per service); the code tree stays read-only.
+StateDirectory=openvibe-tips
+StateDirectoryMode=0700
+Environment=TIPS_DB_PATH=/var/lib/openvibe-tips/tips.db
+UMask=0077
+NoNewPrivileges=true
+PrivateTmp=true
+PrivateDevices=true
+ProtectSystem=strict
+ProtectHome=true
+ProtectKernelTunables=true
+ProtectKernelModules=true
+ProtectKernelLogs=true
+ProtectControlGroups=true
+ProtectClock=true
+ProtectHostname=true
+RestrictSUIDSGID=true
+RestrictRealtime=true
+RestrictNamespaces=true
+LockPersonality=true
+SystemCallArchitectures=native
+RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
+CapabilityBoundingSet=
+AmbientCapabilities=
+
+[Install]
+WantedBy=multi-user.target
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
+{
+ "id": "live.tips_delivery.write",
+ "version": "1.0",
+ "owner": "live",
+ "status": "active",
+ "visibility": "internal",
+ "description": "OpenVibe.Tips' live-chat adapter: announce a settled tip in the creator's Live chat (donation line, paid message, TTS, media request at no charge). Owned by Live (docs/live-patch.diff adds the route); proposed here for Live's manifest.",
+ "permissions": [
+ "live:chat"
+ ],
+ "resourceConstraints": [
+ "none"
+ ],
+ "quotaClass": "standard",
+ "events": [],
+ "implementedBy": [
+ "POST /internal/tips/deliveries"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,22 @@
@@ -0,0 +1,22 @@
+{
+ "id": "tips.checkout.create",
+ "version": "1.0",
+ "owner": "tips",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Start a tip for a supporter (on_behalf SubjectRef): paid from the supporter's Billing credit (billing.transfer.create) or through a Billing checkout intent (billing.intent.create). Tips never prices money; Billing does. Idempotent by Idempotency-Key.",
+ "permissions": [
+ "tips:checkout"
+ ],
+ "resourceConstraints": [
+ "owner"
+ ],
+ "quotaClass": "payments",
+ "events": [
+ "tips.interaction.ready",
+ "tips.interaction.cancelled"
+ ],
+ "implementedBy": [
+ "POST /api/v1/checkout"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
+{
+ "id": "tips.goal.close",
+ "version": "1.0",
+ "owner": "tips",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Close a goal; its history stays.",
+ "permissions": [
+ "tips:goals"
+ ],
+ "resourceConstraints": [
+ "owner"
+ ],
+ "quotaClass": "standard",
+ "events": [
+ "tips.goal.updated"
+ ],
+ "implementedBy": [
+ "POST /api/v1/goals/:id/close"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
+{
+ "id": "tips.goal.create",
+ "version": "1.0",
+ "owner": "tips",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Create a goal for a creator. Goal totals are derived from settled interactions only.",
+ "permissions": [
+ "tips:goals"
+ ],
+ "resourceConstraints": [
+ "owner"
+ ],
+ "quotaClass": "standard",
+ "events": [
+ "tips.goal.updated"
+ ],
+ "implementedBy": [
+ "POST /api/v1/goals"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,22 @@
@@ -0,0 +1,22 @@
+{
+ "id": "tips.goal.update",
+ "version": "1.0",
+ "owner": "tips",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Edit a goal (title, target, description, image, order) with optimistic revision checks; read private goals with contributions.",
+ "permissions": [
+ "tips:goals"
+ ],
+ "resourceConstraints": [
+ "owner"
+ ],
+ "quotaClass": "standard",
+ "events": [
+ "tips.goal.updated"
+ ],
+ "implementedBy": [
+ "PATCH /api/v1/goals/:id",
+ "GET /api/v1/goals/:id"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
+{
+ "id": "tips.interaction.get",
+ "version": "1.0",
+ "owner": "tips",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Read one interaction with its separate payment state (from Billing) and delivery state (effects).",
+ "permissions": [
+ "tips:interactions"
+ ],
+ "resourceConstraints": [
+ "owner"
+ ],
+ "quotaClass": "standard",
+ "events": [],
+ "implementedBy": [
+ "GET /api/v1/interactions/:id"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,20 @@
@@ -0,0 +1,20 @@
+{
+ "id": "tips.interaction.list",
+ "version": "1.0",
+ "owner": "tips",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "List a creator's or a supporter's interactions and a creator's derived totals.",
+ "permissions": [
+ "tips:interactions"
+ ],
+ "resourceConstraints": [
+ "owner"
+ ],
+ "quotaClass": "standard",
+ "events": [],
+ "implementedBy": [
+ "GET /api/v1/interactions",
+ "GET /api/v1/profiles/:creator/totals"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
+{
+ "id": "tips.interaction.record",
+ "version": "1.0",
+ "owner": "tips",
+ "status": "active",
+ "visibility": "internal",
+ "description": "Record a tip that never touched OpenVibe money (ADR-012 EXTERNAL: a tip on a creator's own PowerChat) once per (provider, provider_ref); no Billing liability, excluded from Billing reconciliation. Not in the charter list: proposed because EXTERNAL tips need a recording path while Billing emits nothing for them.",
+ "permissions": [
+ "tips:interactions"
+ ],
+ "resourceConstraints": [
+ "none"
+ ],
+ "quotaClass": "standard",
+ "events": [
+ "tips.interaction.ready"
+ ],
+ "implementedBy": [
+ "POST /api/v1/interactions/external"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
+{
+ "id": "tips.media_request.create",
+ "version": "1.0",
+ "owner": "tips",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Start a paid media request for a supporter (allowed hosts, the creator's minimum and length); queued with the media-queue owner only after settlement.",
+ "permissions": [
+ "tips:checkout"
+ ],
+ "resourceConstraints": [
+ "owner"
+ ],
+ "quotaClass": "payments",
+ "events": [
+ "tips.interaction.ready"
+ ],
+ "implementedBy": [
+ "POST /api/v1/media-requests"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,20 @@
@@ -0,0 +1,20 @@
+{
+ "id": "tips.overlay.config.get",
+ "version": "1.0",
+ "owner": "tips",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Read a creator's overlay configs (alert and goal display settings).",
+ "permissions": [
+ "tips:overlays"
+ ],
+ "resourceConstraints": [
+ "owner"
+ ],
+ "quotaClass": "standard",
+ "events": [],
+ "implementedBy": [
+ "GET /api/v1/overlay-configs",
+ "GET /api/v1/overlay-configs/:id"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,20 @@
@@ -0,0 +1,20 @@
+{
+ "id": "tips.overlay.config.update",
+ "version": "1.0",
+ "owner": "tips",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Create or change a creator's overlay configs; open overlays receive the change at once.",
+ "permissions": [
+ "tips:overlays"
+ ],
+ "resourceConstraints": [
+ "owner"
+ ],
+ "quotaClass": "standard",
+ "events": [],
+ "implementedBy": [
+ "POST /api/v1/overlay-configs",
+ "PATCH /api/v1/overlay-configs/:id"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,20 @@
@@ -0,0 +1,20 @@
+{
+ "id": "tips.overlay.token.create",
+ "version": "1.0",
+ "owner": "tips",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Mint a scoped overlay token (alerts, goals) for a creator — shown once, stored hashed, never a creator cookie — and list a creator's tokens without secrets.",
+ "permissions": [
+ "tips:overlays"
+ ],
+ "resourceConstraints": [
+ "owner"
+ ],
+ "quotaClass": "standard",
+ "events": [],
+ "implementedBy": [
+ "POST /api/v1/overlay-tokens",
+ "GET /api/v1/overlay-tokens"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
+{
+ "id": "tips.overlay.token.revoke",
+ "version": "1.0",
+ "owner": "tips",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Revoke an overlay token; open overlay streams using it are closed immediately.",
+ "permissions": [
+ "tips:overlays"
+ ],
+ "resourceConstraints": [
+ "owner"
+ ],
+ "quotaClass": "standard",
+ "events": [],
+ "implementedBy": [
+ "POST /api/v1/overlay-tokens/:id/revoke"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
+{
+ "id": "tips.profile.get",
+ "version": "1.0",
+ "owner": "tips",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Read a creator's tip profile including a page that is switched off (public pages are readable without a token).",
+ "permissions": [
+ "tips:profile"
+ ],
+ "resourceConstraints": [
+ "owner"
+ ],
+ "quotaClass": "standard",
+ "events": [],
+ "implementedBy": [
+ "GET /api/v1/profiles/:creator"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
+{
+ "id": "tips.profile.update",
+ "version": "1.0",
+ "owner": "tips",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Create or update a creator's tip profile on their behalf: page switch (indexing), accepting, minimums, TTS and media-request settings. Creators do this themselves with their user token.",
+ "permissions": [
+ "tips:profile"
+ ],
+ "resourceConstraints": [
+ "owner"
+ ],
+ "quotaClass": "standard",
+ "events": [],
+ "implementedBy": [
+ "PATCH /api/v1/profiles/:creator"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
+{
+ "id": "tips.simulation.run",
+ "version": "1.0",
+ "owner": "tips",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Run the full effect path (overlay alert, goal widget, delivery) flagged test, with no Billing call, never counted in any total and never emitted as a durable event. The charter's 'tips.simulate' as a 3-segment id.",
+ "permissions": [
+ "tips:simulate"
+ ],
+ "resourceConstraints": [
+ "owner"
+ ],
+ "quotaClass": "standard",
+ "events": [],
+ "implementedBy": [
+ "POST /api/v1/simulate"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
+{
+ "id": "tips.superchat.create",
+ "version": "1.0",
+ "owner": "tips",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Start a paid (highlighted) chat message for a supporter; the paid_messages row exists only after Billing settles it.",
+ "permissions": [
+ "tips:checkout"
+ ],
+ "resourceConstraints": [
+ "owner"
+ ],
+ "quotaClass": "payments",
+ "events": [
+ "tips.interaction.ready"
+ ],
+ "implementedBy": [
+ "POST /api/v1/paid-messages"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
+{
+ "id": "tips.tts.request",
+ "version": "1.0",
+ "owner": "tips",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Start a paid text-to-speech request for a supporter, within the creator's TTS settings; delivered to chat only after settlement.",
+ "permissions": [
+ "tips:checkout"
+ ],
+ "resourceConstraints": [
+ "owner"
+ ],
+ "quotaClass": "payments",
+ "events": [
+ "tips.interaction.ready"
+ ],
+ "implementedBy": [
+ "POST /api/v1/tts-requests"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,232 @@
@@ -0,0 +1,232 @@
+diff --git a/server/index.js b/server/index.js
+index 4bba504..63c0969 100644
+--- a/server/index.js
+@@ -564,6 +564,8 @@ app.get('/internal/analytics-summary', (req, res) => {
+ const chatLiveContext = require('./chat/live-context-routes');
+ app.use('/internal/chat-context', chatLiveContext.contextRouter);
+ app.use('/internal/chat-effects', chatLiveContext.effectsRouter);
++// OpenVibe.Tips announces settled tips in the creator's chat here (service token, live.tips_delivery.write).
++app.use('/internal/tips', require('./tips/delivery-routes'));
+ app.use('/internal', require('./internal/routes'));
+
+
+diff --git a/server/tips/delivery-routes.js b/server/tips/delivery-routes.js
+new file mode 100644
+index 0000000..ff08b97
+--- /dev/null
+@@ -0,0 +1,108 @@
++'use strict';
++/**
++ * OpenVibe.Tips → Live: announce a settled tip in the creator's Live chat (Tips' live-chat
++ * delivery adapter, roadmap Wave 9). The money already moved in OpenVibe.Billing; this route only
++ * does what the tail of POST /api/funds/donate does after the balance changed.
++ *
++ * POST /internal/tips/deliveries capability live.tips_delivery.write (Network service token,
++ * audience openvibe.live; loopback-only like every /internal route)
++ * Idempotency-Key: <interaction id>:<effect> a repeat answers the first result and does nothing
++ * body { delivery_id, effect, test, creator: { type, id: usr_… }, supporter: { name, subject? },
++ * interaction: { id, kind, amount, currency, message }, text, tts?: { text, voice },
++ * media?: { url }, highlight_seconds?, target?: { service: 'live', type: 'stream', id } }
++ * → 200 { ok: true, ref } 404 creator has no Live channel (permanent) 409 not live (retried)
++ *
++ * chat_line | paid_message donation event to the channel room + global, saved as a 'donation'
++ * chat message (metadata.source 'tips'), the donation alert sound
++ * tts synthesizeAndBroadcastTTS on the live stream (or the offline channel room)
++ * media_request into the media queue at cost 0 — Billing charged it, Tips holds the record
++ */
++const express = require('express');
++const db = require('../db/database');
++const { guard } = require('../net/service-guard');
++
++const router = express.Router();
++const done = new Map(); // Idempotency-Key → response (Tips retries within minutes; bounded)
++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++
++function channelUserId(subject) {
++ if (!SUBJECT_RE.test(String(subject || ''))) return null;
++ const row = db.getDb().prepare("SELECT user_id FROM linked_accounts WHERE service = 'network' AND subject_id = ? ORDER BY id DESC LIMIT 1").get(subject);
++ return row ? row.user_id : null;
++}
++
++function streamIdFor(target, userId) {
++ if (target && target.service === 'live' && target.type === 'stream') {
++ const s = db.getStreamById(Number(target.id));
++ if (s && s.user_id === userId && s.is_live) return s.id;
++ }
++ const live = db.getStreamByUserId(userId);
++ return live ? live.id : null;
++}
++
++function remember(key, out) {
++ done.set(key, out);
++ if (done.size > 5000) done.delete(done.keys().next().value);
++ return out;
++}
++
++router.post('/deliveries', guard('live.tips_delivery.write'), express.json({ limit: '32kb' }), async (req, res) => {
++ const key = String(req.get('idempotency-key') || '');
++ if (!/^[A-Za-z0-9_:.-]{8,200}$/.test(key)) return res.status(400).json({ error: 'Idempotency-Key required' });
++ if (done.has(key)) return res.json(done.get(key));
++ const b = req.body || {};
++ const i = b.interaction || {};
++ const userId = channelUserId(b.creator && b.creator.id);
++ if (!userId) return res.status(404).json({ error: 'this creator has no Live channel' });
++ const chatServer = require('../chat/chat-server');
++ const streamId = streamIdFor(b.target, userId);
++ const name = String((b.supporter && b.supporter.name) || 'Someone').slice(0, 80);
++ const amount = Math.max(0, Math.round(Number(i.amount) || 0));
++ const message = String(i.message || '').slice(0, 500);
++ try {
++ if (b.effect === 'chat_line' || b.effect === 'paid_message') {
++ const event = {
++ type: 'donation', username: name, user_id: null, avatar_url: null, amount, message, timestamp: new Date().toISOString(),
++ source: 'tips', paid_message: b.effect === 'paid_message', highlight_seconds: Number(b.highlight_seconds) || 0,
++ };
++ chatServer.broadcastToChannelRoom(userId, streamId, event);
++ try { chatServer.broadcastGlobal({ ...event, global: true, channel_user_id: userId }); } catch { /* non-critical */ }
++ const saved = db.saveChatMessage({
++ stream_id: streamId, channel_user_id: userId, user_id: null, username: name,
++ message: String(b.text || `${name} tipped ${amount.toLocaleString()} Vibes${message ? ': ' + message : ''}`).slice(0, 1000),
++ message_type: 'donation',
++ metadata: { kind: 'donation', amount, message, username: name, source: 'tips', interaction_id: i.id || null, paid_message: event.paid_message, highlight_seconds: event.highlight_seconds, test: !!b.test },
++ });
++ require('../monetization/alerts').playAlertSound(chatServer, userId, streamId, 'donation');
++ return res.json(remember(key, { ok: true, ref: { chat_message_id: saved && saved.lastInsertRowid != null ? Number(saved.lastInsertRowid) : null } }));
++ }
++ if (b.effect === 'tts') {
++ const text = String((b.tts && b.tts.text) || '').slice(0, 1200);
++ if (!text) return res.status(422).json({ error: 'no text' });
++ await chatServer.synthesizeAndBroadcastTTS(streamId, name, text, null, 'tips', `tips:${i.id}`, userId, `tips-${i.id}`);
++ return res.json(remember(key, { ok: true, ref: { stream_id: streamId } }));
++ }
++ if (b.effect === 'media_request') {
++ const mq = require('../media/media-queue');
++ const settings = mq.getSettings(userId);
++ const normalized = await mq.normalizeInput(String((b.media && b.media.url) || ''), settings);
++ const max = Number(settings.max_duration_seconds) || 600;
++ if (Number.isFinite(normalized.duration_seconds) && normalized.duration_seconds > max) return res.status(422).json({ error: `longer than ${max} seconds` });
++ const r = db.createMediaRequest({
++ streamer_id: userId, stream_id: streamId, user_id: null, username: name, input: String(b.media.url),
++ canonical_url: normalized.canonical_url, embed_url: normalized.embed_url, provider: normalized.provider, title: normalized.title,
++ thumbnail_url: normalized.thumbnail_url, duration_seconds: normalized.duration_seconds,
++ // Paid through OpenVibe.Billing and recorded by OpenVibe.Tips: Live charges nothing here.
++ cost: 0, currency: 'free', queue_position: db.getMediaRequestMaxQueuePosition(userId) + 1,
++ });
++ mq.broadcastQueueUpdate(userId);
++ return res.json(remember(key, { ok: true, ref: { media_request_id: Number(r.lastInsertRowid) } }));
++ }
++ return res.status(422).json({ error: `unknown effect ${b.effect}` });
++ } catch (err) {
++ console.warn('[Tips delivery]', b.effect, err.message);
++ return res.status(422).json({ error: err.message });
++ }
++});
++
++module.exports = router;
+diff --git a/test/tips-delivery.test.js b/test/tips-delivery.test.js
+new file mode 100644
+index 0000000..2c3730a
+--- /dev/null
+@@ -0,0 +1,99 @@
++'use strict';
++
++// OpenVibe.Tips → Live (roadmap Wave 9): POST /internal/tips/deliveries announces a settled tip in the
++// creator's chat — service token with live.tips_delivery.write, idempotent per delivery id, the
++// creator found by Network subject, no Live balance touched.
++
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++const crypto = require('crypto');
++const express = require('express');
++const { serviceAuth } = require('openvibe-contracts');
++
++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-tips-delivery-'));
++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
++fs.writeFileSync(path.join(tmp, 'network.pem'), keys.publicKey);
++fs.mkdirSync(path.join(tmp, 'sounds'));
++process.env.DB_PATH = path.join(tmp, 'live.db');
++process.env.OV_NETWORK_PUBLIC_KEY = path.join(tmp, 'network.pem');
++process.env.OV_NETWORK_URL = 'https://openvibe.network';
++process.env.SOUNDS_PATH = path.join(tmp, 'sounds');
++console.log = () => {};
++console.warn = () => {};
++
++const ISS = 'https://openvibe.network';
++const SUBJECT = 'usr_01J0000000000000000000000Z';
++const now = () => Math.floor(Date.now() / 1000);
++let jti = 0;
++const token = (cap, aud = 'openvibe.live') => serviceAuth.signServiceToken({ iss: ISS, sub: 'svc:tips', actor_type: 'service', aud: [aud], cap, iat: now(), exp: now() + 300, jti: `tok_tips_${++jti}` }, keys.privateKey);
++
++(async () => {
++ const db = require('../server/db/database');
++ db.initDb();
++ const d = db.getDb();
++ d.prepare("INSERT INTO users (id, username, display_name, password_hash, openvibe_bucks_balance, openvibe_bucks_cashout_balance) VALUES (501, 'alex', 'Alex', 'x', 0, 0)").run();
++ d.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (501, 'network', '77', ?)").run(SUBJECT);
++
++ const chatServer = require('../server/chat/chat-server');
++ const broadcasts = [];
++ chatServer.broadcastToChannelRoom = (uid, sid, ev) => broadcasts.push({ uid, sid, ev });
++ chatServer.broadcastGlobal = () => {};
++ const tts = [];
++ chatServer.synthesizeAndBroadcastTTS = async (...a) => { tts.push(a); };
++
++ const app = express();
++ app.use('/internal/tips', require('../server/tips/delivery-routes'));
++ const server = await new Promise((r) => { const s = http.createServer(app); s.listen(0, '127.0.0.1', () => r(s)); });
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const post = (body, { cap = ['live.tips_delivery.write'], key = 'tint_1:chat_line', headers = {} } = {}) => fetch(`${base}/internal/tips/deliveries`, {
++ method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token(cap)}`, 'Idempotency-Key': key, ...headers }, body: JSON.stringify(body),
++ }).then(async (r) => ({ status: r.status, json: await r.json().catch(() => null) }));
++
++ const job = {
++ delivery_id: 'tint_1:chat_line', effect: 'chat_line', test: false, creator: { type: 'user', id: SUBJECT },
++ supporter: { name: 'Viewer' }, interaction: { id: 'tint_1', kind: 'tip', amount: 250, currency: 'vibes-bits', message: 'gg' },
++ text: 'Viewer tipped 250 Vibes: gg',
++ };
++
++ assert.strictEqual((await post(job, { cap: ['live.chat_effects.write'] })).status, 403, 'needs live.tips_delivery.write');
++ assert.strictEqual((await post(job, { headers: { 'X-Forwarded-For': '1.2.3.4' } })).status, 403, 'loopback only');
++
++ const a = await post(job);
++ assert.strictEqual(a.status, 200);
++ assert.ok(a.json.ref.chat_message_id > 0);
++ assert.strictEqual(broadcasts.length, 1);
++ assert.strictEqual(broadcasts[0].uid, 501);
++ assert.strictEqual(broadcasts[0].ev.type, 'donation');
++ assert.strictEqual(broadcasts[0].ev.amount, 250);
++ const saved = d.prepare("SELECT * FROM chat_messages WHERE message_type = 'donation'").get();
++ assert.strictEqual(saved.message, 'Viewer tipped 250 Vibes: gg');
++ assert.strictEqual(JSON.parse(saved.metadata).source, 'tips');
++
++ // A retry of the same delivery changes nothing.
++ const b = await post(job);
++ assert.deepStrictEqual(b.json, a.json);
++ assert.strictEqual(broadcasts.length, 1);
++ assert.strictEqual(d.prepare("SELECT COUNT(*) AS n FROM chat_messages WHERE message_type = 'donation'").get().n, 1);
++
++ // TTS on the (offline) channel room.
++ const t = await post({ ...job, effect: 'tts', tts: { text: 'read me', voice: 'gary' } }, { key: 'tint_1:tts' });
++ assert.strictEqual(t.status, 200);
++ assert.strictEqual(tts[0][2], 'read me');
++ assert.strictEqual(tts[0][6], 501);
++
++ // Unknown creator: permanent refusal.
++ const u = await post({ ...job, creator: { type: 'user', id: 'usr_01J0000000000000000000000Y' } }, { key: 'tint_2:chat_line' });
++ assert.strictEqual(u.status, 404);
++
++ // No Live balance moved.
++ const user = d.prepare('SELECT openvibe_bucks_balance, openvibe_bucks_cashout_balance FROM users WHERE id = 501').get();
++ assert.deepStrictEqual(user, { openvibe_bucks_balance: 0, openvibe_bucks_cashout_balance: 0 });
++ assert.strictEqual(d.prepare("SELECT COUNT(*) AS n FROM transactions").get().n, 0);
++
++ server.close();
++ process.stdout.write('tips-delivery: all passed\n');
++ process.exit(0);
++})().catch((e) => { process.stderr.write(`${e.stack}\n`); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,51 @@
@@ -0,0 +1,51 @@
+{
+ "version": "0.2.0",
+ "contractRanges": {
+ "openvibe-contracts": ">=0.11.0 <1.0.0"
+ },
+ "id": "tips",
+ "name": "OpenVibe.Tips",
+ "status": "alpha",
+ "repository": "OpenVibers/OpenVibe.Tips",
+ "domains": [
+ "openvibe.tips"
+ ],
+ "publicOrigin": "https://openvibe.tips",
+ "health": "/api/health",
+ "ready": "/api/ready",
+ "capabilities": [
+ "tips.profile.get",
+ "tips.profile.update",
+ "tips.checkout.create",
+ "tips.superchat.create",
+ "tips.tts.request",
+ "tips.media_request.create",
+ "tips.interaction.get",
+ "tips.interaction.list",
+ "tips.interaction.record",
+ "tips.goal.create",
+ "tips.goal.update",
+ "tips.goal.close",
+ "tips.overlay.token.create",
+ "tips.overlay.token.revoke",
+ "tips.overlay.config.get",
+ "tips.overlay.config.update",
+ "tips.simulation.run"
+ ],
+ "eventsProduced": [
+ "tips.interaction.ready",
+ "tips.interaction.failed",
+ "tips.interaction.cancelled",
+ "tips.goal.updated",
+ "tips.overlay.delivered",
+ "tips.overlay.failed"
+ ],
+ "eventsConsumed": [
+ "billing.transaction.settled",
+ "billing.transaction.reversed"
+ ],
+ "namespacesOwned": [
+ "tips.*"
+ ],
+ "notes": "Creator support (roadmap Wave 9): tip interactions whose payment state mirrors OpenVibe.Billing (keyed by Billing transaction id) and whose delivery state is Tips' own; goals derived from settled interactions; paid messages, TTS and media requests created on settlement; scoped revocable overlay tokens with SSE overlays; simulation mode. Tips never moves money (ADR-012). Alpha: the placeholder on OpenVibe.Sites stays until the launch rule in README.md holds. Proposed manifest; replaces the placeholder in manifests/services/tips.json."
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,1501 @@
@@ -0,0 +1,1501 @@
+{
+ "name": "openvibe-tips",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "openvibe-tips",
+ "version": "0.1.0",
+ "license": "AGPL-3.0-only",
+ "dependencies": {
+ "better-sqlite3": "^11.10.0",
+ "cookie-parser": "^1.4.7",
+ "dotenv": "^16.4.7",
+ "express": "^4.21.2",
+ "jsonwebtoken": "^9.0.2",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.11.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.2.1"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/accepts": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "~2.1.34",
+ "negotiator": "0.6.3"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
+ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-uri": "^3.0.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ajv-formats": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
+ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "ajv": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/array-flatten": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+ "license": "MIT"
+ },
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/better-sqlite3": {
+ "version": "11.10.0",
+ "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
+ "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "bindings": "^1.5.0",
+ "prebuild-install": "^7.1.1"
+ }
+ },
+ "node_modules/bindings": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
+ "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
+ "license": "MIT",
+ "dependencies": {
+ "file-uri-to-path": "1.0.0"
+ }
+ },
+ "node_modules/bl": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
+ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer": "^5.5.0",
+ "inherits": "^2.0.4",
+ "readable-stream": "^3.4.0"
+ }
+ },
+ "node_modules/body-parser": {
+ "version": "1.20.8",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",
+ "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "content-type": "~1.0.5",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "~1.2.0",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "on-finished": "~2.4.1",
+ "qs": "~6.16.0",
+ "raw-body": "~2.5.3",
+ "type-is": "~1.6.18",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/buffer": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
+ "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.1.13"
+ }
+ },
+ "node_modules/buffer-equal-constant-time": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
+ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/chownr": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
+ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
+ "license": "ISC"
+ },
+ "node_modules/content-disposition": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-parser": {
+ "version": "1.4.7",
+ "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
+ "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "0.7.2",
+ "cookie-signature": "1.0.6"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
+ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/decompress-response": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
+ "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
+ "license": "MIT",
+ "dependencies": {
+ "mimic-response": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/deep-extend": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
+ "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/destroy": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/dotenv": {
+ "version": "16.6.1",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
+ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ecdsa-sig-formatter": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
+ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/end-of-stream": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
+ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
+ "license": "MIT",
+ "dependencies": {
+ "once": "^1.4.0"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/expand-template": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
+ "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
+ "license": "(MIT OR WTFPL)",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/express": {
+ "version": "4.22.3",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",
+ "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "~1.3.8",
+ "array-flatten": "1.1.1",
+ "body-parser": "~1.20.5",
+ "content-disposition": "~0.5.4",
+ "content-type": "~1.0.4",
+ "cookie": "~0.7.1",
+ "cookie-signature": "~1.0.6",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "finalhandler": "~1.3.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.0",
+ "merge-descriptors": "1.0.3",
+ "methods": "~1.1.2",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "path-to-regexp": "~0.1.13",
+ "proxy-addr": "~2.0.7",
+ "qs": "~6.16.0",
+ "range-parser": "~1.2.1",
+ "safe-buffer": "5.2.1",
+ "send": "~0.19.0",
+ "serve-static": "~1.16.2",
+ "setprototypeof": "1.2.0",
+ "statuses": "~2.0.1",
+ "type-is": "~1.6.18",
+ "utils-merge": "1.0.1",
+ "vary": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "license": "MIT"
+ },
+ "node_modules/fast-uri": {
+ "version": "3.1.8",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
+ "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/file-uri-to-path": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
+ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
+ "license": "MIT"
+ },
+ "node_modules/finalhandler": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
+ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "statuses": "~2.0.2",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fs-constants": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
+ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
+ "license": "MIT"
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/github-from-package": {
+ "version": "0.0.0",
+ "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
+ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
+ "license": "MIT"
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/ieee754": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ini": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
+ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
+ "license": "ISC"
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "license": "MIT"
+ },
+ "node_modules/jsonwebtoken": {
+ "version": "9.0.3",
+ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
+ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
+ "license": "MIT",
+ "dependencies": {
+ "jws": "^4.0.1",
+ "lodash.includes": "^4.3.0",
+ "lodash.isboolean": "^3.0.3",
+ "lodash.isinteger": "^4.0.4",
+ "lodash.isnumber": "^3.0.3",
+ "lodash.isplainobject": "^4.0.6",
+ "lodash.isstring": "^4.0.1",
+ "lodash.once": "^4.0.0",
+ "ms": "^2.1.1",
+ "semver": "^7.5.4"
+ },
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ }
+ },
+ "node_modules/jsonwebtoken/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/jwa": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
+ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer-equal-constant-time": "^1.0.1",
+ "ecdsa-sig-formatter": "1.0.11",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/jws": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
+ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
+ "license": "MIT",
+ "dependencies": {
+ "jwa": "^2.0.1",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/lodash.includes": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
+ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isboolean": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
+ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isinteger": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
+ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isnumber": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
+ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isplainobject": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
+ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isstring": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
+ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.once": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
+ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
+ "license": "MIT"
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/methods": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+ "license": "MIT",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mimic-response": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
+ "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/minimist": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
+ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/mkdirp-classic": {
+ "version": "0.5.3",
+ "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
+ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
+ "license": "MIT"
+ },
+ "node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "license": "MIT"
+ },
+ "node_modules/napi-build-utils": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
+ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
+ "license": "MIT"
+ },
+ "node_modules/negotiator": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/node-abi": {
+ "version": "3.96.0",
+ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz",
+ "integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==",
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.3.5"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/openvibe-contracts": {
+ "version": "0.11.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.11.0",
+ "integrity": "sha512-M1UIDUF1nK2v94S++Sqw+d1G9mj3UKaPB8vse6UWIKZy9IAdA7Yu6DsTK0mJ0UXKeOVLLWuxeQX4G+/HtV9REg==",
+ "license": "AGPL-3.0-only",
+ "dependencies": {
+ "ajv": "^8.17.1",
+ "ajv-formats": "^3.0.1"
+ },
+ "bin": {
+ "openvibe-contracts-check": "bin/check-service.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/openvibe-sdk": {
+ "version": "0.2.2",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",
+ "integrity": "sha512-kDifUp3EcmnTWzElAmaCZnX659WErcnm4+qVEoAiYQWX/w/1p4TWvoHbUPsrX65TSROVR3tiyVkl801n/0yyCw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/openvibe-shared": {
+ "version": "1.2.1",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.2.1",
+ "integrity": "sha512-t8EHgju5/H2VqkM89LzdCKxBogJ8xilxmvZVJWQIYaGPKe7vMe4yrITlJi/Sy98phsffeFRogpaukDuO6QlDUw==",
+ "license": "MIT",
+ "dependencies": {
+ "jsonwebtoken": "^9.0.0"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
+ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
+ "license": "MIT"
+ },
+ "node_modules/prebuild-install": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
+ "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
+ "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
+ "license": "MIT",
+ "dependencies": {
+ "detect-libc": "^2.0.0",
+ "expand-template": "^2.0.3",
+ "github-from-package": "0.0.0",
+ "minimist": "^1.2.3",
+ "mkdirp-classic": "^0.5.3",
+ "napi-build-utils": "^2.0.0",
+ "node-abi": "^3.3.0",
+ "pump": "^3.0.0",
+ "rc": "^1.2.7",
+ "simple-get": "^4.0.0",
+ "tar-fs": "^2.0.0",
+ "tunnel-agent": "^0.6.0"
+ },
+ "bin": {
+ "prebuild-install": "bin.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.8",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
+ "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/pump": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
+ "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
+ "license": "MIT",
+ "dependencies": {
+ "end-of-stream": "^1.1.0",
+ "once": "^1.3.1"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.16.0",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
+ "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "es-define-property": "^1.0.1",
+ "side-channel": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "2.5.3",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/rc": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
+ "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
+ "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
+ "dependencies": {
+ "deep-extend": "^0.6.0",
+ "ini": "~1.3.0",
+ "minimist": "^1.2.0",
+ "strip-json-comments": "~2.0.1"
+ },
+ "bin": {
+ "rc": "cli.js"
+ }
+ },
+ "node_modules/readable-stream": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/send": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
+ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.1",
+ "mime": "1.6.0",
+ "ms": "2.1.3",
+ "on-finished": "~2.4.1",
+ "range-parser": "~1.2.1",
+ "statuses": "~2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/send/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/serve-static": {
+ "version": "1.16.3",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
+ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "parseurl": "~1.3.3",
+ "send": "~0.19.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4",
+ "side-channel-list": "^1.0.1",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/simple-concat": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
+ "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/simple-get": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
+ "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "decompress-response": "^6.0.0",
+ "once": "^1.3.1",
+ "simple-concat": "^1.0.0"
+ }
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/string_decoder": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.2.0"
+ }
+ },
+ "node_modules/strip-json-comments": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
+ "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tar-fs": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
+ "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
+ "license": "MIT",
+ "dependencies": {
+ "chownr": "^1.1.1",
+ "mkdirp-classic": "^0.5.2",
+ "pump": "^3.0.0",
+ "tar-stream": "^2.1.4"
+ }
+ },
+ "node_modules/tar-stream": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
+ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "bl": "^4.0.3",
+ "end-of-stream": "^1.4.1",
+ "fs-constants": "^1.0.0",
+ "inherits": "^2.0.3",
+ "readable-stream": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/tunnel-agent": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
+ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "1.6.18",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "media-typer": "0.3.0",
+ "mime-types": "~2.1.24"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "license": "MIT"
+ },
+ "node_modules/utils-merge": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "license": "ISC"
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,31 @@
@@ -0,0 +1,31 @@
+{
+ "name": "openvibe-tips",
+ "version": "0.1.0",
+ "private": true,
+ "description": "OpenVibe.Tips — creator support on the OpenVibe network: tips, goals, paid messages, TTS and media requests, and overlays. Billing moves the money; Tips owns the interaction.",
+ "main": "server/index.js",
+ "scripts": {
+ "start": "node server/index.js",
+ "dev": "NODE_ENV=development node server/index.js",
+ "test": "node test/run.js",
+ "subscribe": "node scripts/subscribe.js",
+ "import-live": "node scripts/import-live.js"
+ },
+ "keywords": ["openvibe", "tips", "overlays", "creator"],
+ "author": "OpenVibers",
+ "license": "AGPL-3.0-only",
+ "repository": "github:OpenVibers/OpenVibe.Tips",
+ "dependencies": {
+ "better-sqlite3": "^11.10.0",
+ "cookie-parser": "^1.4.7",
+ "dotenv": "^16.4.7",
+ "express": "^4.21.2",
+ "jsonwebtoken": "^9.0.2",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.11.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.2.1"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,16 @@
@@ -0,0 +1,16 @@
+/* OpenVibe.Tips overlay — transparent, for OBS Browser Sources. */
+html, body { margin: 0; background: transparent; color: #fff; font: 600 28px/1.3 system-ui, -apple-system, "Segoe UI", sans-serif; overflow: hidden; }
+#alert { position: fixed; left: 50%; top: 12%; transform: translateX(-50%); width: min(90vw, 760px); text-align: center; opacity: 0; transition: opacity .35s, transform .35s; }
+#alert.show { opacity: 1; transform: translateX(-50%) translateY(6px); }
+#alert .box { background: rgba(10, 15, 28, .82); border: 2px solid #3b82f6; border-radius: 18px; padding: 18px 24px; text-shadow: 0 2px 6px rgba(0, 0, 0, .6); }
+#alert img { max-width: 220px; max-height: 160px; display: block; margin: 0 auto 10px; }
+#alert .title b { color: #60a5fa; }
+#alert .msg { font-weight: 500; font-size: 24px; margin-top: 8px; overflow-wrap: anywhere; }
+#alert .test { font-size: 14px; color: #fbbf24; letter-spacing: .08em; text-transform: uppercase; }
+#goals { position: fixed; left: 24px; right: 24px; bottom: 24px; display: grid; gap: 10px; }
+.g { background: rgba(10, 15, 28, .78); border-radius: 12px; padding: 10px 14px; font-size: 20px; }
+.g .bar { height: 12px; border-radius: 6px; background: rgba(255, 255, 255, .15); overflow: hidden; margin-top: 6px; }
+.g .bar span { display: block; height: 100%; background: #3b82f6; transition: width .6s; }
+.g.reached .bar span { background: #34d399; }
+.g .test { color: #fbbf24; font-size: 14px; }
+.nojs { font-size: 16px; }
new file mode 100644
new file mode 100644
@@ -0,0 +1,64 @@
@@ -0,0 +1,64 @@
+/* OpenVibe.Tips — page styles. Colours follow the shared theme variables (theme-loader.js). */
+:root {
+ --tips-bg: var(--bg-primary, #0a0f1c);
+ --tips-card: var(--bg-secondary, #121a2b);
+ --tips-text: var(--text-primary, #e6edf7);
+ --tips-muted: var(--text-secondary, #9aa7bd);
+ --tips-accent: var(--accent, #3b82f6);
+ --tips-on-accent: var(--on-accent, #ffffff);
+ --tips-border: var(--border, rgba(255, 255, 255, .1));
+ --tips-bad: #f87171;
+ --tips-good: #34d399;
+}
+* { box-sizing: border-box; }
+body { margin: 0; background: var(--tips-bg); color: var(--tips-text); font: 16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
+a { color: var(--tips-accent); }
+.site-head { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 18px; padding: 12px 20px; border-bottom: 1px solid var(--tips-border); }
+.ov-has-navbar .site-head { display: none; }
+.site-head .brand { font-weight: 700; color: inherit; text-decoration: none; }
+.site-head nav { display: flex; gap: 14px; flex: 1; }
+.site-head nav a { color: var(--tips-muted); text-decoration: none; }
+.site-head nav a[aria-current] { color: var(--tips-text); }
+.page { max-width: 880px; margin: 0 auto; padding: 24px 16px 48px; }
+h1 { font-size: 1.8rem; margin: .2em 0 .5em; }
+.hero p { font-size: 1.1rem; color: var(--tips-muted); }
+.card { background: var(--tips-card); border: 1px solid var(--tips-border); border-radius: 12px; padding: 18px 20px; margin: 18px 0; }
+.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 18px; }
+.grid .card { margin: 0; }
+.btn { display: inline-block; background: var(--tips-accent); color: var(--tips-on-accent); border: 0; border-radius: 8px; padding: 9px 16px; font: inherit; font-weight: 600; cursor: pointer; text-decoration: none; }
+.btn.ghost { background: transparent; color: var(--tips-accent); border: 1px solid var(--tips-accent); }
+form label { display: block; margin: 10px 0; }
+form input[type=text], form input[type=number], form input[type=url], form textarea, form select, input.copy {
+ display: block; width: 100%; margin-top: 4px; padding: 8px 10px; border-radius: 8px; border: 1px solid var(--tips-border);
+ background: var(--tips-bg); color: var(--tips-text); font: inherit; }
+form input[type=checkbox], form input[type=radio] { margin-right: 6px; }
+form.inline { display: inline-flex; flex-wrap: wrap; gap: 8px; align-items: end; margin: 4px 8px 12px 0; }
+form.inline label { margin: 0; }
+fieldset { border: 1px solid var(--tips-border); border-radius: 8px; margin: 12px 0; }
+small, .fine, .meta { color: var(--tips-muted); }
+.fine { font-size: .9rem; }
+.notice { background: rgba(59, 130, 246, .12); border: 1px solid rgba(59, 130, 246, .4); border-radius: 8px; padding: 10px 14px; }
+.notice.error { background: rgba(248, 113, 113, .12); border-color: rgba(248, 113, 113, .5); }
+.creator-head { display: flex; gap: 18px; align-items: center; }
+.creator-head .avatar { border-radius: 50%; object-fit: cover; }
+.headline { color: var(--tips-muted); margin: 0; }
+.goal { margin: 12px 0; }
+.goal h3 { margin: 0 0 4px; font-size: 1.05rem; }
+.bar { height: 12px; border-radius: 6px; background: var(--tips-border); overflow: hidden; }
+.bar span { display: block; height: 100%; background: var(--tips-accent); }
+.goal.reached .bar span { background: var(--tips-good); }
+table.list { width: 100%; border-collapse: collapse; font-size: .95rem; }
+table.list th, table.list td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--tips-border); }
+.num { text-align: right !important; font-variant-numeric: tabular-nums; }
+tr.test td, tr.revoked td { color: var(--tips-muted); }
+dl.receipt { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; }
+dl.receipt dt { color: var(--tips-muted); }
+dl.receipt dd { margin: 0; overflow-wrap: anywhere; }
+.big { font-size: 1.8rem; font-weight: 700; margin: 0; }
+.creators { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 10px; }
+.creators a { display: flex; align-items: center; gap: 10px; padding: 8px; border-radius: 8px; background: var(--tips-card); text-decoration: none; color: inherit; }
+.creators img { border-radius: 50%; }
+.steps li { margin: 8px 0; }
+.js .tip-form .when-tts, .js .tip-form .when-media { display: none; }
+.js .tip-form[data-kind=tts] .when-tts, .js .tip-form[data-kind=media_request] .when-media { display: block; }
+@media (max-width: 560px) { dl.receipt { grid-template-columns: 1fr; } .creator-head { flex-direction: column; align-items: flex-start; } table.list { font-size: .85rem; } }
new file mode 100644
new file mode 100644
@@ -0,0 +1,88 @@
@@ -0,0 +1,88 @@
+// OpenVibe.Tips overlay client (OBS Browser Source). The overlay link's token is in the page URL;
+// this script only reads the event stream next to it. Replays after a reconnect (Last-Event-ID)
+// are shown again but change nothing on the server.
+(function () {
+ 'use strict';
+ var base = location.pathname.replace(/\/+$/, '');
+ var alertEl = document.getElementById('alert');
+ var goalsEl = document.getElementById('goals');
+ var config = { settings: { duration_ms: 8000, show_message: true, show_amount: true, template: '{name} tipped {amount} Vibes' } };
+ var goals = {};
+ var queue = [];
+ var busy = false;
+ var shown = {};
+
+ function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
+ function fmt(n) { try { return Number(n).toLocaleString('en-US'); } catch (e) { return String(n); } }
+
+ function renderGoals() {
+ goalsEl.textContent = '';
+ Object.keys(goals).forEach(function (id) {
+ var g = goals[id];
+ if (g.status !== 'active') return;
+ var box = el('div', 'g' + (g.reached ? ' reached' : ''));
+ box.appendChild(el('div', null, g.title + ' — ' + fmt(g.current_amount) + ' / ' + fmt(g.target_amount) + ' Vibes'));
+ if (g._test) box.appendChild(el('div', 'test', 'simulation — not counted'));
+ var bar = el('div', 'bar'); var fill = el('span'); fill.style.width = Math.min(100, g.percent) + '%'; bar.appendChild(fill); box.appendChild(bar);
+ goalsEl.appendChild(box);
+ });
+ }
+
+ function next() {
+ if (busy || !queue.length) return;
+ busy = true;
+ var a = queue.shift();
+ var s = config.settings || {};
+ alertEl.textContent = '';
+ var box = el('div', 'box');
+ if (s.image_url) { var img = el('img'); img.src = s.image_url; img.alt = ''; box.appendChild(img); }
+ if (a.test) box.appendChild(el('div', 'test', 'test alert'));
+ var title = el('div', 'title');
+ var tpl = String(s.template || '{name} tipped {amount} Vibes');
+ var parts = tpl.split(/(\{name\}|\{amount\})/);
+ parts.forEach(function (p) {
+ if (p === '{name}') title.appendChild(el('b', null, a.supporter_name || 'Someone'));
+ else if (p === '{amount}') title.appendChild(document.createTextNode(s.show_amount === false ? '' : fmt(a.amount)));
+ else title.appendChild(document.createTextNode(p));
+ });
+ box.appendChild(title);
+ var text = a.tts ? a.tts.text : a.message;
+ if (s.show_message !== false && text) box.appendChild(el('div', 'msg', text));
+ alertEl.appendChild(box);
+ alertEl.className = 'show';
+ if (s.sound_url) { try { new Audio(s.sound_url).play().catch(function () {}); } catch (e) { /* no audio */ } }
+ if (s.speak_message && a.tts && window.speechSynthesis) {
+ try { window.speechSynthesis.speak(new SpeechSynthesisUtterance(a.tts.text)); } catch (e) { /* no voice */ }
+ }
+ setTimeout(function () {
+ alertEl.className = '';
+ setTimeout(function () { busy = false; next(); }, 400);
+ }, Number(s.duration_ms) || 8000);
+ }
+
+ var es = new EventSource(base + '/events');
+ es.addEventListener('hello', function (e) {
+ var d = JSON.parse(e.data);
+ if (d.config) config = d.config;
+ goals = {};
+ (d.goals || []).forEach(function (g) { goals[g.id] = g; });
+ renderGoals();
+ });
+ es.addEventListener('config', function (e) { var d = JSON.parse(e.data); if (d.config) config = d.config; });
+ es.addEventListener('alert', function (e) {
+ var d = JSON.parse(e.data);
+ if (shown[d.delivery_id]) return; // a replay of one this page already showed
+ shown[d.delivery_id] = 1;
+ queue.push(d);
+ next();
+ });
+ es.addEventListener('goal', function (e) {
+ var d = JSON.parse(e.data);
+ if (!d.goal) return;
+ var g = d.goal; g._test = !!d.test;
+ goals[g.id] = g;
+ renderGoals();
+ if (d.test) setTimeout(function () { if (goals[g.id] && goals[g.id]._test) { delete goals[g.id]; renderGoals(); es.close(); location.reload(); } }, 15000);
+ });
+ es.addEventListener('revoked', function () { es.close(); alertEl.textContent = ''; goalsEl.textContent = ''; });
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,15 @@
@@ -0,0 +1,15 @@
+// OpenVibe.Tips — progressive touches only; every form works without this file.
+(function () {
+ 'use strict';
+ document.documentElement.classList.add('js');
+ document.addEventListener('DOMContentLoaded', function () {
+ var form = document.querySelector('.tip-form');
+ if (!form) return;
+ function sync() {
+ var checked = form.querySelector('input[name=kind]:checked');
+ form.setAttribute('data-kind', checked ? checked.value : 'tip');
+ }
+ form.addEventListener('change', sync);
+ sync();
+ });
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,62 @@
@@ -0,0 +1,62 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * Import OpenVibe.Live's tip history into Tips, linked to OpenVibe.Billing's imported journal.
+ *
+ * node scripts/import-live.js --live-db <live snapshot> [--billing-db <billing snapshot>] [--dry-run] [--json]
+ *
+ * Both databases must be COPIES (e.g. `sqlite3 live.db ".backup live-snapshot.db"`); they are opened
+ * read-only. Run it AFTER Billing's own import (scripts/import-live.js in OpenVibe.Billing) so every
+ * Live donation links to its Billing transaction. Live user ids are resolved to Network subjects
+ * through resolve-batch with Tips' client credentials (capability identity.subject.resolve).
+ *
+ * With --billing-db the report ends with the reconciliation: per creator, Tips' settled total must
+ * equal Billing's; the exit code is 1 when it does not. Safe to re-run.
+ */
+const path = require('path');
+const Database = require('better-sqlite3');
+const { loadConfig } = require('../server/config');
+const { openDb } = require('../server/db');
+const { createIdentity } = require('../server/network');
+const { createTipsOutbox } = require('../server/events/outbox');
+const { createDomain } = require('../server/domain');
+const { importLive, billingSnapshotSource } = require('../server/importer/live');
+
+const args = process.argv.slice(2);
+const opt = (name) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : null; };
+const flag = (name) => args.includes(`--${name}`);
+
+async function main() {
+ const livePath = opt('live-db');
+ if (!livePath) { console.error('usage: import-live.js --live-db <snapshot> [--billing-db <snapshot>] [--dry-run] [--json]'); process.exit(2); }
+ const config = loadConfig();
+ for (const p of [livePath, opt('billing-db')].filter(Boolean)) if (path.resolve(p) === path.resolve(config.dbPath)) throw new Error(`${p} is the Tips database`);
+ const live = new Database(livePath, { readonly: true, fileMustExist: true });
+ const bdb = opt('billing-db') ? new Database(opt('billing-db'), { readonly: true, fileMustExist: true }) : null;
+ const db = openDb(config.dbPath);
+ const outbox = createTipsOutbox({ db, config });
+ const domain = createDomain({ db, config, outbox, billing: null, adapters: {}, log: console });
+ const identity = createIdentity(config);
+ const report = await importLive(domain, { live, billingSource: bdb ? billingSnapshotSource(bdb) : null, resolveLiveUsers: identity.resolveLiveUsers, dryRun: flag('dry-run') });
+ if (flag('json')) console.log(JSON.stringify(report, null, 2));
+ else {
+ const c = report.counts;
+ console.log(`import ${report.run_id}${report.dry_run ? ' (DRY RUN — nothing kept)' : ''}`);
+ console.log(` live snapshot: ${JSON.stringify(c.live)}; identities ${JSON.stringify(c.identities)}; test before ${report.test_before || '(none)'}`);
+ console.log(` imported ${c.imported} tips (${c.linked_to_billing} linked to Billing, ${c.unlinked} not), ${c.refunds_applied} refunds applied, ${c.external} external PowerChat tips, ${c.goals} goals`);
+ console.log(` held ${c.held}, excluded ${c.excluded}, unchanged ${c.unchanged}, released from hold ${c.released}`);
+ for (const h of report.holds) console.log(` held: ${JSON.stringify(h)}`);
+ const r = report.reconciliation;
+ if (!r) console.log(' reconciliation: skipped (no --billing-db)');
+ else {
+ console.log(` reconciliation: ${r.ok ? 'OK' : 'MISMATCH'} over ${r.creators} creators`);
+ for (const m of r.mismatches) console.log(` ${m.creator}: tips ${m.tips} vs billing ${m.billing} (difference ${m.difference})`);
+ }
+ }
+ live.close();
+ if (bdb) bdb.close();
+ db.close();
+ if (report.reconciliation && !report.reconciliation.ok) process.exit(1);
+}
+
+main().catch((e) => { console.error(e.message); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,42 @@
@@ -0,0 +1,42 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * Create Tips' OpenVibe.Events subscription for Billing's settlement events.
+ *
+ * node scripts/subscribe.js [--endpoint http://127.0.0.1:4610/internal/events] [--pattern 'billing.transaction.*']
+ *
+ * Reads the environment (.env or /etc/openvibe/tips.env): EVENTS_URL, OV_NETWORK_INTERNAL_URL,
+ * OV_OAUTH_CLIENT_ID, OV_OAUTH_CLIENT_SECRET (the tips principal needs events.subscription.manage for
+ * audience openvibe.events) and TIPS_EVENTS_SECRET — the delivery signing secret, handed to Events
+ * here, so generate it first (`openssl rand -hex 32`). Nothing secret is printed. An existing
+ * identical subscription is reported, not duplicated.
+ */
+require('dotenv').config();
+const { serviceAuth } = require('openvibe-contracts');
+const { loadConfig } = require('../server/config');
+
+const args = process.argv.slice(2);
+const opt = (name, d) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : d; };
+
+(async () => {
+ const config = loadConfig();
+ const endpoint = opt('endpoint', `http://127.0.0.1:${config.port}/internal/events`);
+ const pattern = opt('pattern', 'billing.transaction.*');
+ const secret = config.events.webhookSecrets[0];
+ if (!config.events.url) throw new Error('EVENTS_URL is not set');
+ if (!secret || secret.length < 32) throw new Error('TIPS_EVENTS_SECRET must be set (32+ characters) before subscribing');
+ if (!config.oauth.clientSecret) throw new Error('OV_OAUTH_CLIENT_SECRET is not set');
+ const tokens = serviceAuth.createTokenClient({
+ tokenUrl: `${config.network.internalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
+ audience: 'openvibe.events', scope: 'events.subscription.manage',
+ });
+ const res = await fetch(`${config.events.url}/api/v1/subscriptions`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', ...(await tokens.authHeaders()) },
+ body: JSON.stringify({ topic_pattern: pattern, endpoint, secret }),
+ });
+ const body = await res.json().catch(() => ({}));
+ if (res.status === 409 && body.subscription_id) { console.log(`subscription exists: ${body.subscription_id} (${pattern} → ${endpoint})`); return; }
+ if (!res.ok) throw new Error(`Events answered ${res.status}: ${body.code || ''} ${body.detail || ''}`.trim());
+ console.log(`subscribed: ${body.id} (${pattern} → ${endpoint}). Replay history with POST /api/v1/deliveries/replay { subscription_id, from_seq }.`);
+})().catch((err) => { console.error(`subscribe failed: ${err.message}`); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,75 @@
@@ -0,0 +1,75 @@
+'use strict';
+
+/**
+ * Who is calling /api/v1 — resolved into req.principal:
+ *
+ * { kind: 'service', sub: 'svc:live', cap: [...] }
+ * A Network client-credentials token for audience openvibe.tips. Each route checks ONE
+ * capability against the token's `cap` claim. The tips.* capabilities are proposals (not yet in
+ * an openvibe-contracts release), so the grant is matched with contracts' capabilities.grants()
+ * (exact id or a `.*` family) — the same interim rule Billing used before v0.8.0.
+ * { kind: 'user', subject: 'usr_…', username, name, avatar, role }
+ * A browser's Network user JWT, presented as `Authorization: Bearer`. Users act only on their
+ * own things (their profile, goals, overlays, receipts; tips paid by themselves). The API never
+ * reads cookies, so a cross-site form cannot drive it; the SSR pages carry their own
+ * anti-forgery tokens.
+ * { kind: 'anonymous' }
+ *
+ * A request that presents a token is judged on that token alone: a bad one is refused, never
+ * downgraded to anonymous.
+ */
+const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');
+
+const PRINCIPAL_SUB = /^(svc|app|mod):/;
+const ANON = Object.freeze({ kind: 'anonymous' });
+
+function decodePayload(token) {
+ const parts = String(token || '').split('.');
+ if (parts.length !== 3) return null;
+ try { return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); } catch { return null; }
+}
+
+/** A Network user token's claims → the user principal (null when it names no subject). */
+function userPrincipal(claims) {
+ if (!claims) return null;
+ const subject = ids.isSubjectId('user', claims.subject_id) ? claims.subject_id : null;
+ if (!subject) return null;
+ return {
+ kind: 'user', subject, username: claims.username || null, name: claims.display_name || claims.username || null,
+ avatar: claims.avatar_url || null, role: claims.role || 'user',
+ };
+}
+
+function createApiAuth({ config, keys, userAuth }) {
+ function resolve(req) {
+ const header = String(req.headers.authorization || '');
+ if (!header.startsWith('Bearer ')) return { principal: ANON };
+ const token = header.slice(7).trim();
+ const publicKey = keys.get();
+ if (!publicKey) return { error: [503, 'identity.unavailable', 'the Network signing key is not loaded yet'] };
+ const payload = decodePayload(token);
+ if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {
+ const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.network.issuer, audience: config.audience });
+ if (!r.ok) return { error: [401, r.code, r.reason] };
+ return { principal: { kind: 'service', sub: r.claims.sub, cap: r.claims.cap || [], jti: r.claims.jti } };
+ }
+ const claims = userAuth.verify(token);
+ if (!claims) return { error: [401, 'token.invalid', 'the user token is invalid or expired'] };
+ const p = userPrincipal(claims);
+ if (!p) return { error: [403, 'identity.no_subject', 'this account has no canonical subject yet; sign in again'] };
+ return { principal: p };
+ }
+
+ function middleware(req, res, next) {
+ const r = resolve(req);
+ if (r.error) return http.sendProblem(res, r.error[0], r.error[1], { detail: r.error[2], ctx: req.ov });
+ req.principal = r.principal;
+ return next();
+ }
+
+ const granted = (p, cap) => p.kind === 'service' && capabilities.grants(p.cap, cap);
+
+ return { middleware, resolve, granted };
+}
+
+module.exports = { createApiAuth, userPrincipal, decodePayload, PRINCIPAL_SUB };
new file mode 100644
new file mode 100644
@@ -0,0 +1,51 @@
@@ -0,0 +1,51 @@
+'use strict';
+
+/**
+ * Idempotency-Key for every mutating /api/v1 call (Billing's rule). The key is scoped to the
+ * calling principal. The first 2xx response is stored with a hash of the request; a replay with the
+ * same key and request returns it verbatim (Idempotent-Replayed: true) and does nothing; the same
+ * key with another request is 422 idempotency.key_reused. Refusals are not stored.
+ *
+ * The scoped key also becomes the interaction's idempotency_key (UNIQUE), the second line of defence.
+ */
+const crypto = require('crypto');
+const { http } = require('openvibe-contracts');
+
+const KEY_RE = /^[A-Za-z0-9._:-]{8,200}$/;
+
+function stable(v) {
+ if (Array.isArray(v)) return `[${v.map(stable).join(',')}]`;
+ if (v && typeof v === 'object') return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${stable(v[k])}`).join(',')}}`;
+ return JSON.stringify(v === undefined ? null : v);
+}
+
+const principalKey = (p) => (p.kind === 'service' ? p.sub : p.kind === 'user' ? p.subject : 'anonymous');
+
+function idempotent(db, now) {
+ return function idempotencyKey(req, res, next) {
+ const key = req.get('Idempotency-Key');
+ if (!key || !KEY_RE.test(key)) {
+ return http.sendProblem(res, 400, 'idempotency.key_required', { detail: 'mutating calls need an Idempotency-Key header (8-200 of A-Z a-z 0-9 . _ : -)', ctx: req.ov });
+ }
+ const scoped = `${principalKey(req.principal)}:${key}`;
+ const hash = crypto.createHash('sha256').update(`${req.method} ${req.baseUrl}${req.path}\n${stable(req.body || {})}`).digest('hex');
+ const row = db.prepare('SELECT * FROM api_idempotency WHERE key = ?').get(scoped);
+ if (row) {
+ if (row.request_hash !== hash) return http.sendProblem(res, 422, 'idempotency.key_reused', { detail: 'this Idempotency-Key was used for a different request', ctx: req.ov });
+ res.setHeader('Idempotent-Replayed', 'true');
+ return res.status(row.status).json(JSON.parse(row.response));
+ }
+ req.idempotencyKey = `api:${scoped}`;
+ const json = res.json.bind(res);
+ res.json = (body) => {
+ if (res.statusCode >= 200 && res.statusCode < 300) {
+ db.prepare('INSERT OR IGNORE INTO api_idempotency (key, request_hash, method, path, status, response, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
+ .run(scoped, hash, req.method, `${req.baseUrl}${req.path}`, res.statusCode, JSON.stringify(body), new Date(now()).toISOString());
+ }
+ return json(body);
+ };
+ return next();
+ };
+}
+
+module.exports = { idempotent, stable, principalKey };
new file mode 100644
new file mode 100644
@@ -0,0 +1,320 @@
@@ -0,0 +1,320 @@
+'use strict';
+
+/**
+ * /api/v1 — Tips' API for services (capability-guarded Network service tokens, audience
+ * openvibe.tips) and for people (their Network user token as a Bearer; they act on their own
+ * things only). Every POST/PATCH needs an Idempotency-Key. Errors are RFC 9457 problem+json.
+ * Amounts are integer Vibes bits; people are SubjectRefs { type: 'user', id: 'usr_…' }.
+ *
+ * | Method & path | Capability (services) | People |
+ * |----------------------------------------|------------------------------|-------------------------------------|
+ * | GET /profiles/:creator | tips.profile.get | anyone when the page is on; owner |
+ * | PATCH /profiles/:creator | tips.profile.update | owner (`me` creates it) |
+ * | GET /profiles/:creator/totals | tips.interaction.list | owner |
+ * | POST /checkout | tips.checkout.create | the supporter |
+ * | POST /paid-messages | tips.superchat.create | the supporter |
+ * | POST /tts-requests | tips.tts.request | the supporter |
+ * | POST /media-requests | tips.media_request.create | the supporter |
+ * | GET /interactions/:id | tips.interaction.get | its creator or supporter |
+ * | GET /interactions | tips.interaction.list | own receipts; ?as=creator own tips |
+ * | POST /interactions/external | tips.interaction.record | — |
+ * | GET /goals?creator=, /goals/:id | tips.goal.update (private) | anyone when the page is on; owner |
+ * | POST /goals | tips.goal.create | owner |
+ * | PATCH /goals/:id | tips.goal.update | owner |
+ * | POST /goals/:id/close | tips.goal.close | owner |
+ * | GET|POST /overlay-tokens | tips.overlay.token.create | owner |
+ * | POST /overlay-tokens/:id/revoke | tips.overlay.token.revoke | owner |
+ * | GET /overlay-configs[/:id] | tips.overlay.config.get | owner |
+ * | POST /overlay-configs, PATCH …/:id | tips.overlay.config.update | owner |
+ * | POST /simulate | tips.simulation.run | owner |
+ */
+const express = require('express');
+const { http } = require('openvibe-contracts');
+const { TipsError, fail, userSubject, displayName } = require('../util');
+const { idempotent } = require('./idempotency');
+
+const CAP = {
+ profileGet: 'tips.profile.get',
+ profileUpdate: 'tips.profile.update',
+ checkout: 'tips.checkout.create',
+ superchat: 'tips.superchat.create',
+ tts: 'tips.tts.request',
+ media: 'tips.media_request.create',
+ interactionGet: 'tips.interaction.get',
+ interactionList: 'tips.interaction.list',
+ interactionRecord: 'tips.interaction.record',
+ goalCreate: 'tips.goal.create',
+ goalUpdate: 'tips.goal.update',
+ goalClose: 'tips.goal.close',
+ tokenCreate: 'tips.overlay.token.create',
+ tokenRevoke: 'tips.overlay.token.revoke',
+ configGet: 'tips.overlay.config.get',
+ configUpdate: 'tips.overlay.config.update',
+ simulate: 'tips.simulation.run',
+};
+
+function v1Router({ domain, apiAuth }) {
+ const r = express.Router();
+ const { db, profiles, goals, interactions, overlays } = domain;
+ const idem = idempotent(db, domain.now);
+
+ /** Service with `cap`, or the user who owns `owner` (subject). Anonymous → 401. */
+ function allow(req, cap, owner) {
+ const p = req.principal;
+ if (p.kind === 'anonymous') fail(401, 'token.missing', 'sign in (a Network user token) or present a service token');
+ if (p.kind === 'service') { if (!apiAuth.granted(p, cap)) fail(403, 'capability.denied', `${cap} not granted`); return 'service'; }
+ if (owner && p.subject === owner) return 'owner';
+ fail(403, 'tips.forbidden', 'this belongs to someone else');
+ return null;
+ }
+ /** The creator a call is about: body/query `creator` (subject or handle), or the signed-in user. */
+ function creatorOf(req, value, { mustExist = true } = {}) {
+ const v = value != null && value !== '' ? value : (req.principal.kind === 'user' ? req.principal.subject : null);
+ if (!v) fail(422, 'tips.invalid_input', 'creator is required');
+ const ref = typeof v === 'object' ? userSubject(v, 'creator') : String(v);
+ const profile = ref === 'me' && req.principal.kind === 'user' ? profiles.bySubject(req.principal.subject) : profiles.resolve(ref);
+ if (!profile && mustExist) fail(404, 'tips.creator_not_found', 'no tip profile for this creator');
+ return profile;
+ }
+ const write = (handler) => [idem, wrap(handler)];
+
+ // ── Profiles ─────────────────────────────────────────────
+ r.get('/profiles/:creator', wrap((req, res) => {
+ const p = creatorOf(req, req.params.creator);
+ const who = req.principal;
+ const mine = who.kind === 'user' && who.subject === p.creator_subject;
+ const svc = who.kind === 'service' && apiAuth.granted(who, CAP.profileGet);
+ // A page that is switched off does not exist for anyone but its creator and granted services.
+ if (!p.page_enabled && !mine && !svc) fail(404, 'tips.creator_not_found', 'no tip profile for this creator');
+ const owner = mine || svc;
+ res.json({ profile: profiles.present(p, { owner }), goals: goals.list(p.creator_subject, { status: 'active' }).map((g) => goals.present(g)) });
+ }));
+
+ r.patch('/profiles/:creator', ...write((req, res) => {
+ const b = req.body || {};
+ let p;
+ if (req.params.creator === 'me') {
+ if (req.principal.kind !== 'user') fail(401, 'token.missing', '`me` needs a user token');
+ p = profiles.ensure(req.principal.subject, { username: req.principal.username, displayName: req.principal.name, avatarUrl: req.principal.avatar });
+ } else if (req.principal.kind === 'service' && /^usr_/.test(req.params.creator) && !profiles.bySubject(req.params.creator)) {
+ allow(req, CAP.profileUpdate);
+ // A service opening a profile for a creator names the account projection it knows.
+ if (!b.handle) fail(422, 'tips.invalid_input', 'handle is required to create a profile');
+ p = profiles.ensure(userSubject(req.params.creator, 'creator'), { username: b.handle, displayName: b.display_name || b.handle, avatarUrl: b.avatar_url });
+ } else {
+ p = creatorOf(req, req.params.creator);
+ }
+ allow(req, CAP.profileUpdate, p.creator_subject);
+ const out = profiles.update(p.creator_subject, b, { expectedRevision: b.revision });
+ res.json({ profile: profiles.present(out, { owner: true }) });
+ }));
+
+ r.get('/profiles/:creator/totals', wrap((req, res) => {
+ const p = creatorOf(req, req.params.creator);
+ allow(req, CAP.interactionList, p.creator_subject);
+ res.json({ creator: { type: 'user', id: p.creator_subject }, totals: interactions.totals(p.creator_subject) });
+ }));
+
+ // ── Paid requests (checkout / credit) ────────────────────
+ function paid(kind, cap) {
+ return write(async (req, res) => {
+ const b = req.body || {};
+ const p = req.principal;
+ if (p.kind === 'anonymous') fail(401, 'token.missing', 'sign in to tip');
+ let supporter;
+ let supporterName;
+ if (p.kind === 'service') {
+ allow(req, cap);
+ supporter = userSubject(b.supporter, 'supporter');
+ supporterName = displayName(b.supporter_name) || null;
+ } else {
+ supporter = p.subject;
+ supporterName = displayName(b.supporter_name) || p.name || p.username;
+ }
+ const profile = creatorOf(req, b.creator);
+ const funding = b.pay_with || 'credit';
+ const tp = req.ov && req.ov.traceparent;
+ const { interaction, replay } = interactions.request(profile, { ...b, kind }, { supporter, supporterName, funding, idempotencyKey: req.idempotencyKey });
+ let i = interaction;
+ let checkout = null;
+ if (!replay) {
+ if (funding === 'credit') {
+ const out = await interactions.transfer(i, { traceparent: tp });
+ i = interactions.get(i.id);
+ if (out.refused) {
+ fail(out.refused === 'billing.insufficient_funds' ? 409 : 422, out.refused, out.detail || 'Billing refused the tip', { interaction: interactions.present(i, { viewer: 'supporter' }) });
+ }
+ } else {
+ checkout = await interactions.startCheckout(i, { provider: b.provider, traceparent: tp });
+ i = checkout.interaction;
+ }
+ } else if (i.payment_state === 'failed') {
+ fail(409, 'tips.payment_failed', i.failure || 'the payment for this request failed', { interaction: interactions.present(i, { viewer: 'supporter' }) });
+ }
+ res.status(replay ? 200 : 201).json({
+ interaction: interactions.present(i, { viewer: p.kind === 'service' ? 'service' : 'supporter' }),
+ checkout_url: i.checkout_url || null, checkout_ref: i.checkout_ref || null,
+ });
+ });
+ }
+ r.post('/checkout', ...paid('tip', CAP.checkout));
+ r.post('/paid-messages', ...paid('paid_message', CAP.superchat));
+ r.post('/tts-requests', ...paid('tts', CAP.tts));
+ r.post('/media-requests', ...paid('media_request', CAP.media));
+
+ // ── Interactions ─────────────────────────────────────────
+ r.get('/interactions', wrap((req, res) => {
+ const p = req.principal;
+ const q = req.query;
+ let filter;
+ if (p.kind === 'service') {
+ allow(req, CAP.interactionList);
+ filter = {
+ creator: q.creator ? (creatorOf(req, q.creator).creator_subject) : null,
+ supporter: q.supporter ? userSubject(String(q.supporter), 'supporter') : null,
+ };
+ if (!filter.creator && !filter.supporter) fail(422, 'tips.invalid_input', 'creator or supporter is required');
+ } else if (p.kind === 'user') {
+ filter = q.as === 'creator' ? { creator: p.subject } : { supporter: p.subject };
+ } else fail(401, 'token.missing', 'sign in to see receipts');
+ const out = interactions.list({ ...filter, includeTest: q.include_test !== '0', cursor: q.cursor, limit: q.limit });
+ const viewer = p.kind === 'service' ? 'service' : (filter.creator ? 'owner' : 'supporter');
+ res.json({ interactions: out.rows.map((i) => interactions.present(i, { viewer })), next_cursor: out.next_cursor });
+ }));
+
+ r.get('/interactions/:id', wrap((req, res) => {
+ const i = interactions.get(req.params.id);
+ const p = req.principal;
+ if (!i) fail(404, 'tips.interaction_not_found', 'no such interaction');
+ let viewer;
+ if (p.kind === 'service') { allow(req, CAP.interactionGet); viewer = 'service'; }
+ else if (p.kind === 'user' && p.subject === i.creator_subject) viewer = 'owner';
+ else if (p.kind === 'user' && p.subject === i.supporter_subject) viewer = 'supporter';
+ else if (p.kind === 'anonymous') fail(401, 'token.missing', 'sign in to see this receipt');
+ else fail(404, 'tips.interaction_not_found', 'no such interaction');
+ res.json({ interaction: interactions.present(i, { viewer }) });
+ }));
+
+ r.post('/interactions/external', ...write((req, res) => {
+ allow(req, CAP.interactionRecord);
+ const b = req.body || {};
+ const profile = creatorOf(req, b.creator);
+ const out = interactions.recordExternal(profile, b);
+ res.status(out.replay ? 200 : 201).json({ interaction: interactions.present(out.interaction, { viewer: 'service' }), duplicate: out.replay });
+ }));
+
+ // ── Goals ────────────────────────────────────────────────
+ function goalVisible(req, g) {
+ const profile = profiles.bySubject(g.creator_subject);
+ if (profile && profile.page_enabled) return true;
+ const p = req.principal;
+ return (p.kind === 'user' && p.subject === g.creator_subject) || (p.kind === 'service' && apiAuth.granted(p, CAP.goalUpdate));
+ }
+ r.get('/goals', wrap((req, res) => {
+ const profile = creatorOf(req, req.query.creator);
+ const sample = { creator_subject: profile.creator_subject };
+ if (!goalVisible(req, sample)) fail(404, 'tips.creator_not_found', 'no tip profile for this creator');
+ const status = ['active', 'closed'].includes(req.query.status) ? req.query.status : undefined;
+ res.json({ goals: goals.list(profile.creator_subject, { status }).map((g) => goals.present(g)) });
+ }));
+ r.get('/goals/:id', wrap((req, res) => {
+ const g = goals.get(req.params.id);
+ if (!g || !goalVisible(req, g)) fail(404, 'tips.goal_not_found', 'no such goal');
+ const mine = (req.principal.kind === 'user' && req.principal.subject === g.creator_subject) || req.principal.kind === 'service';
+ res.json({ goal: goals.present(g, { contributions: mine }) });
+ }));
+ r.post('/goals', ...write((req, res) => {
+ const b = req.body || {};
+ const profile = creatorOf(req, b.creator);
+ allow(req, CAP.goalCreate, profile.creator_subject);
+ res.status(201).json({ goal: goals.create(profile.creator_subject, b) });
+ }));
+ const ownGoal = (req, cap) => {
+ const g = goals.get(req.params.id);
+ if (!g) fail(404, 'tips.goal_not_found', 'no such goal');
+ allow(req, cap, g.creator_subject);
+ return g;
+ };
+ r.patch('/goals/:id', ...write((req, res) => res.json({ goal: goals.update(ownGoal(req, CAP.goalUpdate), req.body || {}) })));
+ r.post('/goals/:id/close', ...write((req, res) => res.json({ goal: goals.close(ownGoal(req, CAP.goalClose)) })));
+
+ // ── Overlay tokens and configs ───────────────────────────
+ r.get('/overlay-tokens', wrap((req, res) => {
+ const profile = creatorOf(req, req.query.creator);
+ allow(req, CAP.tokenCreate, profile.creator_subject);
+ res.json({ tokens: overlays.listTokens(profile.creator_subject).map(overlays.presentToken) });
+ }));
+ r.post('/overlay-tokens', ...write((req, res) => {
+ const b = req.body || {};
+ const profile = creatorOf(req, b.creator);
+ allow(req, CAP.tokenCreate, profile.creator_subject);
+ const by = req.principal.kind === 'service' ? req.principal.sub : req.principal.subject;
+ const out = overlays.createToken(profile.creator_subject, { scopes: b.scopes, label: b.label, configId: b.config_id, createdBy: by });
+ // Shown once: only its hash is stored. An idempotent replay of this call returns it again to
+ // the same caller with the same key, which is the point of the key.
+ res.status(201).json({ token: out.token, secret: out.secret, overlay_url: out.overlay_url, events_url: out.events_url });
+ }));
+ r.post('/overlay-tokens/:id/revoke', ...write((req, res) => {
+ const t = overlays.getToken(req.params.id);
+ if (!t) fail(404, 'tips.overlay_token_not_found', 'no such overlay token');
+ allow(req, CAP.tokenRevoke, t.creator_subject);
+ res.json({ token: overlays.revokeToken(t) });
+ }));
+
+ r.get('/overlay-configs', wrap((req, res) => {
+ const profile = creatorOf(req, req.query.creator);
+ allow(req, CAP.configGet, profile.creator_subject);
+ res.json({ configs: overlays.listConfigs(profile.creator_subject).map(overlays.presentConfig) });
+ }));
+ r.get('/overlay-configs/:id', wrap((req, res) => {
+ const c = overlays.getConfig(req.params.id);
+ if (!c) fail(404, 'tips.overlay_config_not_found', 'no such overlay config');
+ allow(req, CAP.configGet, c.creator_subject);
+ res.json({ config: overlays.presentConfig(c) });
+ }));
+ r.post('/overlay-configs', ...write((req, res) => {
+ const b = req.body || {};
+ const profile = creatorOf(req, b.creator);
+ allow(req, CAP.configUpdate, profile.creator_subject);
+ res.status(201).json({ config: overlays.createConfig(profile.creator_subject, b) });
+ }));
+ r.patch('/overlay-configs/:id', ...write((req, res) => {
+ const c = overlays.getConfig(req.params.id);
+ if (!c) fail(404, 'tips.overlay_config_not_found', 'no such overlay config');
+ allow(req, CAP.configUpdate, c.creator_subject);
+ res.json({ config: overlays.updateConfig(c, req.body || {}) });
+ }));
+
+ // ── Simulation ───────────────────────────────────────────
+ r.post('/simulate', ...write((req, res) => {
+ const b = req.body || {};
+ const profile = creatorOf(req, b.creator);
+ allow(req, CAP.simulate, profile.creator_subject);
+ const by = req.principal.kind === 'service' ? req.principal.sub : req.principal.subject;
+ const i = interactions.simulate(profile, b, { by });
+ res.status(201).json({ interaction: interactions.present(i, { viewer: 'owner' }) });
+ }));
+
+ return r;
+}
+
+/** Async-safe handler: TipsError → problem+json; anything else → 500. */
+function wrap(fn) {
+ return (req, res, next) => {
+ try {
+ const p = fn(req, res, next);
+ if (p && typeof p.catch === 'function') p.catch((e) => sendError(req, res, e));
+ } catch (e) { sendError(req, res, e); }
+ };
+}
+
+function sendError(req, res, e) {
+ if (res.headersSent) return;
+ if (e instanceof TipsError) {
+ return http.sendProblem(res, e.status, e.code, { detail: e.detail || e.message, ctx: req.ov, extra: e.extra && e.status < 500 ? { details: e.extra } : undefined });
+ }
+ console.error('[Tips] unexpected error:', e);
+ return http.sendProblem(res, 500, 'tips.internal', { detail: 'internal error', ctx: req.ov });
+}
+
+module.exports = { v1Router, wrap, sendError, CAP };
new file mode 100644
new file mode 100644
@@ -0,0 +1,120 @@
@@ -0,0 +1,120 @@
+'use strict';
+
+/**
+ * OpenVibe.Tips — creator support: tips, goals, paid messages, TTS and media requests, overlays.
+ * Express app factory; server/index.js listens, tests build their own instance.
+ *
+ * GET /api/health, /api/ready, /release.json
+ * /api/v1/* API (service tokens + user tokens, see api/v1.js)
+ * POST /internal/events Billing settlement events from OpenVibe.Events (signed webhook + inbox)
+ * /auth/* Network SSO session for the pages
+ * /overlay/:token[/events|/state] overlays (scoped revocable tokens)
+ * /, /dashboard, /receipts, /:handle … server-rendered pages
+ *
+ * createApp({ config, db, keys, billing, adapters, now, fetchImpl, log }) — everything injectable.
+ */
+const path = require('path');
+const express = require('express');
+const cookieParser = require('cookie-parser');
+const { http } = require('openvibe-contracts');
+const { loadConfig } = require('./config');
+const { openDb } = require('./db');
+const { createKeyProvider, createUserAuth } = require('./network');
+const { createBillingClient } = require('./billing-client');
+const { createAdapters } = require('./delivery');
+const { createTipsOutbox } = require('./events/outbox');
+const { consumerRouter } = require('./events/consumer');
+const { createDomain } = require('./domain');
+const { createApiAuth } = require('./api/auth');
+const { v1Router } = require('./api/v1');
+const { createSessionRoutes } = require('./web/session');
+const { createWebRoutes } = require('./web/routes');
+const { createLayout, assetVersion } = require('./web/layout');
+const pages = require('./web/pages');
+
+const VERSION = require('../package.json').version;
+const PUBLIC_DIR = path.join(__dirname, '..', 'public');
+
+function createApp(opts = {}) {
+ const config = opts.config || loadConfig();
+ const db = opts.db || openDb(config.dbPath);
+ const fetchImpl = opts.fetchImpl || globalThis.fetch;
+ const log = opts.log || console;
+ const now = opts.now || (() => Date.now());
+ const keys = opts.keys || createKeyProvider(config, { fetchImpl, log });
+ const userAuth = createUserAuth(config, keys);
+ const billing = opts.billing || createBillingClient(config, { fetchImpl });
+ const adapters = opts.adapters || createAdapters(config, { fetchImpl });
+ const outbox = opts.outbox || createTipsOutbox({ db, config, fetchImpl: opts.eventsFetch, now, log });
+ const domain = createDomain({ db, config, outbox, billing, adapters, now, log });
+ const apiAuth = createApiAuth({ config, keys, userAuth });
+ const release = require('openvibe-shared/release').createRelease({ service: 'tips', root: path.join(__dirname, '..') });
+ const layout = createLayout({ config, release });
+
+ const app = express();
+ app.disable('x-powered-by');
+ app.set('trust proxy', config.trustProxy);
+ app.use(http.middleware());
+ app.use((req, res, next) => {
+ res.setHeader('X-Content-Type-Options', 'nosniff');
+ res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
+ // Pages frame nothing but the Network's /sso/check; overlays are loaded by OBS, not framed by sites.
+ res.setHeader('Content-Security-Policy', [
+ "default-src 'self'", "script-src 'self' 'unsafe-inline' https://openvibe.network", "style-src 'self' 'unsafe-inline' https://openvibe.network",
+ "img-src 'self' data: https:", "media-src 'self' https:", "connect-src 'self' https://openvibe.network", "frame-src 'self' https://openvibe.network",
+ "frame-ancestors 'self'", "object-src 'none'", "base-uri 'self'", "form-action 'self' https://openvibe.network https:",
+ ].join('; '));
+ next();
+ });
+ app.use(cookieParser());
+
+ app.get('/api/health', (req, res) => res.json({
+ ok: true, service: 'tips', version: VERSION, chat_adapter: config.chat.adapter, events: outbox.status(),
+ }));
+ app.get('/api/ready', (req, res) => {
+ const problems = [];
+ try { db.prepare('SELECT 1 FROM settings WHERE id = 1').get(); } catch (e) { problems.push(`database: ${e.message}`); }
+ if (!keys.get()) problems.push('Network public key not loaded');
+ if (problems.length) return http.sendProblem(res, 503, 'service.not_ready', { detail: problems.join('; '), ctx: req.ov });
+ return res.json({ ready: true });
+ });
+ app.get('/release.json', release.handler);
+
+ const consumer = consumerRouter({ domain, config, log });
+ // Service-to-service only: OpenVibe.Events calls 127.0.0.1:4610 directly; anything that came
+ // through nginx carries X-Forwarded-For and is refused (the signature is checked as well).
+ app.use('/internal', (req, res, next) => (req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.headers['cf-connecting-ip']
+ ? http.sendProblem(res, 404, 'not_found', { ctx: req.ov }) : next()), consumer.router);
+ app.use('/api/v1', express.json({ limit: '64kb' }), (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); }, apiAuth.middleware, v1Router({ domain, apiAuth }));
+ app.use('/auth', createSessionRoutes(config, userAuth, { fetchImpl }));
+ { const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'tips', service: 'tips', host: 'openvibe.tips', name: 'OpenVibe.Tips', profile: 'ugc' })); }
+
+ app.use(express.static(PUBLIC_DIR, {
+ index: false, redirect: false,
+ setHeaders(res, filePath) {
+ const rel = path.relative(PUBLIC_DIR, filePath).split(path.sep).join('/');
+ const v = res.req && res.req.query && res.req.query.v;
+ res.setHeader('Cache-Control', v && v === assetVersion(rel) ? 'public, max-age=31536000, immutable' : 'no-cache');
+ },
+ }));
+ app.use(createWebRoutes({ domain, config, layout, userAuth }));
+
+ app.use((req, res) => {
+ if (req.path.startsWith('/api/') || req.path.startsWith('/internal/')) return http.sendProblem(res, 404, 'not_found', { ctx: req.ov });
+ return res.status(404).type('html').send(layout.page({ title: 'Not found', robots: 'noindex', body: pages.errorPage({ status: 404, title: 'Nothing here', message: 'That page does not exist.' }) }));
+ });
+ // eslint-disable-next-line no-unused-vars
+ app.use((err, req, res, next) => {
+ if (err && err.type === 'entity.parse.failed') return http.sendProblem(res, 400, 'request.malformed_json', { ctx: req.ov });
+ if (err && err.type === 'entity.too.large') return http.sendProblem(res, 413, 'request.too_large', { ctx: req.ov });
+ log.error('[Tips] unhandled error:', err);
+ if (res.headersSent) return undefined;
+ if (req.path.startsWith('/api/')) return http.sendProblem(res, 500, 'tips.internal', { ctx: req.ov });
+ return res.status(500).type('html').send(layout.page({ title: 'Error', robots: 'noindex', body: pages.errorPage({ status: 500, title: 'Something went wrong', message: 'This one is on us. Please try again.' }) }));
+ });
+
+ Object.assign(app.locals, { config, db, domain, keys, outbox, adapters, billing, consumer });
+ return app;
+}
+
+module.exports = { createApp, VERSION };
new file mode 100644
new file mode 100644
@@ -0,0 +1,86 @@
@@ -0,0 +1,86 @@
+'use strict';
+
+/**
+ * OpenVibe.Billing client (the only place money moves, ADR-012). Tips holds a client-credentials
+ * token for audience openvibe.billing with:
+ *
+ * billing.intent.create POST /api/v1/intents checkout: the supporter buys the credit they give
+ * billing.transfer.create POST /api/v1/transfers a tip from the supporter's credit to the creator
+ * POST /api/v1/transfers/:id/refund a paid media request that never played
+ *
+ * Every POST carries an Idempotency-Key derived from the interaction id, so a retry after a lost
+ * response (or a crash) returns Billing's original transaction instead of moving money twice.
+ *
+ * Errors: a Billing problem+json becomes a BillingCallError with .status/.code (4xx: Billing
+ * refused, e.g. billing.insufficient_funds, billing.frozen); a network failure has no status and is
+ * retryable.
+ */
+const { serviceAuth } = require('openvibe-contracts');
+
+class BillingCallError extends Error {
+ constructor(message, { status = null, code = null, body = null } = {}) {
+ super(message);
+ this.name = 'BillingCallError';
+ this.status = status;
+ this.code = code;
+ this.body = body;
+ }
+ get retryable() { return this.status == null || this.status >= 500 || this.status === 429 || this.status === 401 || this.code === 'billing.frozen'; }
+}
+
+function createBillingClient(config, { fetchImpl = globalThis.fetch, tokenClient } = {}) {
+ const base = config.billing.url;
+ let tokens = tokenClient || null;
+ const tokenClientFor = () => {
+ if (!tokens) {
+ tokens = serviceAuth.createTokenClient({
+ tokenUrl: `${config.network.internalUrl}/oauth/token`,
+ clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret,
+ audience: config.billing.audience,
+ scope: 'billing.intent.create billing.transfer.create',
+ fetchImpl,
+ });
+ }
+ return tokens;
+ };
+
+ async function call(method, path, { body, key, traceparent, retried = false } = {}) {
+ let auth;
+ try { auth = await tokenClientFor().authHeaders(); } catch (e) { throw new BillingCallError(`token: ${e.message}`); }
+ const headers = { Accept: 'application/json', ...auth };
+ if (body !== undefined) headers['Content-Type'] = 'application/json';
+ if (key) headers['Idempotency-Key'] = key;
+ if (traceparent) headers.traceparent = traceparent;
+ let res;
+ try {
+ res = await fetchImpl(`${base}${path}`, { method, headers, body: body !== undefined ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(10000) });
+ } catch (e) {
+ throw new BillingCallError(`Billing unreachable: ${e.message}`);
+ }
+ if (res.status === 401 && !retried) { tokenClientFor().invalidate(); return call(method, path, { body, key, traceparent, retried: true }); }
+ const data = await res.json().catch(() => null);
+ if (!res.ok) {
+ const code = (data && (data.code || data.error)) || `http_${res.status}`;
+ throw new BillingCallError(`Billing ${res.status} ${code}: ${(data && data.detail) || ''}`.trim(), { status: res.status, code, body: data });
+ }
+ return data;
+ }
+
+ return {
+ /** { intent, checkout_url } — kind purchase: the supporter buys `bits` of credit. */
+ createIntent: ({ provider, subject, bits, successUrl, cancelUrl, key, traceparent }) => call('POST', '/api/v1/intents', {
+ body: { provider, kind: 'purchase', subject: { type: 'user', id: subject }, bits, success_url: successUrl, cancel_url: cancelUrl },
+ key, traceparent,
+ }),
+ /** { transaction } — credit of `from` → payable of `to`, tagged with the interaction. */
+ createTransfer: ({ from, to, amount, kind, target, message, key, traceparent }) => call('POST', '/api/v1/transfers', {
+ body: { from: { type: 'user', id: from }, to: { type: 'user', id: to }, amount, kind, target, message: message || undefined },
+ key, traceparent,
+ }),
+ refundTransfer: ({ txnId, amount, reason, key }) => call('POST', `/api/v1/transfers/${encodeURIComponent(txnId)}/refund`, { body: { amount, reason }, key }),
+ rates: () => call('GET', '/api/v1/rates'),
+ };
+}
+
+module.exports = { createBillingClient, BillingCallError };
new file mode 100644
new file mode 100644
@@ -0,0 +1,111 @@
@@ -0,0 +1,111 @@
+'use strict';
+
+/**
+ * OpenVibe.Tips configuration. Everything comes from the environment (.env in development,
+ * /etc/openvibe/tips.env in production). loadConfig(env) is pure so tests build their own.
+ *
+ * Tips never moves money (ADR-012): amounts here are product limits (minimums, message lengths),
+ * never prices or rates. Prices and the value of a bit are Billing's.
+ */
+require('dotenv').config();
+
+const int = (v, d) => { const n = parseInt(v, 10); return Number.isFinite(n) ? n : d; };
+const bool = (v, d = false) => (v == null || v === '' ? d : ['1', 'true', 'yes', 'on'].includes(String(v).toLowerCase()));
+const trim = (u) => String(u || '').replace(/\/+$/, '');
+const list = (v) => String(v || '').split(',').map((s) => s.trim()).filter(Boolean);
+
+function loadConfig(env = process.env) {
+ const nodeEnv = env.NODE_ENV || 'development';
+ const isProduction = nodeEnv === 'production';
+ const port = int(env.PORT, 4610);
+ const networkUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');
+ const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://openvibe.tips' : `http://localhost:${port}`));
+ const chatAdapter = String(env.TIPS_CHAT_ADAPTER || (nodeEnv === 'production' ? 'none' : 'test')).toLowerCase();
+ return {
+ nodeEnv,
+ isProduction,
+ port,
+ host: env.HOST || '127.0.0.1',
+ baseUrl,
+ trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 1,
+ dbPath: env.TIPS_DB_PATH || './data/tips.db',
+
+ // Identity: service tokens and user tokens are RS256 JWTs signed by OpenVibe.Network.
+ network: {
+ url: networkUrl,
+ internalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
+ issuer: trim(env.OV_NETWORK_ISSUER || networkUrl),
+ publicKey: env.OV_NETWORK_PUBLIC_KEY ? env.OV_NETWORK_PUBLIC_KEY.replace(/\\n/g, '\n') : null,
+ },
+ audience: env.TIPS_AUDIENCE || 'openvibe.tips',
+ // Tips' own client credentials (client `tips` in the Network): service tokens for Billing,
+ // Events, Live (chat delivery) and Network (identity resolve for the importer); and the
+ // OAuth code flow for browser sign-in (redirect ${baseUrl}/auth/callback).
+ oauth: {
+ clientId: env.OV_OAUTH_CLIENT_ID || 'tips',
+ clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
+ redirectUri: env.OV_OAUTH_REDIRECT_URI || `${baseUrl}/auth/callback`,
+ scope: env.OV_OAUTH_SCOPE || 'profile',
+ },
+ cookies: { secure: env.COOKIE_SECURE != null ? bool(env.COOKIE_SECURE) : isProduction },
+ // Signs the anti-forgery tokens of the server-rendered forms.
+ formSecret: env.TIPS_FORM_SECRET || '',
+
+ billing: {
+ url: trim(env.BILLING_URL || 'http://127.0.0.1:4600'),
+ audience: env.BILLING_AUDIENCE || 'openvibe.billing',
+ // Checkout providers Tips offers (must be enabled on Billing too).
+ providers: list(env.TIPS_CHECKOUT_PROVIDERS || 'powerchat'),
+ // Billing's minimum purchase; a checkout tip below it is refused here with a clear message.
+ minPurchaseBits: int(env.TIPS_MIN_CHECKOUT_BITS, 100),
+ retryMs: int(env.TIPS_BILLING_RETRY_MS, 30_000),
+ // Billing answers a PowerChat intent with a checkout_ref (pcorder:…) instead of a URL. When
+ // set, this template turns it into the link: {ref}, {cents} and {bits} are filled in.
+ powerchatLinkTemplate: env.TIPS_POWERCHAT_LINK_TEMPLATE || '',
+ },
+
+ events: {
+ url: trim(env.EVENTS_URL || ''),
+ intervalMs: int(env.EVENTS_RELAY_INTERVAL_MS, 2000),
+ // Signing secret(s) of Tips' OpenVibe.Events subscription (billing.transaction.*).
+ // Comma-separated so a rotation can accept the old and the new value.
+ webhookSecrets: list(env.TIPS_EVENTS_SECRET),
+ },
+
+ // Chat delivery (paid messages, TTS, media requests, the tip line):
+ // live-chat OpenVibe.Live's /internal/tips/deliveries (docs/live-patch.diff)
+ // test recorded here, nothing leaves the process (development, tests)
+ // none no chat effects are created
+ chat: {
+ adapter: ['live-chat', 'test', 'none'].includes(chatAdapter) ? chatAdapter : 'none',
+ liveUrl: trim(env.LIVE_INTERNAL_URL || 'http://127.0.0.1:3000'),
+ liveAudience: env.LIVE_AUDIENCE || 'openvibe.live',
+ maxAttempts: int(env.TIPS_DELIVERY_MAX_ATTEMPTS, 6),
+ backoffMs: list(env.TIPS_DELIVERY_BACKOFF_MS || '2000,10000,60000,300000,900000').map(Number),
+ },
+
+ overlays: {
+ // A delivery no overlay received within this window is recorded failed (tips.overlay.failed).
+ ttlMs: int(env.TIPS_OVERLAY_TTL_MS, 10 * 60 * 1000),
+ heartbeatMs: int(env.TIPS_OVERLAY_HEARTBEAT_MS, 25_000),
+ replayLimit: int(env.TIPS_OVERLAY_REPLAY_LIMIT, 50),
+ },
+
+ limits: {
+ maxBits: int(env.TIPS_MAX_BITS, 10_000_000), // Billing's maximum
+ messageChars: int(env.TIPS_MESSAGE_CHARS, 300), // Live's donation message limit
+ ttsHardCap: int(env.TIPS_TTS_HARD_CAP, 1200), // Live's hard TTS length cap
+ goalTitleChars: 120, // Live's goal title limit
+ },
+
+ jobs: {
+ enabled: env.TIPS_JOBS !== 'off',
+ intervalMs: int(env.TIPS_JOBS_INTERVAL_MS, 2000),
+ },
+ liveUrl: trim(env.LIVE_URL || 'https://openvibe.live'),
+ // Media requests Tips accepts (the media queue owner re-checks them).
+ mediaHosts: list(env.TIPS_MEDIA_HOSTS || 'youtube.com,www.youtube.com,m.youtube.com,music.youtube.com,youtu.be'),
+ };
+}
+
+module.exports = { loadConfig };
new file mode 100644
new file mode 100644
@@ -0,0 +1,272 @@
@@ -0,0 +1,272 @@
+'use strict';
+
+/**
+ * Tips' own SQLite database (WAL), created on boot, idempotent.
+ *
+ * The ten product tables (roadmap §15.11) store interaction state and REFERENCES to Billing
+ * transactions — never a mutable cash balance. Amounts are integer vibes-bits as Billing counts
+ * them; a creator's totals are always derived from settled interactions.
+ *
+ * creator_tip_profiles one per creator subject: page switch, minimums, TTS/media settings
+ * tip_interactions one logical interaction; payment_state (mirrored from Billing, keyed by
+ * Billing txn id) is separate from delivery_state (Tips' own effects)
+ * tip_goals creator goals
+ * tip_goal_contributions settled interactions counted toward a goal (never tests, never pending)
+ * paid_messages highlighted paid chat messages and TTS requests — created on settlement
+ * paid_media_requests paid media requests — created on settlement
+ * overlay_configs alert / goal overlay settings
+ * overlay_deliveries what overlays were sent (monotonic seq per creator, replayable)
+ * interaction_effects the retryable delivery work of an interaction (chat line, TTS, media, overlay)
+ * migration_maps legacy (Live) row → Tips entity, with held/excluded reasons
+ *
+ * Supporting: overlay_tokens (hashed, scoped, revocable), api_idempotency, import_runs,
+ * event_outbox + idempotency_receipts (openvibe-sdk outbox and inbox), settings.
+ */
+const fs = require('fs');
+const path = require('path');
+const Database = require('better-sqlite3');
+
+const SCHEMA = `
+CREATE TABLE IF NOT EXISTS settings (
+ id INTEGER PRIMARY KEY CHECK (id = 1),
+ schema_version INTEGER NOT NULL,
+ created_at TEXT NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS creator_tip_profiles (
+ creator_subject TEXT PRIMARY KEY, -- usr_…
+ handle TEXT NOT NULL UNIQUE, -- lower-case Network username (projection)
+ display_name TEXT NOT NULL,
+ avatar_url TEXT,
+ headline TEXT,
+ page_enabled INTEGER NOT NULL DEFAULT 0, -- public page + indexing
+ accepting INTEGER NOT NULL DEFAULT 1, -- new tips accepted
+ min_amount INTEGER NOT NULL DEFAULT 1,
+ paid_message_min INTEGER NOT NULL DEFAULT 100,
+ tts_enabled INTEGER NOT NULL DEFAULT 0,
+ tts_min_amount INTEGER NOT NULL DEFAULT 100,
+ tts_max_chars INTEGER NOT NULL DEFAULT 200,
+ tts_voice TEXT NOT NULL DEFAULT 'gary',
+ media_requests_enabled INTEGER NOT NULL DEFAULT 0,
+ media_request_min INTEGER NOT NULL DEFAULT 25,
+ media_max_seconds INTEGER NOT NULL DEFAULT 600,
+ revision INTEGER NOT NULL DEFAULT 1,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS tip_interactions (
+ id TEXT PRIMARY KEY, -- tint_<ULID>
+ creator_subject TEXT NOT NULL,
+ supporter_subject TEXT, -- null: external/anonymous/unmapped
+ supporter_name TEXT, -- display projection at the time
+ kind TEXT NOT NULL CHECK (kind IN ('tip', 'paid_message', 'tts', 'media_request')),
+ amount INTEGER NOT NULL CHECK (amount > 0),
+ currency TEXT NOT NULL DEFAULT 'vibes-bits',
+ amount_cents INTEGER, -- money that arrived (provider/external)
+ message TEXT,
+ request TEXT NOT NULL DEFAULT '{}', -- JSON: tts text/voice, media url, goal_id, target
+ funding TEXT NOT NULL CHECK (funding IN ('credit', 'checkout', 'provider', 'external', 'none')),
+ settlement TEXT NOT NULL CHECK (settlement IN ('billing', 'external', 'simulated', 'imported')),
+ payment_state TEXT NOT NULL CHECK (payment_state IN ('pending', 'settled', 'reversed', 'failed')),
+ delivery_state TEXT NOT NULL CHECK (delivery_state IN ('awaiting_payment', 'queued', 'delivered', 'failed', 'cancelled')),
+ test INTEGER NOT NULL DEFAULT 0, -- simulation or Billing test money: never counted
+ billing_txn_id TEXT UNIQUE, -- the Billing transaction that settled it
+ billing_intent_id TEXT UNIQUE, -- checkout: the Billing payment intent
+ funding_txn_id TEXT UNIQUE, -- checkout: the purchase that funded it
+ reversal_txn_ids TEXT NOT NULL DEFAULT '[]',
+ reversed_bits INTEGER NOT NULL DEFAULT 0, -- bits Billing took back from the creator
+ checkout_url TEXT,
+ checkout_ref TEXT,
+ provider TEXT,
+ provider_ref TEXT,
+ legacy_source TEXT, -- 'live:transactions:<id>' etc.
+ idempotency_key TEXT UNIQUE,
+ failure TEXT,
+ origin TEXT NOT NULL DEFAULT 'tips', -- tips | billing (settled elsewhere) | external | import
+ transfer_due INTEGER NOT NULL DEFAULT 0, -- a Billing transfer still has to be (re)tried
+ transfer_attempts INTEGER NOT NULL DEFAULT 0,
+ next_transfer_at INTEGER NOT NULL DEFAULT 0,
+ target TEXT, -- JSON EntityRef (e.g. a Live stream)
+ created_at TEXT NOT NULL,
+ settled_at TEXT,
+ reversed_at TEXT,
+ delivered_at TEXT,
+ updated_at TEXT NOT NULL,
+ UNIQUE (provider, provider_ref),
+ UNIQUE (legacy_source)
+);
+CREATE INDEX IF NOT EXISTS idx_tint_creator ON tip_interactions (creator_subject, created_at);
+CREATE INDEX IF NOT EXISTS idx_tint_supporter ON tip_interactions (supporter_subject, created_at);
+CREATE INDEX IF NOT EXISTS idx_tint_pending ON tip_interactions (payment_state, funding);
+
+CREATE TABLE IF NOT EXISTS tip_goals (
+ id TEXT PRIMARY KEY, -- tgoal_<ULID>
+ creator_subject TEXT NOT NULL,
+ title TEXT NOT NULL,
+ description TEXT,
+ target_amount INTEGER NOT NULL CHECK (target_amount > 0),
+ currency TEXT NOT NULL DEFAULT 'vibes-bits',
+ image_url TEXT,
+ status TEXT NOT NULL CHECK (status IN ('active', 'closed')),
+ sort_order INTEGER NOT NULL DEFAULT 0,
+ reached_at TEXT,
+ closed_at TEXT,
+ opening_amount INTEGER NOT NULL DEFAULT 0, -- carried over from Live's goal column (not tied to tips)
+ legacy_source TEXT UNIQUE,
+ revision INTEGER NOT NULL DEFAULT 1,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_goal_creator ON tip_goals (creator_subject, status);
+
+CREATE TABLE IF NOT EXISTS tip_goal_contributions (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ goal_id TEXT NOT NULL REFERENCES tip_goals(id),
+ interaction_id TEXT NOT NULL REFERENCES tip_interactions(id),
+ amount INTEGER NOT NULL CHECK (amount > 0),
+ reversed_amount INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL,
+ UNIQUE (goal_id, interaction_id)
+);
+
+CREATE TABLE IF NOT EXISTS paid_messages (
+ id TEXT PRIMARY KEY, -- tpm_<ULID>
+ interaction_id TEXT NOT NULL UNIQUE REFERENCES tip_interactions(id),
+ creator_subject TEXT NOT NULL,
+ kind TEXT NOT NULL CHECK (kind IN ('paid_message', 'tts')),
+ text TEXT NOT NULL,
+ voice TEXT,
+ highlight_seconds INTEGER NOT NULL DEFAULT 0,
+ status TEXT NOT NULL CHECK (status IN ('queued', 'delivered', 'failed', 'cancelled')),
+ chat_ref TEXT, -- JSON reference the chat adapter returned
+ test INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS paid_media_requests (
+ id TEXT PRIMARY KEY, -- tmr_<ULID>
+ interaction_id TEXT NOT NULL UNIQUE REFERENCES tip_interactions(id),
+ creator_subject TEXT NOT NULL,
+ url TEXT NOT NULL,
+ provider TEXT,
+ status TEXT NOT NULL CHECK (status IN ('queued', 'accepted', 'played', 'skipped', 'failed', 'cancelled', 'refunded')),
+ queue_ref TEXT, -- JSON reference returned by the queue owner
+ test INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS overlay_configs (
+ id TEXT PRIMARY KEY, -- tovc_<ULID>
+ creator_subject TEXT NOT NULL,
+ kind TEXT NOT NULL CHECK (kind IN ('alerts', 'goal')),
+ name TEXT NOT NULL,
+ settings TEXT NOT NULL DEFAULT '{}',
+ goal_id TEXT,
+ revision INTEGER NOT NULL DEFAULT 1,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_ovc_creator ON overlay_configs (creator_subject);
+
+CREATE TABLE IF NOT EXISTS overlay_tokens (
+ id TEXT PRIMARY KEY, -- tovt_<ULID>
+ creator_subject TEXT NOT NULL,
+ token_hash TEXT NOT NULL UNIQUE, -- sha256 of the secret; the secret is shown once
+ scopes TEXT NOT NULL, -- JSON ["alerts","goals"]
+ config_id TEXT,
+ label TEXT,
+ created_by TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ last_used_at TEXT,
+ revoked_at TEXT
+);
+CREATE INDEX IF NOT EXISTS idx_ovt_creator ON overlay_tokens (creator_subject);
+
+CREATE TABLE IF NOT EXISTS overlay_deliveries (
+ seq INTEGER PRIMARY KEY AUTOINCREMENT, -- the SSE event id (monotonic)
+ id TEXT NOT NULL UNIQUE, -- tovd_<ULID>
+ creator_subject TEXT NOT NULL,
+ kind TEXT NOT NULL CHECK (kind IN ('alert', 'goal')),
+ interaction_id TEXT,
+ goal_id TEXT,
+ payload TEXT NOT NULL,
+ test INTEGER NOT NULL DEFAULT 0,
+ status TEXT NOT NULL CHECK (status IN ('pending', 'delivered', 'failed')),
+ delivered_at TEXT,
+ failed_at TEXT,
+ sends INTEGER NOT NULL DEFAULT 0, -- times written to an overlay (replays included)
+ dedupe_key TEXT NOT NULL UNIQUE, -- alert:<interaction> | goal:<goal>:<interaction|revision>
+ created_at TEXT NOT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_ovd_creator ON overlay_deliveries (creator_subject, seq);
+
+CREATE TABLE IF NOT EXISTS interaction_effects (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ interaction_id TEXT NOT NULL REFERENCES tip_interactions(id),
+ effect TEXT NOT NULL CHECK (effect IN ('chat_line', 'paid_message', 'tts', 'media_request', 'overlay_alert')),
+ adapter TEXT NOT NULL,
+ state TEXT NOT NULL CHECK (state IN ('queued', 'delivered', 'failed', 'cancelled')),
+ attempts INTEGER NOT NULL DEFAULT 0,
+ next_attempt_at INTEGER NOT NULL DEFAULT 0,
+ last_error TEXT,
+ result TEXT,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL,
+ UNIQUE (interaction_id, effect)
+);
+CREATE INDEX IF NOT EXISTS idx_effects_due ON interaction_effects (state, next_attempt_at);
+
+CREATE TABLE IF NOT EXISTS migration_maps (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ source TEXT NOT NULL, -- 'live'
+ source_table TEXT NOT NULL,
+ source_id TEXT NOT NULL,
+ target_type TEXT, -- 'interaction' | 'goal'
+ target_id TEXT,
+ status TEXT NOT NULL CHECK (status IN ('imported', 'held', 'excluded')),
+ reason TEXT,
+ billing_txn_id TEXT,
+ run_id TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL,
+ UNIQUE (source, source_table, source_id)
+);
+
+CREATE TABLE IF NOT EXISTS import_runs (
+ id TEXT PRIMARY KEY,
+ source TEXT NOT NULL,
+ dry_run INTEGER NOT NULL,
+ started_at TEXT NOT NULL,
+ finished_at TEXT,
+ report TEXT
+);
+
+CREATE TABLE IF NOT EXISTS api_idempotency (
+ key TEXT PRIMARY KEY, -- <principal>:<Idempotency-Key>
+ request_hash TEXT NOT NULL,
+ method TEXT NOT NULL,
+ path TEXT NOT NULL,
+ status INTEGER NOT NULL,
+ response TEXT NOT NULL,
+ created_at TEXT NOT NULL
+);
+`;
+
+const SCHEMA_VERSION = 1;
+
+function openDb(file) {
+ if (file !== ':memory:') fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true });
+ const db = new Database(file);
+ db.pragma('journal_mode = WAL');
+ db.pragma('foreign_keys = ON');
+ db.pragma('busy_timeout = 5000');
+ db.exec(SCHEMA);
+ db.prepare('INSERT OR IGNORE INTO settings (id, schema_version, created_at) VALUES (1, ?, ?)').run(SCHEMA_VERSION, new Date().toISOString());
+ return db;
+}
+
+module.exports = { openDb, SCHEMA_VERSION };
new file mode 100644
new file mode 100644
@@ -0,0 +1,50 @@
@@ -0,0 +1,50 @@
+'use strict';
+
+/**
+ * Chat delivery seam. An adapter delivers one effect of a settled interaction to the product that
+ * owns the chat room, TTS queue or media queue:
+ *
+ * adapter = { name, async deliver(job) → { ref? } } throw to fail; err.permanent = true stops retries
+ * job = { delivery_id, effect: 'chat_line'|'paid_message'|'tts'|'media_request', test,
+ * creator: SubjectRef, supporter: { name, subject? }, interaction: { id, kind, amount, currency, message },
+ * text, tts?: { text, voice }, media?: { url }, highlight_seconds?, target?: EntityRef }
+ *
+ * Adapters (TIPS_CHAT_ADAPTER):
+ * live-chat OpenVibe.Live's /internal/tips/deliveries — the existing donation path (broadcast to the
+ * channel room + global, saved as a 'donation' chat message, the alert sound); see
+ * docs/live-patch.diff. Used until OpenVibe.Chat exposes a public paid-message API.
+ * test records jobs in memory (development, tests, and simulations when no chat target should
+ * hear a test)
+ * none no chat effects are created at all
+ *
+ * A delivery failure is retried with backoff and finally recorded as failed on the effect; it never
+ * touches the interaction's payment state (the money is Billing's and already settled).
+ */
+const { createLiveChatAdapter } = require('./live-chat');
+
+function createTestAdapter({ name = 'test' } = {}) {
+ const jobs = [];
+ const state = { failWith: null, permanent: false };
+ return {
+ name,
+ jobs,
+ state,
+ async deliver(job) {
+ if (state.failWith) {
+ const e = new Error(state.failWith);
+ e.permanent = state.permanent;
+ throw e;
+ }
+ jobs.push(job);
+ return { ref: { adapter: name, n: jobs.length } };
+ },
+ };
+}
+
+function createAdapters(config, { fetchImpl } = {}) {
+ const adapters = { test: createTestAdapter() };
+ if (config.chat.adapter === 'live-chat') adapters['live-chat'] = createLiveChatAdapter(config, { fetchImpl });
+ return adapters;
+}
+
+module.exports = { createAdapters, createTestAdapter };
new file mode 100644
new file mode 100644
@@ -0,0 +1,59 @@
@@ -0,0 +1,59 @@
+'use strict';
+
+/**
+ * live-chat adapter: posts a paid effect into a creator's OpenVibe.Live chat through Live's
+ * existing donation path.
+ *
+ * POST ${LIVE_INTERNAL_URL}/internal/tips/deliveries
+ * Authorization: Bearer <Tips service token, audience openvibe.live, capability live.tips_delivery.write>
+ * Idempotency-Key: <delivery_id> (Live answers a repeat with the first result)
+ * body: the job (server/delivery/index.js)
+ * → 200 { ok: true, ref: { chat_message_id?, media_request_id? } }
+ *
+ * Live has no such route today: docs/live-patch.diff adds it (server/tips/delivery-routes.js,
+ * loopback-only like every /internal route, guarded by Live's service-guard). Live maps the creator
+ * subject to its channel through linked_accounts and then does exactly what POST /api/funds/donate
+ * does after the money moved: broadcastToChannelRoom + broadcastGlobal of the donation event,
+ * saveChatMessage(message_type 'donation'), the donation alert sound; TTS through
+ * synthesizeAndBroadcastTTS; media requests into its media queue at no charge (Billing already
+ * charged). When OpenVibe.Chat publishes a paid-message capability, a `chat` adapter replaces this.
+ *
+ * 4xx answers other than 408/409/425/429 are permanent (the effect fails at once); everything
+ * else is retried by the effects worker.
+ */
+const { serviceAuth } = require('openvibe-contracts');
+
+function createLiveChatAdapter(config, { fetchImpl = globalThis.fetch, tokenClient } = {}) {
+ let tokens = tokenClient || null;
+ const client = () => {
+ if (!tokens) {
+ tokens = serviceAuth.createTokenClient({
+ tokenUrl: `${config.network.internalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
+ audience: config.chat.liveAudience, scope: 'live.tips_delivery.write', fetchImpl,
+ });
+ }
+ return tokens;
+ };
+
+ async function deliver(job, retried = false) {
+ const headers = { 'Content-Type': 'application/json', Accept: 'application/json', 'Idempotency-Key': job.delivery_id, ...(await client().authHeaders()) };
+ let res;
+ try {
+ res = await fetchImpl(`${config.chat.liveUrl}/internal/tips/deliveries`, { method: 'POST', headers, body: JSON.stringify(job), signal: AbortSignal.timeout(10000) });
+ } catch (e) {
+ throw new Error(`Live unreachable: ${e.message}`);
+ }
+ if (res.status === 401 && !retried) { client().invalidate(); return deliver(job, true); }
+ const body = await res.json().catch(() => null);
+ if (!res.ok) {
+ const e = new Error(`Live ${res.status}: ${(body && (body.detail || body.error || body.code)) || 'refused'}`);
+ e.permanent = res.status >= 400 && res.status < 500 && ![401, 408, 409, 425, 429].includes(res.status);
+ throw e;
+ }
+ return { ref: (body && body.ref) || null };
+ }
+
+ return { name: 'live-chat', deliver };
+}
+
+module.exports = { createLiveChatAdapter };
new file mode 100644
new file mode 100644
@@ -0,0 +1,117 @@
@@ -0,0 +1,117 @@
+'use strict';
+
+/**
+ * The effects worker: delivers the queued interaction_effects of settled interactions through the
+ * chat adapters, with retries. One effect = one adapter call with a stable delivery id
+ * (`<interaction>:<effect>`), so the receiving side can drop a repeat after a lost response.
+ *
+ * A failure is retried with backoff (TIPS_DELIVERY_BACKOFF_MS) up to TIPS_DELIVERY_MAX_ATTEMPTS, or
+ * stops at once when the adapter says it is permanent. The interaction's delivery_state is then
+ * recomputed (failed → tips.interaction.failed). payment_state is never touched here.
+ */
+const { iso, json } = require('../util');
+
+function createEffects(ctx) {
+ const { db, config } = ctx;
+ let running = false;
+ let kickTimer = null;
+
+ function jobFor(effect, i, profile) {
+ const req = json(i.request, {});
+ const name = i.supporter_name || 'Someone';
+ const amount = i.amount.toLocaleString('en-US');
+ const job = {
+ delivery_id: `${i.id}:${effect.effect}`,
+ effect: effect.effect,
+ test: !!i.test,
+ creator: { type: 'user', id: i.creator_subject, handle: profile ? profile.handle : null },
+ supporter: { name, subject: i.supporter_subject ? { type: 'user', id: i.supporter_subject } : null },
+ interaction: { id: i.id, kind: i.kind, amount: i.amount, currency: i.currency, message: i.message || null },
+ text: `${name} tipped ${amount} Vibes${i.message ? `: ${i.message}` : ''}`,
+ target: json(i.target, null),
+ };
+ if (effect.effect === 'paid_message') job.highlight_seconds = req.highlight_seconds || 0;
+ if (effect.effect === 'tts') job.tts = req.tts;
+ if (effect.effect === 'media_request') { job.media = req.media; job.text = `${name} requested media for ${amount} Vibes`; }
+ return job;
+ }
+
+ function markDone(effect, result) {
+ ctx.tx(() => {
+ const at = iso(ctx.now());
+ const r = db.prepare("UPDATE interaction_effects SET state = 'delivered', attempts = attempts + 1, last_error = NULL, result = ?, updated_at = ? WHERE id = ? AND state = 'queued'")
+ .run(JSON.stringify(result || null), at, effect.id);
+ if (!r.changes) return; // cancelled meanwhile (a reversal): the delivery stays recorded on the adapter's side
+ const ref = JSON.stringify((result && result.ref) || null);
+ if (effect.effect === 'paid_message' || effect.effect === 'tts') {
+ db.prepare("UPDATE paid_messages SET status = 'delivered', chat_ref = ?, updated_at = ? WHERE interaction_id = ? AND status = 'queued'").run(ref, at, effect.interaction_id);
+ }
+ if (effect.effect === 'media_request') {
+ db.prepare("UPDATE paid_media_requests SET status = 'accepted', queue_ref = ?, updated_at = ? WHERE interaction_id = ? AND status = 'queued'").run(ref, at, effect.interaction_id);
+ }
+ ctx.interactions.recomputeDelivery(effect.interaction_id);
+ });
+ }
+
+ function markFailed(effect, err) {
+ ctx.tx(() => {
+ const at = iso(ctx.now());
+ const attempts = effect.attempts + 1;
+ const final = err.permanent || attempts >= config.chat.maxAttempts;
+ const backoff = config.chat.backoffMs[Math.min(attempts - 1, config.chat.backoffMs.length - 1)] || 60000;
+ db.prepare('UPDATE interaction_effects SET state = ?, attempts = ?, next_attempt_at = ?, last_error = ?, updated_at = ? WHERE id = ? AND state = \'queued\'')
+ .run(final ? 'failed' : 'queued', attempts, ctx.now() + backoff, String(err.message || err).slice(0, 500), at, effect.id);
+ if (final) {
+ if (effect.effect === 'paid_message' || effect.effect === 'tts') db.prepare("UPDATE paid_messages SET status = 'failed', updated_at = ? WHERE interaction_id = ? AND status = 'queued'").run(at, effect.interaction_id);
+ if (effect.effect === 'media_request') db.prepare("UPDATE paid_media_requests SET status = 'failed', updated_at = ? WHERE interaction_id = ? AND status = 'queued'").run(at, effect.interaction_id);
+ ctx.interactions.recomputeDelivery(effect.interaction_id);
+ }
+ });
+ ctx.outboxKick();
+ }
+
+ async function runOne(effect) {
+ const i = ctx.interactions.get(effect.interaction_id);
+ if (!i || i.payment_state !== 'settled') {
+ // Reversed or failed before its turn: nothing to deliver.
+ ctx.tx(() => ctx.interactions.cancelQueued(i, 'payment_not_settled'));
+ return 'cancelled';
+ }
+ const adapter = ctx.adapters[effect.adapter];
+ if (!adapter) { markFailed(effect, Object.assign(new Error(`no adapter ${effect.adapter} configured`), { permanent: true })); return 'failed'; }
+ try {
+ const result = await adapter.deliver(jobFor(effect, i, ctx.profiles.bySubject(i.creator_subject)));
+ markDone(effect, result);
+ return 'delivered';
+ } catch (e) {
+ markFailed(effect, e);
+ return 'retry';
+ }
+ }
+
+ /** Deliver every due effect once. */
+ async function drain() {
+ if (running) return { busy: true };
+ running = true;
+ const out = { delivered: 0, retry: 0, failed: 0, cancelled: 0 };
+ try {
+ for (;;) {
+ const due = db.prepare("SELECT * FROM interaction_effects WHERE state = 'queued' AND next_attempt_at <= ? ORDER BY id LIMIT 50").all(ctx.now());
+ if (!due.length) break;
+ for (const e of due) { const r = await runOne(e); out[r] = (out[r] || 0) + 1; }
+ if (due.length < 50) break;
+ }
+ } finally { running = false; }
+ return out;
+ }
+
+ function kick() {
+ if (!config.jobs.enabled || kickTimer) return;
+ kickTimer = setTimeout(() => { kickTimer = null; drain().catch((e) => ctx.log.warn('[Tips] effects:', e.message)); }, 0);
+ if (kickTimer.unref) kickTimer.unref();
+ }
+
+ return { drain, kick, jobFor };
+}
+
+module.exports = { createEffects };
new file mode 100644
new file mode 100644
@@ -0,0 +1,169 @@
@@ -0,0 +1,169 @@
+'use strict';
+
+/**
+ * Creator goals. A goal's total is DERIVED: the sum of its contributions, and a contribution is
+ * written only when an interaction settles (never while pending, never for a simulation). A
+ * reversal lowers the contribution by what Billing took back from the creator.
+ *
+ * Which goal a tip counts toward (Live's rule): the goal the supporter picked if it is the
+ * creator's and active, otherwise the creator's only active goal; with several active goals and
+ * no pick, none.
+ *
+ * Every change bumps the goal's revision and emits tips.goal.updated plus an overlay delivery.
+ */
+const { fail, iso, prefixedId, text, positiveInt } = require('../util');
+const { safeUrl } = require('./profiles');
+
+function createGoals(ctx) {
+ const { db } = ctx;
+
+ const get = (id) => db.prepare('SELECT * FROM tip_goals WHERE id = ?').get(String(id || '')) || null;
+ /** Settled contributions, plus the opening amount a goal imported from Live carried over. */
+ const totalOf = (goalId) => {
+ const r = db.prepare('SELECT COALESCE(SUM(amount - reversed_amount), 0) AS n, COUNT(*) AS c FROM tip_goal_contributions WHERE goal_id = ?').get(goalId);
+ const g = db.prepare('SELECT opening_amount FROM tip_goals WHERE id = ?').get(goalId);
+ return { n: r.n + ((g && g.opening_amount) || 0), c: r.c, contributions: r.n, opening: (g && g.opening_amount) || 0 };
+ };
+
+ function list(creator, { status } = {}) {
+ const rows = status
+ ? db.prepare('SELECT * FROM tip_goals WHERE creator_subject = ? AND status = ? ORDER BY sort_order, created_at').all(creator, status)
+ : db.prepare("SELECT * FROM tip_goals WHERE creator_subject = ? ORDER BY status = 'active' DESC, sort_order, created_at DESC").all(creator);
+ return rows;
+ }
+
+ function present(g, { contributions = false } = {}) {
+ if (!g) return null;
+ const t = totalOf(g.id);
+ const out = {
+ id: g.id, creator: { type: 'user', id: g.creator_subject }, title: g.title, description: g.description || null,
+ target_amount: g.target_amount, current_amount: t.n, currency: g.currency, supporters_count: t.c,
+ carried_over_amount: t.opening,
+ percent: Math.min(100, Math.floor((t.n * 100) / g.target_amount)), reached: !!g.reached_at, reached_at: g.reached_at || null,
+ image_url: g.image_url || null, status: g.status, sort_order: g.sort_order, revision: g.revision,
+ created_at: g.created_at, updated_at: g.updated_at, closed_at: g.closed_at || null,
+ };
+ if (contributions) {
+ out.contributions = db.prepare(`SELECT c.interaction_id, c.amount, c.reversed_amount, c.created_at, i.supporter_name
+ FROM tip_goal_contributions c JOIN tip_interactions i ON i.id = c.interaction_id WHERE c.goal_id = ? ORDER BY c.id DESC LIMIT 200`).all(g.id);
+ }
+ return out;
+ }
+
+ /** Inside a transaction: bump revision, emit the event, queue the overlay delivery. */
+ function changed(goalId, reason, { interactionId = null, by = null } = {}) {
+ const at = iso(ctx.now());
+ db.prepare('UPDATE tip_goals SET revision = revision + 1, updated_at = ? WHERE id = ?').run(at, goalId);
+ const g = get(goalId);
+ const view = present(g);
+ ctx.outbox.emit('tips.goal.updated', { type: 'goal', id: g.id, revision: g.revision }, {
+ goal_id: g.id, creator: view.creator, title: g.title, target_amount: g.target_amount, current_amount: view.current_amount,
+ currency: g.currency, status: g.status, reached: view.reached, reason, interaction_id: interactionId,
+ });
+ ctx.overlays.addGoalDelivery(g.creator_subject, view, { reason, interactionId, by, dedupe: `goal:${g.id}:r${g.revision}` });
+ return view;
+ }
+
+ function fields(input, partial) {
+ const out = {};
+ if (!partial || input.title !== undefined) {
+ const t = text(input.title, 'title', ctx.config.limits.goalTitleChars);
+ if (!t) fail(422, 'tips.invalid_input', 'a goal needs a title');
+ out.title = t;
+ }
+ if (input.description !== undefined) out.description = text(input.description, 'description', 1000);
+ if (!partial || input.target_amount !== undefined) out.target_amount = positiveInt(input.target_amount, 'target_amount', ctx.config.limits.maxBits);
+ if (input.image_url !== undefined) {
+ out.image_url = input.image_url ? safeUrl(input.image_url) : null;
+ if (input.image_url && !out.image_url) fail(422, 'tips.invalid_input', 'image_url must be an https URL');
+ }
+ if (input.sort_order !== undefined && input.sort_order !== '') {
+ const n = Number(input.sort_order);
+ if (!Number.isInteger(n) || n < 0 || n > 1000) fail(422, 'tips.invalid_input', 'sort_order must be 0-1000');
+ out.sort_order = n;
+ }
+ return out;
+ }
+
+ function create(creator, input = {}) {
+ const f = fields(input, false);
+ const count = db.prepare("SELECT COUNT(*) AS n FROM tip_goals WHERE creator_subject = ? AND status = 'active'").get(creator).n;
+ if (count >= 20) fail(409, 'tips.too_many_goals', 'close a goal before adding another (20 active at most)');
+ return ctx.tx(() => {
+ const at = iso(ctx.now());
+ const id = prefixedId('tgoal', ctx.now());
+ db.prepare(`INSERT INTO tip_goals (id, creator_subject, title, description, target_amount, image_url, status, sort_order, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)`).run(id, creator, f.title, f.description || null, f.target_amount, f.image_url || null, f.sort_order || 0, at, at);
+ return changed(id, 'created');
+ });
+ }
+
+ function update(goal, input = {}) {
+ if (goal.status !== 'active') fail(409, 'tips.goal_closed', 'a closed goal cannot be edited');
+ if (input.revision != null && Number(input.revision) !== goal.revision) fail(409, 'tips.revision_conflict', `the goal is at revision ${goal.revision}`);
+ const f = fields(input, true);
+ const keys = Object.keys(f);
+ if (!keys.length) return present(goal);
+ return ctx.tx(() => {
+ db.prepare(`UPDATE tip_goals SET ${keys.map((k) => `${k} = @${k}`).join(', ')} WHERE id = @id`).run({ ...f, id: goal.id });
+ markReached(goal.id);
+ return changed(goal.id, 'updated');
+ });
+ }
+
+ function close(goal) {
+ if (goal.status === 'closed') return present(goal);
+ return ctx.tx(() => {
+ db.prepare("UPDATE tip_goals SET status = 'closed', closed_at = ? WHERE id = ?").run(iso(ctx.now()), goal.id);
+ return changed(goal.id, 'closed');
+ });
+ }
+
+ function markReached(goalId) {
+ const g = get(goalId);
+ const reached = totalOf(goalId).n >= g.target_amount;
+ if (reached && !g.reached_at) { db.prepare('UPDATE tip_goals SET reached_at = ? WHERE id = ?').run(iso(ctx.now()), goalId); return true; }
+ if (!reached && g.reached_at) db.prepare('UPDATE tip_goals SET reached_at = NULL WHERE id = ?').run(goalId);
+ return false;
+ }
+
+ /** The goal an interaction counts toward, or null. */
+ function pick(creator, requestedGoalId) {
+ if (requestedGoalId) {
+ const g = get(requestedGoalId);
+ if (g && g.creator_subject === creator && g.status === 'active') return g;
+ }
+ const active = db.prepare("SELECT * FROM tip_goals WHERE creator_subject = ? AND status = 'active' LIMIT 2").all(creator);
+ return active.length === 1 ? active[0] : null;
+ }
+
+ /** Inside the settlement transaction. Simulations never get here. */
+ function contribute(interaction, requestedGoalId) {
+ const g = pick(interaction.creator_subject, requestedGoalId);
+ if (!g) return null;
+ const at = iso(ctx.now());
+ const r = db.prepare(`INSERT OR IGNORE INTO tip_goal_contributions (goal_id, interaction_id, amount, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`)
+ .run(g.id, interaction.id, interaction.amount, at, at);
+ if (!r.changes) return null;
+ const reachedNow = markReached(g.id);
+ return changed(g.id, reachedNow ? 'reached' : 'contribution', { interactionId: interaction.id, by: interaction.supporter_name });
+ }
+
+ /** Inside the reversal transaction: take `bits` back off every goal the interaction counted toward. */
+ function reverse(interaction, bits) {
+ const rows = db.prepare('SELECT * FROM tip_goal_contributions WHERE interaction_id = ?').all(interaction.id);
+ const out = [];
+ for (const c of rows) {
+ const take = Math.min(bits, c.amount - c.reversed_amount);
+ if (take <= 0) continue;
+ db.prepare('UPDATE tip_goal_contributions SET reversed_amount = reversed_amount + ?, updated_at = ? WHERE id = ?').run(take, iso(ctx.now()), c.id);
+ markReached(c.goal_id);
+ out.push(changed(c.goal_id, 'reversal', { interactionId: interaction.id }));
+ }
+ return out;
+ }
+
+ return { get, list, present, create, update, close, contribute, reverse, pick, totalOf };
+}
+
+module.exports = { createGoals };
new file mode 100644
new file mode 100644
@@ -0,0 +1,39 @@
@@ -0,0 +1,39 @@
+'use strict';
+
+/**
+ * The Tips domain: one context shared by profiles, goals, interactions, overlays and effects.
+ *
+ * ctx.tx(fn) run fn in one SQLite transaction; ctx.afterCommit(hook) queues work (overlay
+ * pushes, worker kicks) that must only happen once the transaction committed
+ * ctx.outbox.emit() durable event, inside the transaction (openvibe-sdk outbox)
+ */
+const { createProfiles } = require('./profiles');
+const { createGoals } = require('./goals');
+const { createOverlays } = require('./overlays');
+const { createInteractions } = require('./interactions');
+const { createEffects } = require('./effects');
+
+function createDomain({ db, config, outbox, billing, adapters, now = () => Date.now(), log = console }) {
+ const ctx = { db, config, now, log, outbox, billing, adapters, _after: null };
+
+ ctx.tx = (fn) => {
+ if (ctx._after) return db.transaction(fn)();
+ const hooks = [];
+ ctx._after = hooks;
+ let result;
+ try { result = db.transaction(fn)(); } finally { ctx._after = null; }
+ for (const h of hooks) { try { h(); } catch (e) { log.warn('[Tips] after-commit:', e.message); } }
+ return result;
+ };
+ ctx.afterCommit = (hook) => { if (ctx._after) ctx._after.push(hook); else hook(); };
+ ctx.outboxKick = () => { try { outbox.kick(); } catch { /* relay off */ } };
+
+ ctx.profiles = createProfiles(ctx);
+ ctx.goals = createGoals(ctx);
+ ctx.overlays = createOverlays(ctx);
+ ctx.interactions = createInteractions(ctx);
+ ctx.effects = createEffects(ctx);
+ return ctx;
+}
+
+module.exports = { createDomain };
new file mode 100644
new file mode 100644
@@ -0,0 +1,496 @@
@@ -0,0 +1,496 @@
+'use strict';
+
+/**
+ * Tip interactions — the product record of one act of support.
+ *
+ * Two independent state machines (roadmap §15.11: "payment settled" is not "interaction delivered"):
+ *
+ * payment_state pending ──► settled ──► reversed mirrored from Billing, keyed by the
+ * └──► failed Billing transaction id (UNIQUE)
+ * delivery_state awaiting_payment ──► queued ──► delivered | failed | cancelled
+ *
+ * How money reaches Billing (Tips never moves it, ADR-012):
+ * funding credit POST /transfers (supporter credit → creator payable, target = this interaction,
+ * Idempotency-Key tips:transfer:<id>). The response settles it at once; the
+ * billing.transaction.settled event for the same transaction is then a no-op.
+ * funding checkout POST /intents (the supporter buys exactly the credit they give,
+ * Idempotency-Key tips:intent:<id>) → checkout URL. When the purchase settles
+ * (event, metadata.intent_id), the transfer above runs — CREDIT becomes MONEY only
+ * by giving it to someone else.
+ * origin billing a donation settled in Billing that Tips did not start (Live's own donate flow,
+ * a site-routed PowerChat tip) is recorded once, by its Billing transaction id.
+ * external a tip on the creator's own PowerChat account: no Billing liability; recorded
+ * once by (provider, provider_ref); never part of the Billing reconciliation.
+ * simulated the whole effect path with test = 1: no Billing call, no goal contribution,
+ * no durable event, excluded from every total.
+ *
+ * Settlement creates, in one transaction: the paid message / TTS / media request row, the goal
+ * contribution, the overlay alert, the delivery effects and tips.interaction.ready. Delivery then
+ * runs in the effects worker; its failure never touches payment_state.
+ */
+const { fail, iso, prefixedId, json, positiveInt, text, userSubject, entityRef, displayName } = require('../util');
+const { VOICES } = require('./profiles');
+const { BillingCallError } = require('../billing-client');
+
+const KINDS = ['tip', 'paid_message', 'tts', 'media_request'];
+// Paid-message highlight time by amount (bits): the bigger the message, the longer it stays up.
+const HIGHLIGHT = [[5000, 300], [1000, 120], [500, 60], [100, 30]];
+const highlightFor = (bits) => (HIGHLIGHT.find(([min]) => bits >= min) || [0, 0])[1];
+const TRANSFER_BACKOFF = [2000, 10000, 30000, 60000, 300000, 600000];
+
+function createInteractions(ctx) {
+ const { db, config } = ctx;
+
+ const get = (id) => db.prepare('SELECT * FROM tip_interactions WHERE id = ?').get(String(id || '')) || null;
+ const byBillingTxn = (txnId) => db.prepare('SELECT * FROM tip_interactions WHERE billing_txn_id = ?').get(txnId) || null;
+
+ // ── Input rules (the creator's settings) ─────────────────
+ function cleanTts(s) {
+ return String(s || '').replace(/[\u0000-\u001f]/g, ' ').replace(/https?:\/\/\S+/gi, 'link').replace(/\s+/g, ' ').trim();
+ }
+
+ function mediaUrl(v) {
+ let u;
+ try { u = new URL(String(v || '')); } catch { fail(422, 'tips.invalid_media', 'media.url must be a link'); }
+ if (u.protocol !== 'https:') fail(422, 'tips.invalid_media', 'media.url must be an https link');
+ if (!config.mediaHosts.includes(u.hostname.toLowerCase())) fail(422, 'tips.invalid_media', `media requests accept links from ${config.mediaHosts.join(', ')}`);
+ const s = u.toString();
+ if (s.length > 500) fail(422, 'tips.invalid_media', 'media.url is too long');
+ return { url: s, provider: /youtu/.test(u.hostname) ? 'youtube' : u.hostname };
+ }
+
+ /**
+ * Validate a request against the creator's profile. Returns the normalised fields:
+ * { kind, amount, message, request: { goal_id, tts, media, highlight_seconds } }.
+ */
+ function validate(profile, input, { simulation = false } = {}) {
+ if (!profile) fail(404, 'tips.creator_not_found', 'this creator has no tip page');
+ if (!profile.accepting && !simulation) fail(409, 'tips.not_accepting', `${profile.display_name} is not accepting tips right now`);
+ const kind = input.kind || 'tip';
+ if (!KINDS.includes(kind)) fail(422, 'tips.invalid_input', `kind must be one of ${KINDS.join(', ')}`);
+ const amount = positiveInt(input.amount, 'amount', config.limits.maxBits);
+ const message = text(input.message, 'message', config.limits.messageChars);
+ const request = {};
+ const min = (n, what) => { if (amount < n) fail(422, 'tips.amount_too_small', `the minimum for ${what} is ${n} Vibes`); };
+ min(profile.min_amount, 'a tip');
+ if (kind === 'paid_message') {
+ min(profile.paid_message_min, 'a paid message');
+ if (!message) fail(422, 'tips.invalid_input', 'a paid message needs a message');
+ request.highlight_seconds = highlightFor(amount);
+ }
+ if (kind === 'tts') {
+ if (!profile.tts_enabled) fail(409, 'tips.tts_disabled', `${profile.display_name} has text-to-speech switched off`);
+ min(profile.tts_min_amount, 'text-to-speech');
+ const raw = input.tts && input.tts.text != null ? input.tts.text : message;
+ const t = cleanTts(raw);
+ if (!t || t.startsWith('.')) fail(422, 'tips.invalid_input', 'text-to-speech needs text to read');
+ if (t.length > profile.tts_max_chars) fail(422, 'tips.text_too_long', `text-to-speech is limited to ${profile.tts_max_chars} characters here`);
+ const voice = input.tts && input.tts.voice ? String(input.tts.voice) : profile.tts_voice;
+ if (!VOICES.includes(voice)) fail(422, 'tips.invalid_input', `voice must be one of ${VOICES.join(', ')}`);
+ request.tts = { text: t, voice };
+ }
+ if (kind === 'media_request') {
+ if (!profile.media_requests_enabled) fail(409, 'tips.media_disabled', `${profile.display_name} is not taking media requests`);
+ min(profile.media_request_min, 'a media request');
+ request.media = { ...mediaUrl(input.media && input.media.url), max_seconds: profile.media_max_seconds };
+ }
+ if (input.goal_id) {
+ const g = ctx.goals.get(input.goal_id);
+ if (!g || g.creator_subject !== profile.creator_subject || g.status !== 'active') fail(422, 'tips.goal_not_found', 'that goal is not open on this page');
+ request.goal_id = g.id;
+ }
+ return { kind, amount, message, request };
+ }
+
+ // ── Creation ─────────────────────────────────────────────
+ function insert(row) {
+ const at = iso(ctx.now());
+ const r = {
+ supporter_subject: null, supporter_name: null, amount_cents: null, message: null, request: {}, test: 0, billing_txn_id: null,
+ billing_intent_id: null, funding_txn_id: null, provider: null, provider_ref: null, legacy_source: null, idempotency_key: null,
+ target: null, origin: 'tips', created_at: at, ...row,
+ };
+ db.prepare(`INSERT INTO tip_interactions (id, creator_subject, supporter_subject, supporter_name, kind, amount, amount_cents, message, request,
+ funding, settlement, payment_state, delivery_state, test, billing_txn_id, billing_intent_id, funding_txn_id, provider, provider_ref,
+ legacy_source, idempotency_key, origin, target, created_at, updated_at)
+ VALUES (@id, @creator_subject, @supporter_subject, @supporter_name, @kind, @amount, @amount_cents, @message, @request,
+ @funding, @settlement, @payment_state, @delivery_state, @test, @billing_txn_id, @billing_intent_id, @funding_txn_id, @provider, @provider_ref,
+ @legacy_source, @idempotency_key, @origin, @target, @created_at, @created_at)`)
+ .run({ ...r, request: JSON.stringify(r.request || {}), target: r.target ? JSON.stringify(r.target) : null });
+ return get(r.id);
+ }
+
+ /**
+ * A supporter's request (checkout or credit). Idempotent by `idempotencyKey` (the caller's key,
+ * scoped to the caller): a repeat returns the interaction it created the first time.
+ */
+ function request(profile, input, { supporter, supporterName, funding, idempotencyKey }) {
+ const v = validate(profile, input);
+ const from = userSubject(supporter, 'supporter');
+ if (from === profile.creator_subject) fail(422, 'tips.self_dealing', 'you cannot tip yourself');
+ if (!['credit', 'checkout'].includes(funding)) fail(422, 'tips.invalid_input', "pay_with must be 'credit' or 'checkout'");
+ if (funding === 'checkout' && v.amount < config.billing.minPurchaseBits) {
+ fail(422, 'tips.amount_too_small', `paying by checkout starts at ${config.billing.minPurchaseBits} Vibes; smaller tips use your Vibes balance`);
+ }
+ const target = entityRef(input.target);
+ return ctx.tx(() => {
+ if (idempotencyKey) {
+ const prev = db.prepare('SELECT * FROM tip_interactions WHERE idempotency_key = ?').get(idempotencyKey);
+ if (prev) return { interaction: prev, replay: true };
+ }
+ const i = insert({
+ id: prefixedId('tint', ctx.now()), creator_subject: profile.creator_subject, supporter_subject: from, supporter_name: displayName(supporterName) || 'Someone',
+ kind: v.kind, amount: v.amount, message: v.message, request: v.request, funding, settlement: 'billing', payment_state: 'pending',
+ delivery_state: 'awaiting_payment', idempotency_key: idempotencyKey || null, target,
+ });
+ return { interaction: i, replay: false };
+ });
+ }
+
+ // ── Paying through Billing ───────────────────────────────
+ const transferKind = (i) => (i.kind === 'tip' ? 'tip' : 'paid_interaction');
+
+ /** Try the Billing transfer now; retryable failures leave it due for the jobs loop. */
+ async function transfer(i, { traceparent } = {}) {
+ try {
+ const out = await ctx.billing.createTransfer({
+ from: i.supporter_subject, to: i.creator_subject, amount: i.amount, kind: transferKind(i),
+ target: { service: 'tips', type: 'interaction', id: i.id }, message: i.message || undefined,
+ key: `tips:transfer:${i.id}`, traceparent,
+ });
+ const txn = out.transaction;
+ ctx.tx(() => settleByTransaction(get(i.id), { id: txn.id, test: !!txn.test }));
+ return { settled: true };
+ } catch (e) {
+ if (e instanceof BillingCallError && !e.retryable) {
+ ctx.tx(() => failPayment(get(i.id), e.code || 'billing.refused', e.message));
+ return { settled: false, refused: e.code, detail: e.body && e.body.detail };
+ }
+ const cur = get(i.id);
+ const n = cur.transfer_attempts + 1;
+ db.prepare('UPDATE tip_interactions SET transfer_due = 1, transfer_attempts = ?, next_transfer_at = ?, failure = ?, updated_at = ? WHERE id = ? AND payment_state = \'pending\'')
+ .run(n, ctx.now() + TRANSFER_BACKOFF[Math.min(n - 1, TRANSFER_BACKOFF.length - 1)], String(e.message).slice(0, 300), iso(ctx.now()), i.id);
+ ctx.log.warn(`[Tips] transfer for ${i.id} will be retried: ${e.message}`);
+ return { settled: false, retrying: true };
+ }
+ }
+
+ async function startCheckout(i, { provider, traceparent }) {
+ const p = String(provider || config.billing.providers[0] || '').toLowerCase();
+ if (!config.billing.providers.includes(p)) fail(422, 'tips.invalid_input', `provider must be one of ${config.billing.providers.join(', ') || '(none configured)'}`);
+ let out;
+ try {
+ out = await ctx.billing.createIntent({
+ provider: p, subject: i.supporter_subject, bits: i.amount,
+ successUrl: `${config.baseUrl}/receipts/${i.id}`, cancelUrl: `${config.baseUrl}/receipts/${i.id}?cancelled=1`,
+ key: `tips:intent:${i.id}`, traceparent,
+ });
+ } catch (e) {
+ if (e instanceof BillingCallError && !e.retryable) {
+ ctx.tx(() => failPayment(get(i.id), e.code || 'billing.refused', e.message));
+ fail(e.status === 409 ? 409 : 422, e.code || 'billing.refused', (e.body && e.body.detail) || 'Billing refused the checkout');
+ }
+ fail(502, 'tips.billing_unavailable', 'checkout is unavailable right now; nothing was charged — try again shortly');
+ }
+ const intent = out.intent || {};
+ let url = out.checkout_url || null;
+ const ref = intent.checkout_ref || null;
+ if (!url && ref && p === 'powerchat' && config.billing.powerchatLinkTemplate) {
+ url = config.billing.powerchatLinkTemplate.replace('{ref}', encodeURIComponent(ref)).replace('{cents}', String(intent.amount_cents || '')).replace('{bits}', String(i.amount));
+ }
+ // Pages link and redirect to it: only an https URL is ever kept.
+ if (url && !/^https:\/\/[^\s"'<>]+$/.test(url)) url = null;
+ db.prepare('UPDATE tip_interactions SET billing_intent_id = ?, checkout_url = ?, checkout_ref = ?, provider = ?, updated_at = ? WHERE id = ?')
+ .run(intent.id || null, url, ref, p, iso(ctx.now()), i.id);
+ return { interaction: get(i.id), checkout_url: url, checkout_ref: ref, intent };
+ }
+
+ /** Due transfers (credit retries and funded checkouts). Called by the jobs loop. */
+ async function processDueTransfers() {
+ const rows = db.prepare("SELECT * FROM tip_interactions WHERE payment_state = 'pending' AND transfer_due = 1 AND next_transfer_at <= ? ORDER BY next_transfer_at LIMIT 20").all(ctx.now());
+ let settled = 0;
+ for (const i of rows) { const r = await transfer(i); if (r.settled) settled++; }
+ return { attempted: rows.length, settled };
+ }
+
+ // ── Settlement ───────────────────────────────────────────
+ /** Inside a transaction: settle `i` by Billing transaction `txn` ({ id, test }). Idempotent. */
+ function settleByTransaction(i, txn) {
+ if (!i) return null;
+ if (i.billing_txn_id === txn.id || i.payment_state === 'settled' || i.payment_state === 'reversed') return i;
+ const other = byBillingTxn(txn.id);
+ if (other && other.id !== i.id) return other;
+ db.prepare('UPDATE tip_interactions SET billing_txn_id = ?, test = MAX(test, ?), transfer_due = 0, failure = NULL WHERE id = ?').run(txn.id, txn.test ? 1 : 0, i.id);
+ return settle(get(i.id));
+ }
+
+ /** Inside a transaction: pending/failed → settled, and the whole effect path. */
+ function settle(i) {
+ const at = iso(ctx.now());
+ const test = !!i.test;
+ const req = json(i.request, {});
+ db.prepare("UPDATE tip_interactions SET payment_state = 'settled', delivery_state = 'queued', settled_at = ?, updated_at = ? WHERE id = ?").run(at, at, i.id);
+ i = get(i.id);
+
+ if (i.kind === 'paid_message' || i.kind === 'tts') {
+ db.prepare(`INSERT OR IGNORE INTO paid_messages (id, interaction_id, creator_subject, kind, text, voice, highlight_seconds, status, test, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, 'queued', ?, ?, ?)`).run(prefixedId('tpm', ctx.now()), i.id, i.creator_subject, i.kind,
+ i.kind === 'tts' ? req.tts.text : (i.message || ''), i.kind === 'tts' ? req.tts.voice : null, req.highlight_seconds || 0, test ? 1 : 0, at, at);
+ }
+ if (i.kind === 'media_request' && req.media) {
+ db.prepare(`INSERT OR IGNORE INTO paid_media_requests (id, interaction_id, creator_subject, url, provider, status, test, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, 'queued', ?, ?, ?)`).run(prefixedId('tmr', ctx.now()), i.id, i.creator_subject, req.media.url, req.media.provider, test ? 1 : 0, at, at);
+ }
+ if (!test && i.origin !== 'import') ctx.goals.contribute(i, req.goal_id);
+
+ const view = { message: i.message, tts: req.tts || null, media: req.media ? { url: req.media.url } : null };
+ if (i.origin !== 'import') {
+ ctx.overlays.addAlert(i, view);
+ addEffect(i, 'overlay_alert', 'overlay', 'delivered');
+ }
+ // Chat effects: never for imports (history), never twice for a donation another product already
+ // announced (origin billing), never to a real chat room for a simulation.
+ const adapter = test ? 'test' : config.chat.adapter;
+ if (i.origin === 'tips' && adapter !== 'none') {
+ if (i.kind === 'tip') addEffect(i, 'chat_line', adapter);
+ if (i.kind === 'paid_message') addEffect(i, 'paid_message', adapter);
+ if (i.kind === 'tts') { addEffect(i, 'chat_line', adapter); addEffect(i, 'tts', adapter); }
+ if (i.kind === 'media_request') addEffect(i, 'media_request', adapter);
+ }
+ recomputeDelivery(i.id);
+ if (!test && i.origin !== 'import') {
+ ctx.outbox.emit('tips.interaction.ready', { type: 'interaction', id: i.id }, summary(get(i.id)));
+ }
+ ctx.afterCommit(() => { ctx.effects.kick(); ctx.outboxKick(); });
+ return get(i.id);
+ }
+
+ function addEffect(i, effect, adapter, state = 'queued') {
+ const at = iso(ctx.now());
+ db.prepare(`INSERT OR IGNORE INTO interaction_effects (interaction_id, effect, adapter, state, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`)
+ .run(i.id, effect, adapter, state, at, at);
+ }
+
+ /** Inside a transaction: pending → failed (nothing was delivered; nothing moved in Billing). */
+ function failPayment(i, code, detail) {
+ if (!i || i.payment_state !== 'pending') return i;
+ const at = iso(ctx.now());
+ db.prepare("UPDATE tip_interactions SET payment_state = 'failed', delivery_state = 'cancelled', transfer_due = 0, failure = ?, updated_at = ? WHERE id = ?")
+ .run(`${code}${detail ? `: ${String(detail).slice(0, 240)}` : ''}`, at, i.id);
+ if (!i.test) ctx.outbox.emit('tips.interaction.cancelled', { type: 'interaction', id: i.id }, { ...summary(get(i.id)), reason: 'payment_failed', code });
+ return get(i.id);
+ }
+
+ /** Billing settled a transaction. Inside the inbox transaction. */
+ function onBillingSettled(p) {
+ const meta = p.metadata || {};
+ const txn = { id: p.transaction_id, test: !!p.test };
+ if (!txn.id) return 'ignored:no_transaction';
+ if (p.type === 'donation') {
+ const known = byBillingTxn(txn.id);
+ if (known) return 'duplicate_transaction';
+ const target = meta.target;
+ if (target && target.service === 'tips' && target.type === 'interaction') {
+ const i = get(target.id);
+ if (i) { settleByTransaction(i, txn); return 'settled'; }
+ return 'ignored:unknown_interaction';
+ }
+ // A donation Tips did not start: record it once, by its transaction id.
+ if (!p.to_subject) return 'ignored:no_recipient';
+ const amount = Number(meta.amount_bits) || 0;
+ if (amount <= 0) return 'ignored:no_amount';
+ const i = insert({
+ id: prefixedId('tint', ctx.now()), creator_subject: p.to_subject, supporter_subject: p.from_subject || null,
+ supporter_name: displayName(meta.donor_name) || null, kind: meta.kind === 'paid_interaction' ? 'media_request' : 'tip', amount, amount_cents: meta.paid_cents || null,
+ message: meta.message ? String(meta.message).slice(0, config.limits.messageChars) : null,
+ funding: p.provider ? 'provider' : 'credit', settlement: 'billing', payment_state: 'pending', delivery_state: 'awaiting_payment',
+ test: txn.test ? 1 : 0, billing_txn_id: txn.id, provider: p.provider || null, origin: 'billing',
+ target: meta.target && meta.target.service ? meta.target : null,
+ });
+ settle(i);
+ return 'recorded';
+ }
+ if (p.type === 'purchase' && meta.intent_id) {
+ const i = db.prepare('SELECT * FROM tip_interactions WHERE billing_intent_id = ?').get(meta.intent_id);
+ if (!i) return 'ignored:not_a_tips_checkout';
+ if (i.funding_txn_id) return 'duplicate_funding';
+ db.prepare("UPDATE tip_interactions SET funding_txn_id = ?, test = MAX(test, ?), transfer_due = CASE WHEN payment_state = 'pending' THEN 1 ELSE 0 END, next_transfer_at = 0, updated_at = ? WHERE id = ?")
+ .run(txn.id, txn.test ? 1 : 0, iso(ctx.now()), i.id);
+ ctx.afterCommit(() => { processDueTransfers().catch((e) => ctx.log.warn('[Tips] transfer after funding:', e.message)); });
+ return 'funded';
+ }
+ return 'ignored';
+ }
+
+ /** Billing reversed a transaction. Inside the inbox transaction. */
+ function onBillingReversed(p) {
+ const meta = p.metadata || {};
+ const orig = p.reverses_txn;
+ if (!orig) return 'ignored:no_original';
+ const i = byBillingTxn(orig);
+ if (!i) {
+ const funded = db.prepare('SELECT * FROM tip_interactions WHERE funding_txn_id = ?').get(orig);
+ if (funded && funded.payment_state === 'pending') { failPayment(funded, 'billing.funding_reversed', 'the checkout payment was reversed before the tip was given'); return 'funding_reversed'; }
+ return 'ignored';
+ }
+ const ids = json(i.reversal_txn_ids, []);
+ if (ids.includes(p.transaction_id)) return 'duplicate_reversal';
+ ids.push(p.transaction_id);
+ // Bits Billing took back from the creator: a transfer refund moves them out of the payable. A
+ // provider chargeback on a site-routed tip leaves the creator's payable intact (ADR-012 rule 2).
+ const clawed = p.type === 'refund' && p.from_subject === i.creator_subject ? Math.max(0, Number(meta.amount_bits) || 0) : 0;
+ const take = Math.min(clawed, i.amount - i.reversed_bits);
+ const at = iso(ctx.now());
+ db.prepare("UPDATE tip_interactions SET payment_state = 'reversed', reversal_txn_ids = ?, reversed_bits = reversed_bits + ?, reversed_at = ?, updated_at = ? WHERE id = ?")
+ .run(JSON.stringify(ids), take, at, at, i.id);
+ if (take > 0 && !i.test) ctx.goals.reverse(i, take);
+ // Undelivered effects are cancelled; a delivery that already happened stays on record.
+ const cancelled = cancelQueued(get(i.id), 'payment_reversed');
+ return cancelled ? 'reversed_cancelled' : 'reversed';
+ }
+
+ /** Inside a transaction: queued effects → cancelled. True when anything was cancelled. */
+ function cancelQueued(i, reason) {
+ const at = iso(ctx.now());
+ const r = db.prepare("UPDATE interaction_effects SET state = 'cancelled', updated_at = ? WHERE interaction_id = ? AND state = 'queued'").run(at, i.id);
+ db.prepare("UPDATE paid_messages SET status = 'cancelled', updated_at = ? WHERE interaction_id = ? AND status = 'queued'").run(at, i.id);
+ db.prepare("UPDATE paid_media_requests SET status = 'cancelled', updated_at = ? WHERE interaction_id = ? AND status = 'queued'").run(at, i.id);
+ if (!r.changes) return false;
+ db.prepare("UPDATE tip_interactions SET delivery_state = 'cancelled', updated_at = ? WHERE id = ?").run(at, i.id);
+ if (!i.test) ctx.outbox.emit('tips.interaction.cancelled', { type: 'interaction', id: i.id }, { ...summary(get(i.id)), reason });
+ return true;
+ }
+
+ /** Aggregate the effects into delivery_state (inside a transaction). Emits failed once. */
+ function recomputeDelivery(id) {
+ const i = get(id);
+ if (!i || i.delivery_state === 'cancelled' || i.delivery_state === 'awaiting_payment') return i;
+ const effects = db.prepare('SELECT state FROM interaction_effects WHERE interaction_id = ?').all(id).map((e) => e.state);
+ let next = 'queued';
+ if (effects.includes('failed') && !effects.includes('queued')) next = 'failed';
+ else if (!effects.includes('queued')) next = 'delivered';
+ if (next === i.delivery_state) return i;
+ const at = iso(ctx.now());
+ db.prepare(`UPDATE tip_interactions SET delivery_state = ?, delivered_at = CASE WHEN ? = 'delivered' THEN ? ELSE delivered_at END, updated_at = ? WHERE id = ?`).run(next, next, at, at, id);
+ if (next === 'failed' && !i.test) {
+ const failed = db.prepare("SELECT effect, adapter, last_error FROM interaction_effects WHERE interaction_id = ? AND state = 'failed'").all(id);
+ ctx.outbox.emit('tips.interaction.failed', { type: 'interaction', id }, { ...summary(get(id)), failed_effects: failed });
+ }
+ return get(id);
+ }
+
+ // ── External and simulated ───────────────────────────────
+ /** A tip on the creator's own PowerChat (EXTERNAL, ADR-012): recorded once by provider ref. */
+ function recordExternal(profile, input) {
+ if (!profile) fail(404, 'tips.creator_not_found', 'this creator has no tip profile');
+ const provider = String(input.provider || '').toLowerCase();
+ if (!/^[a-z][a-z0-9_-]{1,39}$/.test(provider)) fail(422, 'tips.invalid_input', 'provider is required');
+ const ref = String(input.provider_ref || '').trim();
+ if (!ref || ref.length > 200) fail(422, 'tips.invalid_input', 'provider_ref (the provider event id) is required');
+ const cents = positiveInt(input.amount_cents, 'amount_cents', 100_000_000);
+ return ctx.tx(() => {
+ const prev = db.prepare('SELECT * FROM tip_interactions WHERE provider = ? AND provider_ref = ?').get(provider, ref);
+ if (prev) return { interaction: prev, replay: true };
+ const i = insert({
+ id: prefixedId('tint', ctx.now()), creator_subject: profile.creator_subject, supporter_subject: input.supporter ? userSubject(input.supporter, 'supporter') : null,
+ supporter_name: displayName(input.supporter_name) || 'Someone', kind: 'tip', amount: cents, amount_cents: cents,
+ message: text(input.message, 'message', 500), request: input.goal_id ? { goal_id: String(input.goal_id) } : {},
+ funding: 'external', settlement: 'external', payment_state: 'pending', delivery_state: 'awaiting_payment',
+ // origin external: the provider (or Live's webhook) already announced it in chat; with
+ // announce: true Tips delivers the chat line itself.
+ provider, provider_ref: ref, origin: input.announce === true ? 'tips' : 'external', test: input.test ? 1 : 0,
+ });
+ return { interaction: settle(i), replay: false };
+ });
+ }
+
+ /** The full effect path with test = 1 and no Billing call. */
+ function simulate(profile, input, { by }) {
+ const v = validate(profile, { ...input, amount: input.amount || Math.max(profile.min_amount, 100) }, { simulation: true });
+ return ctx.tx(() => {
+ const i = insert({
+ id: prefixedId('tint', ctx.now()), creator_subject: profile.creator_subject, supporter_subject: null,
+ supporter_name: displayName(input.supporter_name) || 'Test supporter', kind: v.kind, amount: v.amount, message: v.message,
+ request: { ...v.request, simulated_by: by }, funding: 'none', settlement: 'simulated', payment_state: 'pending',
+ delivery_state: 'awaiting_payment', test: 1,
+ });
+ const out = settle(i);
+ // Show the goal widget moving without counting anything: a test goal delivery with the
+ // would-be total. No contribution row is written.
+ const g = ctx.goals.pick(profile.creator_subject, v.request.goal_id);
+ if (g) {
+ const view = ctx.goals.present(g);
+ const would = { ...view, current_amount: view.current_amount + v.amount, percent: Math.min(100, Math.floor(((view.current_amount + v.amount) * 100) / view.target_amount)) };
+ ctx.overlays.addGoalDelivery(profile.creator_subject, would, { reason: 'simulation', interactionId: i.id, by: i.supporter_name, dedupe: `goal:${g.id}:sim:${i.id}`, test: true });
+ }
+ return out;
+ });
+ }
+
+ // ── Reads ────────────────────────────────────────────────
+ function summary(i) {
+ return {
+ interaction_id: i.id, creator: { type: 'user', id: i.creator_subject }, supporter: i.supporter_subject ? { type: 'user', id: i.supporter_subject } : null,
+ supporter_name: i.supporter_name || null, kind: i.kind, amount: i.amount, currency: i.currency, settlement: i.settlement,
+ payment_state: i.payment_state, delivery_state: i.delivery_state, billing_txn_id: i.billing_txn_id || null, test: !!i.test,
+ };
+ }
+
+ function present(i, { viewer = 'owner' } = {}) {
+ if (!i) return null;
+ const req = json(i.request, {});
+ const out = {
+ id: i.id, creator: { type: 'user', id: i.creator_subject }, supporter: i.supporter_subject ? { type: 'user', id: i.supporter_subject } : null,
+ supporter_name: i.supporter_name || null, kind: i.kind, amount: i.amount, currency: i.currency, message: i.message || null,
+ tts: req.tts || null, media: req.media ? { url: req.media.url, provider: req.media.provider } : null, goal_id: req.goal_id || null,
+ funding: i.funding, settlement: i.settlement, origin: i.origin, test: !!i.test,
+ payment: { state: i.payment_state, billing_txn_id: i.billing_txn_id || null, reversal_txn_ids: json(i.reversal_txn_ids, []), reversed_amount: i.reversed_bits, failure: i.failure || null, settled_at: i.settled_at || null, reversed_at: i.reversed_at || null },
+ delivery: { state: i.delivery_state, delivered_at: i.delivered_at || null },
+ created_at: i.created_at, updated_at: i.updated_at,
+ };
+ if (i.funding === 'checkout') out.checkout = { url: i.checkout_url || null, ref: i.checkout_ref || null, provider: i.provider || null, intent_id: i.billing_intent_id || null };
+ if (viewer === 'owner' || viewer === 'service') {
+ out.effects = db.prepare('SELECT effect, adapter, state, attempts, last_error, updated_at FROM interaction_effects WHERE interaction_id = ? ORDER BY id').all(i.id);
+ out.provider = i.provider || null;
+ out.provider_ref = i.provider_ref || null;
+ out.legacy_source = i.legacy_source || null;
+ }
+ return out;
+ }
+
+ /** Cursor-paged list, newest first. filter: { creator?, supporter?, includeTest? } */
+ function list({ creator, supporter, includeTest = true, cursor, limit = 50 } = {}) {
+ const n = Math.min(200, Math.max(1, Number(limit) || 50));
+ let cur = null;
+ if (cursor) { try { cur = JSON.parse(Buffer.from(String(cursor), 'base64url').toString('utf8')); } catch { fail(422, 'tips.invalid_input', 'bad cursor'); } }
+ const rows = db.prepare(`SELECT * FROM tip_interactions WHERE (@c IS NULL OR creator_subject = @c) AND (@s IS NULL OR supporter_subject = @s)
+ AND (@t = 1 OR test = 0) AND (@ca IS NULL OR created_at < @ca OR (created_at = @ca AND id < @ci))
+ ORDER BY created_at DESC, id DESC LIMIT @n`).all({ c: creator || null, s: supporter || null, t: includeTest ? 1 : 0, ca: cur ? cur[0] : null, ci: cur ? cur[1] : null, n: n + 1 });
+ const page = rows.slice(0, n);
+ const next = rows.length > n ? Buffer.from(JSON.stringify([page[n - 1].created_at, page[n - 1].id])).toString('base64url') : null;
+ return { rows: page, next_cursor: next };
+ }
+
+ /**
+ * A creator's totals, derived from settled interactions. `billing` is what must equal Billing's
+ * books (settled through Billing or imported from Live's ledger, minus what Billing took back);
+ * external and test interactions are reported apart and never mixed in.
+ */
+ function totals(creator) {
+ const r = db.prepare(`SELECT
+ COALESCE(SUM(CASE WHEN test = 0 AND settlement IN ('billing', 'imported') AND payment_state IN ('settled', 'reversed') THEN amount - reversed_bits END), 0) AS billing,
+ COALESCE(SUM(CASE WHEN test = 0 AND settlement = 'external' AND payment_state = 'settled' THEN amount END), 0) AS external,
+ COALESCE(SUM(CASE WHEN test = 0 AND payment_state IN ('settled', 'reversed') THEN 1 END), 0) AS count,
+ COALESCE(SUM(CASE WHEN test = 1 THEN 1 END), 0) AS test_count,
+ COALESCE(SUM(CASE WHEN payment_state = 'pending' THEN 1 END), 0) AS pending_count
+ FROM tip_interactions WHERE creator_subject = ?`).get(creator);
+ return { currency: 'vibes-bits', settled_via_billing: r.billing, external: r.external, interactions: r.count, simulations: r.test_count, pending: r.pending_count };
+ }
+
+ return {
+ get, byBillingTxn, validate, request, transfer, startCheckout, processDueTransfers, settleByTransaction, settle, failPayment,
+ onBillingSettled, onBillingReversed, cancelQueued, recomputeDelivery, recordExternal, simulate, summary, present, list, totals, insert, KINDS,
+ };
+}
+
+module.exports = { createInteractions, KINDS, highlightFor };
new file mode 100644
new file mode 100644
@@ -0,0 +1,306 @@
@@ -0,0 +1,306 @@
+'use strict';
+
+/**
+ * Overlays: scoped, revocable tokens; configs; deliveries; the live SSE hub.
+ *
+ * Tokens. `tovl_<43 base64url chars>` shown ONCE at creation; only its SHA-256 is stored. A token
+ * carries scopes (alerts, goals), optionally a config, and is never a creator cookie: an OBS
+ * browser source holds it in its URL and nothing else. Revocation is immediate: the row is marked
+ * and every open stream of that token is closed in the same call.
+ *
+ * Deliveries. Every alert (a settled interaction) and goal change becomes one overlay_deliveries
+ * row with a monotonic seq (the SSE event id). The first time a row is written to any overlay it
+ * becomes `delivered` and emits tips.overlay.delivered; a row no overlay received within the
+ * delivery window becomes `failed` (tips.overlay.failed). A reconnect with Last-Event-ID
+ * REPLAYS rows after that id: replay only writes bytes to a socket — it never charges, never
+ * re-counts a goal, never emits another event. Simulated (test) rows are flagged and emit nothing.
+ */
+const crypto = require('crypto');
+const { fail, iso, prefixedId, sha256, json, text } = require('../util');
+const { safeUrl } = require('./profiles');
+
+const SCOPES = ['alerts', 'goals'];
+const TOKEN_RE = /^tovl_[A-Za-z0-9_-]{43}$/;
+
+function createOverlays(ctx) {
+ const { db } = ctx;
+ const clients = new Map(); // creator subject → Set<client>
+ let nextClient = 1;
+
+ // ── Tokens ───────────────────────────────────────────────
+ function createToken(creator, { scopes, label, configId, createdBy }) {
+ const list = Array.isArray(scopes) && scopes.length ? [...new Set(scopes.map(String))] : SCOPES;
+ if (list.some((s) => !SCOPES.includes(s))) fail(422, 'tips.invalid_input', `scopes must be among ${SCOPES.join(', ')}`);
+ if (configId) {
+ const c = getConfig(configId);
+ if (!c || c.creator_subject !== creator) fail(404, 'tips.overlay_config_not_found', 'no such overlay config for this creator');
+ }
+ const active = db.prepare('SELECT COUNT(*) AS n FROM overlay_tokens WHERE creator_subject = ? AND revoked_at IS NULL').get(creator).n;
+ if (active >= 25) fail(409, 'tips.too_many_tokens', 'revoke an overlay token before creating another (25 active at most)');
+ const secret = `tovl_${crypto.randomBytes(32).toString('base64url')}`;
+ const id = prefixedId('tovt', ctx.now());
+ db.prepare(`INSERT INTO overlay_tokens (id, creator_subject, token_hash, scopes, config_id, label, created_by, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(id, creator, sha256(secret), JSON.stringify(list), configId || null, text(label, 'label', 80), createdBy, iso(ctx.now()));
+ return { token: presentToken(getToken(id)), secret, overlay_url: `${ctx.config.baseUrl}/overlay/${secret}`, events_url: `${ctx.config.baseUrl}/overlay/${secret}/events` };
+ }
+ const getToken = (id) => db.prepare('SELECT * FROM overlay_tokens WHERE id = ?').get(String(id || '')) || null;
+ const listTokens = (creator) => db.prepare('SELECT * FROM overlay_tokens WHERE creator_subject = ? ORDER BY revoked_at IS NOT NULL, created_at DESC').all(creator);
+
+ /** The active token row for a presented secret, or null (unknown, malformed or revoked). */
+ function authenticate(secret) {
+ if (!TOKEN_RE.test(String(secret || ''))) return null;
+ const row = db.prepare('SELECT * FROM overlay_tokens WHERE token_hash = ?').get(sha256(secret));
+ if (!row || row.revoked_at) return null;
+ return { ...row, scopes: json(row.scopes, []) };
+ }
+
+ function revokeToken(row) {
+ if (!row.revoked_at) db.prepare('UPDATE overlay_tokens SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL').run(iso(ctx.now()), row.id);
+ closeToken(row.id);
+ return presentToken(getToken(row.id));
+ }
+
+ function presentToken(t) {
+ if (!t) return null;
+ return {
+ id: t.id, creator: { type: 'user', id: t.creator_subject }, scopes: json(t.scopes, []), label: t.label || null, config_id: t.config_id || null,
+ created_at: t.created_at, last_used_at: t.last_used_at || null, revoked_at: t.revoked_at || null, active: !t.revoked_at,
+ };
+ }
+
+ // ── Configs ──────────────────────────────────────────────
+ const getConfig = (id) => db.prepare('SELECT * FROM overlay_configs WHERE id = ?').get(String(id || '')) || null;
+ const listConfigs = (creator) => db.prepare('SELECT * FROM overlay_configs WHERE creator_subject = ? ORDER BY created_at').all(creator);
+
+ function settingsOf(kind, input = {}, base = {}) {
+ const s = { ...base };
+ const int = (k, lo, hi) => {
+ if (input[k] === undefined || input[k] === '') return;
+ const n = Number(input[k]);
+ if (!Number.isInteger(n) || n < lo || n > hi) fail(422, 'tips.invalid_input', `${k} must be between ${lo} and ${hi}`);
+ s[k] = n;
+ };
+ const bool = (k) => { if (input[k] !== undefined) s[k] = input[k] === true || input[k] === 'on' || input[k] === '1' || input[k] === 1; };
+ const url = (k) => {
+ if (input[k] === undefined) return;
+ if (!input[k]) { s[k] = null; return; }
+ const u = safeUrl(input[k]);
+ if (!u) fail(422, 'tips.invalid_input', `${k} must be an https URL`);
+ s[k] = u;
+ };
+ if (kind === 'alerts') {
+ int('min_amount', 1, 10_000_000); int('duration_ms', 1000, 60_000);
+ bool('show_message'); bool('show_amount'); bool('speak_message');
+ url('sound_url'); url('image_url');
+ if (input.template !== undefined) s.template = text(input.template, 'template', 120);
+ } else {
+ bool('show_amounts'); bool('show_percent');
+ }
+ return s;
+ }
+ const DEFAULTS = {
+ alerts: { min_amount: 1, duration_ms: 8000, show_message: true, show_amount: true, speak_message: false, sound_url: null, image_url: null, template: '{name} tipped {amount} Vibes' },
+ goal: { show_amounts: true, show_percent: true },
+ };
+
+ function createConfig(creator, input = {}) {
+ const kind = input.kind === 'goal' ? 'goal' : input.kind === 'alerts' ? 'alerts' : fail(422, 'tips.invalid_input', "kind must be 'alerts' or 'goal'");
+ const name = text(input.name, 'name', 80) || (kind === 'alerts' ? 'Alerts' : 'Goal');
+ let goalId = null;
+ if (kind === 'goal' && input.goal_id) {
+ const g = ctx.goals.get(input.goal_id);
+ if (!g || g.creator_subject !== creator) fail(404, 'tips.goal_not_found', 'no such goal for this creator');
+ goalId = g.id;
+ }
+ const id = prefixedId('tovc', ctx.now());
+ const at = iso(ctx.now());
+ db.prepare(`INSERT INTO overlay_configs (id, creator_subject, kind, name, settings, goal_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
+ .run(id, creator, kind, name, JSON.stringify(settingsOf(kind, input, DEFAULTS[kind])), goalId, at, at);
+ return presentConfig(getConfig(id));
+ }
+
+ function updateConfig(c, input = {}) {
+ if (input.revision != null && Number(input.revision) !== c.revision) fail(409, 'tips.revision_conflict', `the config is at revision ${c.revision}`);
+ const settings = settingsOf(c.kind, input, json(c.settings, {}));
+ let goalId = c.goal_id;
+ if (c.kind === 'goal' && input.goal_id !== undefined) {
+ if (!input.goal_id) goalId = null;
+ else {
+ const g = ctx.goals.get(input.goal_id);
+ if (!g || g.creator_subject !== c.creator_subject) fail(404, 'tips.goal_not_found', 'no such goal for this creator');
+ goalId = g.id;
+ }
+ }
+ const name = input.name !== undefined ? (text(input.name, 'name', 80) || c.name) : c.name;
+ db.prepare('UPDATE overlay_configs SET name = ?, settings = ?, goal_id = ?, revision = revision + 1, updated_at = ? WHERE id = ?')
+ .run(name, JSON.stringify(settings), goalId, iso(ctx.now()), c.id);
+ const out = presentConfig(getConfig(c.id));
+ // Open overlays using this config pick the change up at once.
+ for (const cl of clients.get(c.creator_subject) || []) if (cl.configId === c.id) write(cl, 'config', null, { config: out });
+ return out;
+ }
+
+ function presentConfig(c) {
+ if (!c) return null;
+ return { id: c.id, creator: { type: 'user', id: c.creator_subject }, kind: c.kind, name: c.name, settings: json(c.settings, {}), goal_id: c.goal_id || null, revision: c.revision, created_at: c.created_at, updated_at: c.updated_at };
+ }
+
+ // ── Deliveries ───────────────────────────────────────────
+ function insertDelivery(creator, kind, { interactionId = null, goalId = null, payload, test = false, dedupe }) {
+ const id = prefixedId('tovd', ctx.now());
+ const r = db.prepare(`INSERT OR IGNORE INTO overlay_deliveries (id, creator_subject, kind, interaction_id, goal_id, payload, test, status, dedupe_key, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)`).run(id, creator, kind, interactionId, goalId, JSON.stringify(payload), test ? 1 : 0, dedupe, iso(ctx.now()));
+ if (r.changes) ctx.afterCommit(() => notify(creator));
+ return r.changes ? id : null;
+ }
+
+ /** Inside the settlement transaction (or a simulation). */
+ function addAlert(interaction, view) {
+ const payload = {
+ interaction_id: interaction.id, kind: interaction.kind, amount: interaction.amount, currency: interaction.currency,
+ supporter_name: interaction.supporter_name || 'Someone', message: view.message, tts: view.tts || null,
+ media: view.media || null, settlement: interaction.settlement, test: !!interaction.test, at: iso(ctx.now()),
+ };
+ return insertDelivery(interaction.creator_subject, 'alert', { interactionId: interaction.id, payload, test: interaction.test, dedupe: `alert:${interaction.id}` });
+ }
+
+ function addGoalDelivery(creator, goalView, { reason, interactionId, by, dedupe, test = false }) {
+ return insertDelivery(creator, 'goal', { interactionId, goalId: goalView.id, payload: { goal: goalView, reason, by: by || null, test }, test, dedupe });
+ }
+
+ /** First write of a pending row: delivered + the event (never for tests). */
+ function markDelivered(row) {
+ ctx.tx(() => {
+ const r = db.prepare("UPDATE overlay_deliveries SET status = 'delivered', delivered_at = ? WHERE seq = ? AND status = 'pending'").run(iso(ctx.now()), row.seq);
+ if (r.changes && !row.test) {
+ ctx.outbox.emit('tips.overlay.delivered', { type: 'overlay_delivery', id: row.id }, {
+ delivery_id: row.id, creator: { type: 'user', id: row.creator_subject }, kind: row.kind, interaction_id: row.interaction_id, goal_id: row.goal_id,
+ });
+ }
+ });
+ ctx.outboxKick();
+ }
+
+ /** Pending rows older than the window → failed (tips.overlay.failed). Returns how many. */
+ function sweepFailed() {
+ const cutoff = iso(ctx.now() - ctx.config.overlays.ttlMs);
+ const rows = db.prepare("SELECT * FROM overlay_deliveries WHERE status = 'pending' AND created_at < ? ORDER BY seq LIMIT 500").all(cutoff);
+ if (!rows.length) return 0;
+ ctx.tx(() => {
+ for (const row of rows) {
+ const r = db.prepare("UPDATE overlay_deliveries SET status = 'failed', failed_at = ? WHERE seq = ? AND status = 'pending'").run(iso(ctx.now()), row.seq);
+ if (r.changes && !row.test) {
+ ctx.outbox.emit('tips.overlay.failed', { type: 'overlay_delivery', id: row.id }, {
+ delivery_id: row.id, creator: { type: 'user', id: row.creator_subject }, kind: row.kind, interaction_id: row.interaction_id,
+ goal_id: row.goal_id, reason: 'no overlay showed it within the delivery window',
+ });
+ }
+ }
+ });
+ ctx.outboxKick();
+ return rows.length;
+ }
+
+ // ── SSE hub ──────────────────────────────────────────────
+ const scopeOf = (kind) => (kind === 'alert' ? 'alerts' : 'goals');
+
+ function write(client, event, id, data) {
+ if (client.closed) return false;
+ try {
+ client.res.write(`${id != null ? `id: ${id}\n` : ''}event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
+ return true;
+ } catch { return false; }
+ }
+
+ function send(client, row) {
+ client.lastSeq = Math.max(client.lastSeq, row.seq);
+ if (!client.scopes.includes(scopeOf(row.kind))) return;
+ const payload = json(row.payload, {});
+ if (row.kind === 'alert' && client.minAmount && payload.amount < client.minAmount && !row.test) return;
+ if (!write(client, row.kind, row.seq, { delivery_id: row.id, seq: row.seq, ...payload })) return;
+ db.prepare('UPDATE overlay_deliveries SET sends = sends + 1 WHERE seq = ?').run(row.seq);
+ if (row.status === 'pending') markDelivered(row);
+ }
+
+ function pushNew(client) {
+ const rows = db.prepare("SELECT * FROM overlay_deliveries WHERE creator_subject = ? AND seq > ? AND status != 'failed' ORDER BY seq LIMIT 200").all(client.creator, client.lastSeq);
+ for (const row of rows) send(client, row);
+ }
+
+ function notify(creator) {
+ for (const c of clients.get(creator) || []) pushNew(c);
+ }
+
+ /**
+ * Attach an SSE response. lastEventId (Last-Event-ID): replay what came after it (bounded).
+ * Without it: what is still pending within the window, then everything new.
+ */
+ function attach(token, res, { lastEventId, config } = {}) {
+ const settings = config ? json(config.settings, {}) : {};
+ const client = {
+ id: nextClient++, creator: token.creator_subject, tokenId: token.id, scopes: token.scopes, configId: token.config_id || null,
+ minAmount: config && config.kind === 'alerts' ? Number(settings.min_amount) || 0 : 0, res, lastSeq: 0, closed: false,
+ };
+ if (!clients.has(client.creator)) clients.set(client.creator, new Set());
+ clients.get(client.creator).add(client);
+ db.prepare('UPDATE overlay_tokens SET last_used_at = ? WHERE id = ?').run(iso(ctx.now()), token.id);
+
+ const maxSeq = db.prepare('SELECT COALESCE(MAX(seq), 0) AS n FROM overlay_deliveries WHERE creator_subject = ?').get(client.creator).n;
+ write(client, 'hello', null, {
+ creator: { type: 'user', id: client.creator }, scopes: client.scopes, config: config ? presentConfig(config) : null,
+ goals: client.scopes.includes('goals') ? ctx.goals.list(client.creator, { status: 'active' }).map((g) => ctx.goals.present(g)) : [],
+ resumed_from: lastEventId != null ? lastEventId : null,
+ });
+ const last = Number(lastEventId);
+ if (lastEventId != null && Number.isInteger(last) && last >= 0) {
+ const limit = ctx.config.overlays.replayLimit;
+ const rows = db.prepare("SELECT * FROM overlay_deliveries WHERE creator_subject = ? AND seq > ? AND status != 'failed' ORDER BY seq DESC LIMIT ?").all(client.creator, last, limit).reverse();
+ client.lastSeq = rows.length ? rows[0].seq - 1 : maxSeq;
+ for (const row of rows) send(client, row);
+ client.lastSeq = Math.max(client.lastSeq, maxSeq);
+ } else {
+ const cutoff = iso(ctx.now() - ctx.config.overlays.ttlMs);
+ const pending = db.prepare("SELECT * FROM overlay_deliveries WHERE creator_subject = ? AND status = 'pending' AND created_at >= ? ORDER BY seq LIMIT 100").all(client.creator, cutoff);
+ for (const row of pending) send(client, row);
+ client.lastSeq = Math.max(client.lastSeq, maxSeq);
+ }
+ pushNew(client);
+ const beat = setInterval(() => { if (!write(client, 'ping', null, { t: Date.now() })) detach(client); }, ctx.config.overlays.heartbeatMs);
+ if (beat.unref) beat.unref();
+ client.beat = beat;
+ res.on('close', () => detach(client));
+ return client;
+ }
+
+ function detach(client) {
+ if (client.closed) return;
+ client.closed = true;
+ clearInterval(client.beat);
+ const set = clients.get(client.creator);
+ if (set) { set.delete(client); if (!set.size) clients.delete(client.creator); }
+ try { client.res.end(); } catch { /* already gone */ }
+ }
+
+ function closeToken(tokenId) {
+ for (const set of clients.values()) {
+ for (const c of [...set]) {
+ if (c.tokenId === tokenId) { write(c, 'revoked', null, { reason: 'this overlay token was revoked' }); detach(c); }
+ }
+ }
+ }
+
+ function connected(creator) { return (clients.get(creator) || new Set()).size; }
+ function closeAll() { for (const set of clients.values()) for (const c of [...set]) detach(c); }
+
+ function recentDeliveries(creator, limit = 50) {
+ return db.prepare('SELECT seq, id, kind, interaction_id, goal_id, test, status, sends, created_at, delivered_at, failed_at FROM overlay_deliveries WHERE creator_subject = ? ORDER BY seq DESC LIMIT ?').all(creator, limit);
+ }
+
+ return {
+ SCOPES, createToken, getToken, listTokens, authenticate, revokeToken, presentToken,
+ getConfig, listConfigs, createConfig, updateConfig, presentConfig,
+ addAlert, addGoalDelivery, sweepFailed, attach, detach, closeToken, notify, connected, closeAll, recentDeliveries,
+ };
+}
+
+module.exports = { createOverlays, SCOPES, TOKEN_RE };
new file mode 100644
new file mode 100644
@@ -0,0 +1,127 @@
@@ -0,0 +1,127 @@
+'use strict';
+
+/**
+ * Creator tip profiles: one per creator subject. A profile is created the first time the creator
+ * opens the dashboard (or a service creates it for them); the handle and display name are a
+ * projection of the Network account, refreshed on every dashboard visit.
+ *
+ * page_enabled is the creator's switch for the public page at openvibe.tips/<handle>: while it is
+ * off the page answers 404 to everyone else and nothing about it is indexable.
+ */
+const { fail, iso, text } = require('../util');
+
+const HANDLE_RE = /^[a-z0-9][a-z0-9_.-]{0,39}$/;
+// Paths the site itself uses; no creator page can take them.
+const RESERVED = new Set(['api', 'auth', 'overlay', 'internal', 'dashboard', 'receipts', 'assets', 'css', 'js', 'img', 'favicon.svg',
+ 'robots.txt', 'sitemap.xml', 'release.json', 'manifest.webmanifest', 'terms', 'privacy', 'dmca', 'tos', 'about', 'help', 'goals', 'tips', 'admin', 'www']);
+const VOICES = ['gary', 'brian', 'amy', 'emma', 'joey', 'justin', 'matthew', 'salli', 'kimberly', 'kendra', 'ivy', 'joanna'];
+
+function normalizeHandle(v) {
+ const h = String(v || '').trim().toLowerCase().replace(/^@/, '');
+ return HANDLE_RE.test(h) && !RESERVED.has(h) ? h : null;
+}
+
+function parse(row) {
+ if (!row) return null;
+ const b = (k) => !!row[k];
+ return { ...row, page_enabled: b('page_enabled'), accepting: b('accepting'), tts_enabled: b('tts_enabled'), media_requests_enabled: b('media_requests_enabled') };
+}
+
+function createProfiles(ctx) {
+ const { db } = ctx;
+
+ const bySubject = (s) => parse(db.prepare('SELECT * FROM creator_tip_profiles WHERE creator_subject = ?').get(s));
+ const byHandle = (h) => { const n = normalizeHandle(h); return n ? parse(db.prepare('SELECT * FROM creator_tip_profiles WHERE handle = ?').get(n)) : null; };
+ /** usr_… or a handle (with or without @). */
+ const resolve = (ref) => (/^usr_/.test(String(ref || '')) ? bySubject(String(ref)) : byHandle(ref));
+
+ /** A handle not held by another creator: the username, else username-<suffix of the subject>. */
+ function freeHandle(subject, username) {
+ let h = normalizeHandle(username) || `creator-${subject.slice(-8).toLowerCase()}`;
+ const holder = db.prepare('SELECT creator_subject FROM creator_tip_profiles WHERE handle = ?').get(h);
+ if (holder && holder.creator_subject !== subject) h = `${h.slice(0, 30)}-${subject.slice(-6).toLowerCase()}`;
+ return h;
+ }
+
+ /** Create or refresh the profile from the creator's own Network account (claims). */
+ function ensure(subject, { username, displayName, avatarUrl } = {}) {
+ const at = iso(ctx.now());
+ const existing = bySubject(subject);
+ const name = String(displayName || username || 'Creator').replace(/[<>\u0000-\u001f]/g, '').trim().slice(0, 80) || 'Creator';
+ if (!existing) {
+ db.prepare(`INSERT INTO creator_tip_profiles (creator_subject, handle, display_name, avatar_url, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?)`).run(subject, freeHandle(subject, username), name, safeUrl(avatarUrl), at, at);
+ return bySubject(subject);
+ }
+ const handle = normalizeHandle(username) && normalizeHandle(username) !== existing.handle ? freeHandle(subject, username) : existing.handle;
+ if (handle !== existing.handle || name !== existing.display_name || (safeUrl(avatarUrl) || null) !== (existing.avatar_url || null)) {
+ db.prepare('UPDATE creator_tip_profiles SET handle = ?, display_name = ?, avatar_url = ?, updated_at = ? WHERE creator_subject = ?')
+ .run(handle, name, safeUrl(avatarUrl), at, subject);
+ }
+ return bySubject(subject);
+ }
+
+ const LIMITS = {
+ min_amount: [1, 1_000_000], paid_message_min: [1, 1_000_000], tts_min_amount: [1, 1_000_000],
+ media_request_min: [1, 1_000_000], media_max_seconds: [10, 3 * 3600],
+ };
+ const BOOLS = ['page_enabled', 'accepting', 'tts_enabled', 'media_requests_enabled'];
+
+ function update(subject, patch = {}, { expectedRevision } = {}) {
+ const p = bySubject(subject);
+ if (!p) fail(404, 'tips.profile_not_found', 'no tip profile for this creator');
+ if (expectedRevision != null && Number(expectedRevision) !== p.revision) fail(409, 'tips.revision_conflict', `the profile is at revision ${p.revision}`);
+ const set = {};
+ for (const k of BOOLS) if (patch[k] !== undefined) set[k] = patch[k] === true || patch[k] === 'on' || patch[k] === '1' || patch[k] === 1 ? 1 : 0;
+ for (const [k, [lo, hi]] of Object.entries(LIMITS)) {
+ if (patch[k] === undefined || patch[k] === '') continue;
+ const n = Number(patch[k]);
+ if (!Number.isInteger(n) || n < lo || n > hi) fail(422, 'tips.invalid_input', `${k} must be a whole number between ${lo} and ${hi}`);
+ set[k] = n;
+ }
+ if (patch.tts_max_chars !== undefined && patch.tts_max_chars !== '') {
+ const n = Number(patch.tts_max_chars);
+ if (!Number.isInteger(n) || n < 20 || n > ctx.config.limits.ttsHardCap) fail(422, 'tips.invalid_input', `tts_max_chars must be between 20 and ${ctx.config.limits.ttsHardCap}`);
+ set.tts_max_chars = n;
+ }
+ if (patch.tts_voice !== undefined) {
+ if (!VOICES.includes(String(patch.tts_voice))) fail(422, 'tips.invalid_input', `tts_voice must be one of ${VOICES.join(', ')}`);
+ set.tts_voice = String(patch.tts_voice);
+ }
+ if (patch.headline !== undefined) set.headline = text(patch.headline, 'headline', 280);
+ if (patch.display_name !== undefined) {
+ const n = text(patch.display_name, 'display_name', 80);
+ if (!n) fail(422, 'tips.invalid_input', 'display_name cannot be empty');
+ set.display_name = n.replace(/[<>]/g, '');
+ }
+ const keys = Object.keys(set);
+ if (!keys.length) return p;
+ db.prepare(`UPDATE creator_tip_profiles SET ${keys.map((k) => `${k} = @${k}`).join(', ')}, revision = revision + 1, updated_at = @at WHERE creator_subject = @s`)
+ .run({ ...set, at: iso(ctx.now()), s: subject });
+ return bySubject(subject);
+ }
+
+ /** Public shape; `owner` adds the settings only the creator sees. */
+ function present(p, { owner = false } = {}) {
+ if (!p) return null;
+ const out = {
+ creator: { type: 'user', id: p.creator_subject }, handle: p.handle, display_name: p.display_name, avatar_url: p.avatar_url || null,
+ headline: p.headline || null, page_enabled: p.page_enabled, accepting: p.accepting, url: `${ctx.config.baseUrl}/${p.handle}`,
+ minimums: { tip: p.min_amount, paid_message: p.paid_message_min, tts: p.tts_enabled ? p.tts_min_amount : null, media_request: p.media_requests_enabled ? p.media_request_min : null },
+ tts: { enabled: p.tts_enabled, max_chars: p.tts_max_chars, voice: p.tts_voice },
+ media_requests: { enabled: p.media_requests_enabled, max_seconds: p.media_max_seconds },
+ currency: 'vibes-bits',
+ };
+ if (owner) Object.assign(out, { revision: p.revision, created_at: p.created_at, updated_at: p.updated_at });
+ return out;
+ }
+
+ return { bySubject, byHandle, resolve, ensure, update, present, normalizeHandle };
+}
+
+function safeUrl(u) {
+ if (!u) return null;
+ try { const x = new URL(String(u)); return x.protocol === 'https:' ? x.toString().slice(0, 500) : null; } catch { return null; }
+}
+
+module.exports = { createProfiles, normalizeHandle, RESERVED, VOICES, safeUrl };
new file mode 100644
new file mode 100644
@@ -0,0 +1,71 @@
@@ -0,0 +1,71 @@
+'use strict';
+
+/**
+ * Billing → Tips: POST /internal/events, the endpoint of Tips' OpenVibe.Events subscription
+ * (topic pattern `billing.transaction.*`, created by scripts/subscribe.js).
+ *
+ * billing.transaction.settled a donation targeting a Tips interaction settles it; a donation Tips
+ * did not start is recorded once; a purchase that funds a Tips
+ * checkout triggers the transfer
+ * billing.transaction.reversed flips the interaction's payment state; undelivered effects are
+ * cancelled, delivered ones stay on record
+ *
+ * Exactly once, twice over: the openvibe-sdk inbox claims (consumer, event_id) in the same SQLite
+ * transaction as the change, so a redelivered event does nothing; and the Billing transaction id is
+ * UNIQUE on tip_interactions, so the same transaction arriving under another event id (a replay, a
+ * republish) still yields one logical interaction.
+ *
+ * The signature (X-OpenVibe-Signature, HMAC-SHA256 of the raw body with TIPS_EVENTS_SECRET) is
+ * verified with openvibe-sdk's parseDelivery. Only events whose source is `billing` are applied.
+ */
+const express = require('express');
+const { http } = require('openvibe-contracts');
+const { parseDelivery, createInbox } = require('openvibe-sdk/events');
+
+const CONSUMER = 'tips-billing';
+
+function consumerRouter({ domain, config, log = console }) {
+ const router = express.Router();
+ const inbox = createInbox(domain.db, { now: domain.now });
+ inbox.ensureSchema();
+
+ /** Apply one envelope (also used by tests and the replay tool). Returns { duplicate, outcome }. */
+ function apply(event) {
+ return domain.tx(() => {
+ const r = inbox.once(CONSUMER, event.event_id, () => {
+ if (event.source !== 'billing') return 'ignored:source';
+ const payload = event.payload && typeof event.payload === 'object' ? event.payload : {};
+ if (event.event_type === 'billing.transaction.settled') return domain.interactions.onBillingSettled(payload);
+ if (event.event_type === 'billing.transaction.reversed') return domain.interactions.onBillingReversed(payload);
+ return 'ignored:type';
+ });
+ return r.duplicate ? { duplicate: true, outcome: null } : { duplicate: false, outcome: r.result };
+ });
+ }
+
+ router.post('/events', express.raw({ type: () => true, limit: '256kb' }), (req, res) => {
+ const secrets = config.events.webhookSecrets;
+ if (!secrets.length) return http.sendProblem(res, 503, 'tips.webhook_disabled', { detail: 'TIPS_EVENTS_SECRET is not set', ctx: req.ov });
+ const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
+ let delivery = null;
+ for (const s of secrets) { delivery = parseDelivery(raw, req.headers, s); if (delivery) break; }
+ if (!delivery) return http.sendProblem(res, 401, 'tips.bad_signature', { detail: 'X-OpenVibe-Signature does not verify', ctx: req.ov });
+ const event = delivery.event;
+ if (!event || typeof event.event_id !== 'string' || !/^evt_[0-9A-HJKMNP-TV-Z]{26}$/.test(event.event_id)) {
+ return http.sendProblem(res, 400, 'tips.bad_delivery', { detail: 'body must be { event: <envelope>, seq }', ctx: req.ov });
+ }
+ let out;
+ try {
+ out = apply(event);
+ } catch (e) {
+ // Not acknowledged: Events retries it, and the inbox claim rolled back with the change.
+ log.error(`[Tips] event ${event.event_id} (${event.event_type}) failed:`, e.message);
+ return http.sendProblem(res, 500, 'tips.event_failed', { detail: 'processing failed; it will be retried', ctx: req.ov });
+ }
+ res.status(200).json({ event_id: event.event_id, duplicate: out.duplicate, outcome: out.outcome });
+ });
+
+ return { router, apply };
+}
+
+module.exports = { consumerRouter, CONSUMER };
new file mode 100644
new file mode 100644
@@ -0,0 +1,66 @@
@@ -0,0 +1,66 @@
+'use strict';
+
+/**
+ * Tips → OpenVibe.Events through the openvibe-sdk transactional outbox (ADR-004).
+ *
+ * tips.interaction.ready settled: the interaction is active and its effects are queued
+ * tips.interaction.failed an effect gave up after its retries (payment state untouched)
+ * tips.interaction.cancelled undelivered effects cancelled (payment reversed/failed, or the creator)
+ * tips.goal.updated a goal's settled total, target or status changed
+ * tips.overlay.delivered an overlay delivery reached at least one overlay (first time only)
+ * tips.overlay.failed no overlay received it within the delivery window
+ *
+ * enqueue() runs inside the SQLite transaction that makes the change, so an event exists if and
+ * only if its change committed. Simulations (test) never produce events. The relay publishes with
+ * Tips' service token (events.event.publish, audience openvibe.events) only when EVENTS_URL and
+ * OV_OAUTH_CLIENT_SECRET are set; otherwise rows wait in event_outbox.
+ */
+const { createClient } = require('openvibe-sdk/core');
+const { createServiceTokenClient } = require('openvibe-sdk/auth');
+const { createEventsClient, createOutbox } = require('openvibe-sdk/events');
+
+const ACTOR = { type: 'service', id: 'tips' };
+
+function createTipsOutbox({ db, config, fetchImpl, now, log = console }) {
+ const enabled = !!(config.events.url && config.oauth.clientSecret);
+ const clientOpts = { baseUrls: { events: config.events.url || 'http://127.0.0.1:4300' }, retries: 0 };
+ if (fetchImpl) clientOpts.fetch = fetchImpl;
+ if (enabled) {
+ clientOpts.tokenProvider = createServiceTokenClient({
+ tokenUrl: `${config.network.internalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
+ scope: { 'openvibe.events': 'events.event.publish' }, ...(fetchImpl ? { fetch: fetchImpl } : {}),
+ });
+ } else {
+ clientOpts.getToken = async () => { throw new Error('events relay disabled (EVENTS_URL / OV_OAUTH_CLIENT_SECRET unset)'); };
+ }
+ const events = createEventsClient(createClient(clientOpts), { source: 'tips' });
+ let lastError = null;
+ const outbox = createOutbox(db, {
+ events,
+ intervalMs: config.events.intervalMs,
+ now,
+ onError: (err) => {
+ const msg = err && err.message;
+ if (msg !== lastError) log.warn('[Tips] event publish failed (will retry):', msg);
+ lastError = msg;
+ },
+ });
+ outbox.ensureSchema();
+
+ /** Inside the caller's transaction. */
+ function emit(eventType, subject, payload, { visibility = 'internal', priority = 'important', traceparent } = {}) {
+ return outbox.enqueue({ event_type: eventType, actor: ACTOR, subject, payload, visibility, priority }, { traceparent });
+ }
+
+ return {
+ emit,
+ outbox,
+ enabled,
+ start() { if (enabled) outbox.start(); },
+ stop: () => outbox.stop(),
+ kick() { if (enabled) outbox.kick(); },
+ status: () => ({ enabled, pending: outbox.pending(), rejected: outbox.rejected(), last_error: lastError }),
+ };
+}
+
+module.exports = { createTipsOutbox };
new file mode 100644
new file mode 100644
@@ -0,0 +1,289 @@
@@ -0,0 +1,289 @@
+'use strict';
+
+/**
+ * Import tip history from a SNAPSHOT COPY of OpenVibe.Live's database (opened read-only), linked to
+ * OpenVibe.Billing's imported journal, with a reconciliation report.
+ *
+ * 1. identities every Live user id in a tip row → Network subject (resolve-batch). A tip whose
+ * CREATOR has no subject is held (migration_maps status 'held'), never dropped; a
+ * later run imports it once the Network knows the creator. An unmapped supporter is
+ * imported with no subject and their Live username as the display name.
+ * 2. donations transactions (type 'donation') → tip_interactions, settlement 'imported':
+ * completed → payment settled; anything else → excluded with the reason (no balance
+ * moved on Live). Rows before site_settings.stats_vibes_reset_at are imported flagged
+ * test, exactly as Billing flags them. Media-request charges ("Media request: …")
+ * become kind media_request. Each row is linked to Billing's transaction for it
+ * (Billing's importer key `import:live:txn:<live id>`).
+ * 3. refunds transactions (type 'refund', streamer → donor) are applied to the latest earlier
+ * donation from that donor to that streamer that still has room (reversed_bits), so a
+ * creator's total nets them out the way Billing's journal does; none left → held.
+ * 4. external chat_messages of kind donation with source powerchat — the only record Live kept of
+ * tips on a streamer's OWN PowerChat (EXTERNAL, no Billing liability). A message that
+ * matches a site-routed "PowerChat tip via site account" donation row (same streamer,
+ * same amount, within 10 minutes) is that row's celebration and is excluded, not
+ * counted twice.
+ * 5. goals donation_goals → tip_goals (Live's current_amount carried over as opening_amount:
+ * Live never recorded which donations counted toward which goal).
+ *
+ * Reconciliation: per creator subject, Tips' settled-via-Billing total (non-test, minus refunds) must
+ * equal Billing's (donations to the creator minus refunds from them, non-test). Any difference is
+ * listed; nothing is absorbed.
+ *
+ * Idempotent: every row is keyed by legacy_source / migration_maps (live, table, id); a re-run changes
+ * nothing, and a later snapshot only adds what is new or releases what was held. --dry-run does it all
+ * inside a transaction that is rolled back.
+ */
+const { iso, prefixedId, json } = require('../util');
+
+const liveTs = (s) => {
+ if (!s) return null;
+ const str = String(s);
+ const t = Date.parse(str.includes('T') ? str : `${str.replace(' ', 'T')}Z`);
+ return Number.isFinite(t) ? new Date(t).toISOString() : null;
+};
+const tableExists = (db, name) => !!db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name);
+const columns = (db, t) => (tableExists(db, t) ? db.prepare(`PRAGMA table_info(${t})`).all().map((c) => c.name) : []);
+
+class DryRun extends Error {}
+
+/**
+ * Billing's side, from a read-only SNAPSHOT of billing.db (operator tool, like Billing reading Live's
+ * snapshot): the transaction Billing's importer made for each Live row, and per-creator totals.
+ */
+function billingSnapshotSource(bdb) {
+ const byKey = bdb.prepare("SELECT id, test FROM transactions WHERE idempotency_key = ?");
+ return {
+ name: 'billing snapshot',
+ linkLiveTxn(liveId) { const r = byKey.get(`import:live:txn:${liveId}`); return r ? r.id : null; },
+ creatorTotals() {
+ const totals = new Map();
+ const add = (s, n) => { if (s && n) totals.set(s, (totals.get(s) || 0) + n); };
+ const rows = bdb.prepare('SELECT id, type, status, from_subject, to_subject, reverses_txn, metadata FROM transactions WHERE test = 0').all();
+ const donations = new Set(rows.filter((t) => t.type === 'donation').map((t) => t.id));
+ for (const t of rows) {
+ const m = json(t.metadata, {});
+ if (t.type === 'import' && m.live && m.live.status === 'completed' && !m.note) {
+ // Billing's replay of Live's history: donation → the creator's payable; refund → out of it.
+ if (m.live.type === 'donation') add(t.to_subject, Number(m.live.amount) || 0);
+ if (m.live.type === 'refund') add(t.from_subject, -(Number(m.live.amount) || 0));
+ } else if (t.type === 'donation') {
+ add(t.to_subject, Number(m.amount_bits) || 0);
+ } else if (t.type === 'refund' && donations.has(t.reverses_txn) && m.amount_bits != null) {
+ // A credit-funded transfer given back (the creator's payable → the giver's credit).
+ add(t.from_subject, -(Number(m.amount_bits) || 0));
+ }
+ }
+ return totals;
+ },
+ };
+}
+
+async function importLive(domain, { live, billingSource = null, resolveLiveUsers, dryRun = false, log = console }) {
+ const { db } = domain;
+ const started = domain.now();
+ const runId = prefixedId('timp', started);
+ const at = iso(started);
+ const report = {
+ run_id: runId, dry_run: !!dryRun, started_at: at, source: 'openvibe-live snapshot', billing_source: billingSource ? billingSource.name : null,
+ counts: { imported: 0, held: 0, excluded: 0, refunds_applied: 0, external: 0, goals: 0, unchanged: 0, released: 0, linked_to_billing: 0, unlinked: 0 },
+ holds: [], excluded: [], unlinked: [], reconciliation: null,
+ };
+
+ // ── Read the snapshot ────────────────────────────────────
+ const txns = tableExists(live, 'transactions') ? live.prepare("SELECT * FROM transactions WHERE type IN ('donation', 'refund') ORDER BY id").all() : [];
+ const chatCols = columns(live, 'chat_messages');
+ const chatDonations = chatCols.includes('metadata')
+ ? live.prepare("SELECT id, channel_user_id, username, message, metadata, created_at FROM chat_messages WHERE message_type = 'donation' AND metadata LIKE '%powerchat%' ORDER BY id").all()
+ : [];
+ const goalRows = tableExists(live, 'donation_goals') ? live.prepare('SELECT * FROM donation_goals ORDER BY id').all() : [];
+ const users = new Map((tableExists(live, 'users') ? live.prepare('SELECT id, username, display_name FROM users').all() : []).map((u) => [String(u.id), u]));
+ let resetAt = null;
+ if (tableExists(live, 'site_settings')) {
+ const r = live.prepare("SELECT value FROM site_settings WHERE key = 'stats_vibes_reset_at'").get();
+ if (r && r.value && !Number.isNaN(Date.parse(r.value))) resetAt = new Date(r.value).toISOString();
+ }
+ report.test_before = resetAt;
+ report.counts.live = { tip_transactions: txns.length, powerchat_chat_donations: chatDonations.length, goals: goalRows.length };
+
+ // ── 1. identities ────────────────────────────────────────
+ const ids = new Set();
+ for (const t of txns) { if (t.from_user_id) ids.add(String(t.from_user_id)); if (t.to_user_id) ids.add(String(t.to_user_id)); }
+ for (const c of chatDonations) if (c.channel_user_id) ids.add(String(c.channel_user_id));
+ for (const g of goalRows) if (g.user_id) ids.add(String(g.user_id));
+ const map = ids.size ? await resolveLiveUsers([...ids]) : new Map();
+ const subjectOf = (liveId) => { const v = liveId == null ? null : map.get(String(liveId)); return v ? (typeof v === 'string' ? v : v.subject) : null; };
+ const nameOf = (liveId) => { const u = users.get(String(liveId)); return u ? (u.display_name || u.username) : null; };
+ report.counts.identities = { resolved: [...ids].filter((i) => subjectOf(i)).length, unmapped: [...ids].filter((i) => !subjectOf(i)).length };
+
+ const mapRow = db.prepare('SELECT * FROM migration_maps WHERE source = ? AND source_table = ? AND source_id = ?');
+ const upsertMap = db.prepare(`INSERT INTO migration_maps (source, source_table, source_id, target_type, target_id, status, reason, billing_txn_id, run_id, created_at, updated_at)
+ VALUES ('live', @table, @id, @target_type, @target_id, @status, @reason, @billing, @run, @at, @at)
+ ON CONFLICT (source, source_table, source_id) DO UPDATE SET target_type = excluded.target_type, target_id = excluded.target_id, status = excluded.status,
+ reason = excluded.reason, billing_txn_id = COALESCE(excluded.billing_txn_id, migration_maps.billing_txn_id), run_id = excluded.run_id, updated_at = excluded.updated_at`);
+ const record = (table, id, fields) => upsertMap.run({ table, id: String(id), target_type: null, target_id: null, reason: null, billing: null, run: runId, at, ...fields });
+
+ const run = () => {
+ // ── 2. donations ─────────────────────────────────────
+ for (const t of txns.filter((x) => x.type === 'donation')) {
+ const prev = mapRow.get('live', 'transactions', String(t.id));
+ if (prev && prev.status !== 'held') {
+ // Re-link to Billing when an earlier run had no Billing transaction for it yet.
+ if (prev.status === 'imported' && !prev.billing_txn_id && billingSource) {
+ const b = billingSource.linkLiveTxn(t.id);
+ if (b) { db.prepare('UPDATE tip_interactions SET billing_txn_id = ? WHERE id = ? AND billing_txn_id IS NULL').run(b, prev.target_id); record('transactions', t.id, { ...pick(prev), billing: b }); report.counts.linked_to_billing++; }
+ }
+ report.counts.unchanged++;
+ continue;
+ }
+ const amount = Math.round(Number(t.amount) || 0);
+ const creator = subjectOf(t.to_user_id);
+ if (t.status !== 'completed' || amount <= 0) {
+ const reason = amount <= 0 ? 'non-positive amount' : `status ${t.status}: no balance moved on Live`;
+ record('transactions', t.id, { status: 'excluded', reason });
+ report.counts.excluded++; report.excluded.push({ live_txn: t.id, reason });
+ continue;
+ }
+ if (!creator) {
+ const reason = `creator (Live user ${t.to_user_id}) has no Network subject yet`;
+ record('transactions', t.id, { status: 'held', reason });
+ report.counts.held++; report.holds.push({ live_txn: t.id, live_creator: t.to_user_id, amount });
+ continue;
+ }
+ const created = liveTs(t.created_at) || at;
+ const test = !!(resetAt && created < resetAt);
+ const billingTxn = billingSource ? billingSource.linkLiveTxn(t.id) : null;
+ const message = t.message ? String(t.message).slice(0, 500) : null;
+ const media = /^Media request:/i.test(message || '');
+ const site = /^PowerChat tip via site account/i.test(message || '');
+ const i = domain.interactions.insert({
+ id: prefixedId('tint', Date.parse(created)), creator_subject: creator, supporter_subject: subjectOf(t.from_user_id),
+ supporter_name: nameOf(t.from_user_id) || (site ? 'PowerChat supporter' : null), kind: media ? 'media_request' : 'tip', amount,
+ message: media || site ? null : message, request: { legacy_message: message, live_stream_id: t.stream_id || null },
+ funding: site ? 'provider' : 'credit', settlement: 'imported', payment_state: 'settled', delivery_state: 'delivered',
+ test: test ? 1 : 0, billing_txn_id: billingTxn, provider: site ? 'powerchat' : null, legacy_source: `live:transactions:${t.id}`,
+ origin: 'import', created_at: created,
+ target: t.stream_id ? { service: 'live', type: 'stream', id: String(t.stream_id) } : null,
+ });
+ db.prepare('UPDATE tip_interactions SET settled_at = ?, delivered_at = ? WHERE id = ?').run(created, created, i.id);
+ record('transactions', t.id, { status: 'imported', target_type: 'interaction', target_id: i.id, billing: billingTxn, reason: prev ? 'released from hold' : null });
+ if (prev) report.counts.released++;
+ report.counts.imported++;
+ if (billingTxn) report.counts.linked_to_billing++;
+ else { report.counts.unlinked++; report.unlinked.push({ live_txn: t.id, interaction: i.id }); }
+ }
+
+ // ── 3. refunds (streamer → donor) ────────────────────
+ for (const t of txns.filter((x) => x.type === 'refund')) {
+ const prev = mapRow.get('live', 'transactions', String(t.id));
+ if (prev && prev.status !== 'held') { report.counts.unchanged++; continue; }
+ const amount = Math.round(Number(t.amount) || 0);
+ if (t.status !== 'completed' || amount <= 0) { record('transactions', t.id, { status: 'excluded', reason: `refund with status ${t.status}` }); report.counts.excluded++; continue; }
+ const creator = subjectOf(t.from_user_id);
+ const donor = subjectOf(t.to_user_id);
+ const created = liveTs(t.created_at) || at;
+ const candidates = creator ? db.prepare(`SELECT * FROM tip_interactions WHERE creator_subject = ? AND settlement = 'imported' AND legacy_source LIKE 'live:transactions:%'
+ AND (supporter_subject IS ? ) AND created_at <= ? AND amount - reversed_bits > 0 ORDER BY created_at DESC`).all(creator, donor, created) : [];
+ // Plan first; apply only when the whole refund can be placed.
+ let left = amount;
+ const plan = [];
+ for (const c of candidates) {
+ if (!left) break;
+ const take = Math.min(left, c.amount - c.reversed_bits);
+ plan.push({ c, take });
+ left -= take;
+ }
+ if (left > 0) {
+ record('transactions', t.id, { status: 'held', reason: `refund of ${amount} from Live user ${t.from_user_id} to ${t.to_user_id}: no earlier imported donation left to apply it to` });
+ report.counts.held++; report.holds.push({ live_txn: t.id, refund: true, amount });
+ continue;
+ }
+ const applied = [];
+ for (const { c, take } of plan) {
+ const ids = json(c.reversal_txn_ids, []); ids.push(`live:transactions:${t.id}`);
+ db.prepare("UPDATE tip_interactions SET reversed_bits = reversed_bits + ?, payment_state = 'reversed', reversed_at = ?, reversal_txn_ids = ? WHERE id = ?").run(take, created, JSON.stringify(ids), c.id);
+ applied.push({ interaction: c.id, bits: take });
+ }
+ record('transactions', t.id, { status: 'imported', target_type: 'refund', target_id: applied.map((a) => a.interaction).join(','), billing: billingSource ? billingSource.linkLiveTxn(t.id) : null });
+ report.counts.refunds_applied++;
+ }
+
+ // ── 4. external PowerChat tips (chat record only) ────
+ const siteRows = txns.filter((x) => x.type === 'donation' && /^PowerChat tip via site account/i.test(x.message || ''));
+ const usedSite = new Set();
+ for (const c of chatDonations) {
+ const m = json(c.metadata, {});
+ if (m.source !== 'powerchat' || m.kind !== 'donation') continue;
+ const prev = mapRow.get('live', 'chat_messages', String(c.id));
+ if (prev && prev.status !== 'held') { report.counts.unchanged++; continue; }
+ const amount = Math.round(Number(m.amount) || 0);
+ const created = liveTs(c.created_at) || at;
+ const twin = siteRows.find((t) => !usedSite.has(t.id) && String(t.to_user_id) === String(c.channel_user_id) && Math.round(Number(t.amount)) === amount
+ && Math.abs(Date.parse(liveTs(t.created_at) || at) - Date.parse(created)) <= 10 * 60 * 1000);
+ if (twin) {
+ usedSite.add(twin.id);
+ record('chat_messages', c.id, { status: 'excluded', reason: `celebration of site-routed tip (Live transaction ${twin.id})` });
+ report.counts.excluded++;
+ continue;
+ }
+ if (amount <= 0) { record('chat_messages', c.id, { status: 'excluded', reason: 'no amount' }); report.counts.excluded++; continue; }
+ const creator = subjectOf(c.channel_user_id);
+ if (!creator) {
+ record('chat_messages', c.id, { status: 'held', reason: `creator (Live user ${c.channel_user_id}) has no Network subject yet` });
+ report.counts.held++; report.holds.push({ live_chat_message: c.id, live_creator: c.channel_user_id, amount });
+ continue;
+ }
+ const i = domain.interactions.insert({
+ id: prefixedId('tint', Date.parse(created)), creator_subject: creator, supporter_name: m.username ? String(m.username).slice(0, 80) : 'PowerChat supporter',
+ kind: 'tip', amount, amount_cents: amount, message: m.message ? String(m.message).slice(0, 500) : null, funding: 'external', settlement: 'external',
+ payment_state: 'settled', delivery_state: 'delivered', test: resetAt && created < resetAt ? 1 : 0, provider: 'powerchat',
+ provider_ref: `live-chat:${c.id}`, legacy_source: `live:chat_messages:${c.id}`, origin: 'import', created_at: created,
+ });
+ db.prepare('UPDATE tip_interactions SET settled_at = ?, delivered_at = ? WHERE id = ?').run(created, created, i.id);
+ record('chat_messages', c.id, { status: 'imported', target_type: 'interaction', target_id: i.id });
+ report.counts.external++;
+ }
+
+ // ── 5. goals ─────────────────────────────────────────
+ for (const g of goalRows) {
+ const prev = mapRow.get('live', 'donation_goals', String(g.id));
+ if (prev && prev.status !== 'held') { report.counts.unchanged++; continue; }
+ const creator = subjectOf(g.user_id);
+ if (!creator) { record('donation_goals', g.id, { status: 'held', reason: `creator (Live user ${g.user_id}) has no Network subject yet` }); report.counts.held++; continue; }
+ const target = Math.round(Number(g.target_amount) || 0);
+ if (target <= 0 || !g.title) { record('donation_goals', g.id, { status: 'excluded', reason: 'no title or target' }); report.counts.excluded++; continue; }
+ const created = liveTs(g.created_at) || at;
+ const id = prefixedId('tgoal', Date.parse(created));
+ db.prepare(`INSERT INTO tip_goals (id, creator_subject, title, target_amount, image_url, status, sort_order, reached_at, closed_at, opening_amount, legacy_source, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, creator, String(g.title).slice(0, 120), target,
+ /^https:\/\//.test(String(g.image_url || '')) ? g.image_url : null, g.is_active ? 'active' : 'closed', Number(g.sort_order) || 0,
+ liveTs(g.reached_at), g.is_active ? null : at, Math.max(0, Math.round(Number(g.current_amount) || 0)), `live:donation_goals:${g.id}`, created, at);
+ record('donation_goals', g.id, { status: 'imported', target_type: 'goal', target_id: id, reason: `opening_amount ${Math.round(Number(g.current_amount) || 0)} carried over from Live's current_amount` });
+ report.counts.goals++;
+ }
+
+ if (dryRun) { report.reconciliation = reconcile(domain, billingSource); throw new DryRun(); }
+ };
+
+ try { db.transaction(run)(); } catch (e) { if (!(e instanceof DryRun)) throw e; }
+ if (!dryRun) report.reconciliation = reconcile(domain, billingSource);
+ report.finished_at = iso(domain.now());
+ db.prepare('INSERT INTO import_runs (id, source, dry_run, started_at, finished_at, report) VALUES (?, ?, ?, ?, ?, ?)').run(runId, 'live', dryRun ? 1 : 0, report.started_at, report.finished_at, JSON.stringify(report));
+ if (log && log.log) log.log(`[Tips] import ${runId}${dryRun ? ' (dry run, rolled back)' : ''}: ${report.counts.imported} tips, ${report.counts.external} external, ${report.counts.goals} goals, ${report.counts.held} held, ${report.counts.excluded} excluded; reconciliation ${report.reconciliation ? (report.reconciliation.ok ? 'OK' : 'MISMATCH') : 'skipped (no Billing source)'}`);
+ return report;
+}
+
+function pick(row) { return { status: row.status, target_type: row.target_type, target_id: row.target_id, reason: row.reason }; }
+
+/** Per-creator totals: Tips (settled via Billing or imported, non-test, minus refunds) vs Billing. */
+function reconcile(domain, billingSource) {
+ if (!billingSource) return null;
+ const billing = billingSource.creatorTotals();
+ const tips = new Map(domain.db.prepare(`SELECT creator_subject AS s, SUM(amount - reversed_bits) AS n FROM tip_interactions
+ WHERE test = 0 AND settlement IN ('billing', 'imported') AND payment_state IN ('settled', 'reversed') GROUP BY creator_subject`).all().map((r) => [r.s, r.n]));
+ const creators = [...new Set([...billing.keys(), ...tips.keys()])].sort();
+ const rows = creators.map((s) => ({ creator: s, tips: tips.get(s) || 0, billing: billing.get(s) || 0, difference: (tips.get(s) || 0) - (billing.get(s) || 0) }));
+ const mismatches = rows.filter((r) => r.difference !== 0);
+ return { ok: mismatches.length === 0, creators: rows.length, mismatches, rows };
+}
+
+module.exports = { importLive, reconcile, billingSnapshotSource, liveTs };
new file mode 100644
new file mode 100644
@@ -0,0 +1,45 @@
@@ -0,0 +1,45 @@
+'use strict';
+
+/**
+ * OpenVibe.Tips — process entry. `node server/index.js`
+ * Listens on PORT (4610) behind nginx (openvibe.tips); see deploy/.
+ *
+ * Background jobs (TIPS_JOBS=off disables them): the effects worker (chat delivery with retries),
+ * due Billing transfers (funded checkouts, retries after Billing was unreachable), the overlay
+ * delivery window (pending → failed), and the events outbox relay when EVENTS_URL is set.
+ */
+const { loadConfig } = require('./config');
+const { createApp } = require('./app');
+
+const config = loadConfig();
+const app = createApp({ config });
+const { domain, keys, outbox } = app.locals;
+keys.start();
+
+const timers = [];
+if (config.jobs.enabled) {
+ const every = (ms, fn) => { const t = setInterval(() => { Promise.resolve().then(fn).catch((e) => console.warn('[Tips] job:', e.message)); }, ms); t.unref(); timers.push(t); };
+ every(config.jobs.intervalMs, () => domain.effects.drain());
+ every(Math.max(config.jobs.intervalMs, 5000), () => domain.interactions.processDueTransfers());
+ every(30_000, () => domain.overlays.sweepFailed());
+ every(6 * 3600 * 1000, () => outbox.outbox.prune());
+ outbox.start();
+}
+
+const server = app.listen(config.port, config.host, () => {
+ console.log(`[Tips] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${config.dbPath})`);
+ console.log(`[Tips] billing ${config.billing.url}; chat adapter ${config.chat.adapter}; events relay ${outbox.enabled ? `→ ${config.events.url}` : 'off (outbox accumulates)'}; billing events ${config.events.webhookSecrets.length ? 'accepted' : 'not accepted (TIPS_EVENTS_SECRET unset)'}`);
+});
+server.keepAliveTimeout = 65_000;
+
+function shutdown(signal) {
+ console.log(`[Tips] ${signal} — closing`);
+ timers.forEach(clearInterval);
+ domain.overlays.closeAll();
+ outbox.stop();
+ keys.stop();
+ server.close(() => { try { domain.db.close(); } catch { /* */ } process.exit(0); });
+ setTimeout(() => process.exit(0), 5000).unref();
+}
+process.on('SIGTERM', () => shutdown('SIGTERM'));
+process.on('SIGINT', () => shutdown('SIGINT'));
new file mode 100644
new file mode 100644
@@ -0,0 +1,120 @@
@@ -0,0 +1,120 @@
+'use strict';
+
+/**
+ * OpenVibe.Network client.
+ *
+ * keys the Network's RS256 public key (OV_NETWORK_PUBLIC_KEY, else GET /api/.well-known/jwks,
+ * refreshed every 6 h and retried every 30 s until it loads). It verifies service tokens
+ * (audience openvibe.tips) and the browser's Network user JWT, both offline.
+ * identity Live user ids → canonical subjects via POST /internal/identity/resolve-batch
+ * (capability identity.subject.resolve, audience openvibe.network) — the importer only.
+ */
+const crypto = require('crypto');
+const { serviceAuth } = require('openvibe-contracts');
+const jwt = require('jsonwebtoken');
+const { OpenVibeAuthClient } = require('openvibe-shared/auth-client');
+
+const BATCH = 500;
+
+function createKeyProvider(config, { fetchImpl = globalThis.fetch, log = console } = {}) {
+ let pem = config.network.publicKey ? crypto.createPublicKey(config.network.publicKey).export({ type: 'spki', format: 'pem' }) : null;
+ let timer = null;
+ async function load() {
+ const url = `${config.network.internalUrl}/api/.well-known/jwks`;
+ try {
+ const res = await fetchImpl(url, { signal: AbortSignal.timeout(8000) });
+ if (!res.ok) throw new Error(`JWKS ${res.status}`);
+ const body = await res.json();
+ const jwk = (body.keys || []).find((k) => k.kty === 'RSA');
+ if (jwk) pem = crypto.createPublicKey({ key: jwk, format: 'jwk' }).export({ type: 'spki', format: 'pem' });
+ else if (typeof body.public_key === 'string') pem = crypto.createPublicKey(body.public_key).export({ type: 'spki', format: 'pem' });
+ else throw new Error('JWKS contained no keys');
+ return pem;
+ } catch (e) {
+ log.warn(`[Tips] Network key not loaded from ${url}: ${e.message}`);
+ return null;
+ }
+ }
+ function start() {
+ if (config.network.publicKey || timer) return;
+ const retry = () => load().then((k) => { if (!k) setTimeout(retry, 30_000).unref(); });
+ retry();
+ timer = setInterval(() => { load(); }, 6 * 60 * 60 * 1000);
+ timer.unref();
+ }
+ function stop() { if (timer) clearInterval(timer); timer = null; }
+ return { get: () => pem, load, start, stop };
+}
+
+/**
+ * The OAuth client of the browser session layer plus offline verification of the Network user
+ * JWT (RS256, issuer = the Network's public URL).
+ */
+function createUserAuth(config, keys) {
+ const client = new OpenVibeAuthClient({
+ clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret,
+ redirectUri: config.oauth.redirectUri,
+ publicKey: null,
+ authBase: config.network.url,
+ internalBase: config.network.internalUrl,
+ });
+ /** Claims of a valid user token, or null. Service tokens are never user tokens. */
+ function verify(token) {
+ if (!token) return null;
+ const key = keys.get();
+ if (!key) return null;
+ let claims;
+ try { claims = jwt.verify(token, key, { algorithms: ['RS256'], issuer: config.network.issuer }); } catch { return null; }
+ if (!claims || typeof claims !== 'object') return null;
+ if (typeof claims.sub === 'string' && /^(svc|app|mod):/.test(claims.sub)) return null;
+ if (claims.actor_type === 'service') return null;
+ return claims;
+ }
+ return { client, verify };
+}
+
+function createIdentity(config, { fetchImpl = globalThis.fetch } = {}) {
+ const base = config.network.internalUrl;
+ let tokens = null;
+ const tokenClient = () => {
+ if (!tokens) {
+ tokens = serviceAuth.createTokenClient({
+ tokenUrl: `${base}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
+ audience: 'openvibe.network', scope: 'identity.subject.resolve', fetchImpl,
+ });
+ }
+ return tokens;
+ };
+
+ async function post(body, retried = false) {
+ const res = await fetchImpl(`${base}/internal/identity/resolve-batch`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(await tokenClient().authHeaders()) },
+ body: JSON.stringify(body),
+ signal: AbortSignal.timeout(10000),
+ });
+ if (res.status === 401 && !retried) { tokenClient().invalidate(); return post(body, true); }
+ const data = await res.json().catch(() => null);
+ if (!res.ok || !data || typeof data.results !== 'object') throw new Error(`resolve-batch ${res.status}: ${(data && (data.detail || data.error)) || 'bad response'}`);
+ return data.results;
+ }
+
+ /** Live user ids → Map(String(id) → { subject, username } | null). */
+ async function resolveLiveUsers(liveIds) {
+ const out = new Map();
+ const all = [...new Set(liveIds.map(String))];
+ for (let i = 0; i < all.length; i += BATCH) {
+ const chunk = all.slice(i, i + BATCH);
+ const results = await post({ system: 'live', type: 'user', ids: chunk });
+ for (const k of chunk) {
+ const p = results[k];
+ out.set(k, p && p.subject && p.subject.type === 'user' ? { subject: p.subject.id, username: p.username || null } : null);
+ }
+ }
+ return out;
+ }
+ return { resolveLiveUsers };
+}
+
+module.exports = { createKeyProvider, createUserAuth, createIdentity };
new file mode 100644
new file mode 100644
@@ -0,0 +1,62 @@
@@ -0,0 +1,62 @@
+'use strict';
+
+/** Small shared pieces: ids, time, the error type, input checks. */
+const crypto = require('crypto');
+const { ids, validate } = require('openvibe-contracts');
+
+class TipsError extends Error {
+ constructor(status, code, detail, extra) {
+ super(detail || code);
+ this.status = status;
+ this.code = code;
+ this.detail = detail;
+ this.extra = extra;
+ }
+}
+
+function fail(status, code, detail, extra) { throw new TipsError(status, code, detail, extra); }
+
+const prefixedId = (prefix, ms = Date.now()) => `${prefix}_${ids.ulid(ms)}`;
+const iso = (ms) => new Date(ms).toISOString();
+const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
+const json = (v, d) => { if (v == null || v === '') return d; try { return JSON.parse(v); } catch { return d; } };
+
+function positiveInt(v, field, max) {
+ const n = Number(v);
+ if (!Number.isInteger(n) || n <= 0) fail(422, 'tips.invalid_amount', `${field} must be a positive whole number of bits`);
+ if (max && n > max) fail(422, 'tips.invalid_amount', `${field} must be at most ${max}`);
+ return n;
+}
+
+/** Optional trimmed text: null when empty, refused when longer than max. */
+function text(v, field, max) {
+ if (v == null) return null;
+ const s = String(v).replace(/\r\n?/g, '\n').trim();
+ if (!s) return null;
+ if (s.length > max) fail(422, 'tips.text_too_long', `${field} must be at most ${max} characters`);
+ return s;
+}
+
+/** A user SubjectRef or bare usr_ id → the subject id. */
+function userSubject(v, field = 'subject') {
+ const ref = typeof v === 'string' ? { type: 'user', id: v } : v;
+ if (!ref || !validate('identity.subject-ref@1', ref).valid || ref.type !== 'user' || !ids.isSubjectId('user', ref.id)) {
+ fail(422, 'tips.invalid_subject', `${field} must be a user SubjectRef ({ type: 'user', id: 'usr_…' })`);
+ }
+ return ref.id;
+}
+
+function entityRef(v, field = 'target') {
+ if (v == null) return null;
+ if (!validate('common.entity-ref@1', v).valid) fail(422, 'tips.invalid_input', `${field} must be an EntityRef ({ service, type, id })`);
+ return { service: v.service, type: v.type, id: String(v.id) };
+}
+
+/** The display name a supporter chose, kept short, without markup characters. */
+function displayName(v) {
+ if (v == null) return null;
+ const s = String(v).replace(/[<>\u0000-\u001f]/g, '').trim().slice(0, 80);
+ return s || null;
+}
+
+module.exports = { TipsError, fail, prefixedId, iso, sha256, json, positiveInt, text, userSubject, entityRef, displayName };
new file mode 100644
new file mode 100644
@@ -0,0 +1,108 @@
@@ -0,0 +1,108 @@
+'use strict';
+
+/**
+ * Page shell for every server-rendered page: full <head> SEO (title, description, canonical,
+ * robots, Open Graph), the shared OpenVibe chrome (inline critical canvas + app icon from
+ * openvibe-shared, navbar.js from the Network, the SSR footer and a <noscript> navigation), this
+ * site's stylesheet and its small progressive script. Everything is useful without JavaScript.
+ */
+const crypto = require('crypto');
+const fs = require('fs');
+const path = require('path');
+const appIcon = require('openvibe-shared/app-icon');
+const chrome = require('openvibe-shared/chrome-ssr');
+
+const SITE_NAME = 'OpenVibe.Tips';
+const NETWORK_URL = 'https://openvibe.network';
+const DEFAULT_DESCRIPTION = 'Support the creators you watch on OpenVibe: tips, goals, paid messages, text-to-speech and media requests, with stream overlays for creators.';
+const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
+
+const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+
+const _hashes = new Map();
+function asset(rel) {
+ if (!_hashes.has(rel)) {
+ let v = 'dev';
+ try { v = crypto.createHash('sha256').update(fs.readFileSync(path.join(PUBLIC_DIR, rel))).digest('hex').slice(0, 10); } catch { /* missing in tests */ }
+ _hashes.set(rel, v);
+ }
+ return `/${rel}?v=${_hashes.get(rel)}`;
+}
+const assetVersion = (rel) => { asset(rel); return _hashes.get(rel); };
+
+const NAV_LINKS = [
+ { label: 'Dashboard', href: '/dashboard' },
+ { label: 'Receipts', href: '/receipts' },
+];
+
+function createLayout({ config, release }) {
+ const abs = (p) => (/^https?:\/\//i.test(p) ? p : `${config.baseUrl}${p.startsWith('/') ? '' : '/'}${p}`);
+
+ /**
+ * o: title, description, canonicalPath, robots ('index,follow' | 'noindex,nofollow'), body,
+ * active ('home'|'dashboard'|'receipts'|'creator'), ogImage, jsonLd (array), viewer
+ */
+ function page(o) {
+ const title = o.title ? `${o.title} · ${SITE_NAME}` : `${SITE_NAME} — support the creators you watch`;
+ const description = (o.description || DEFAULT_DESCRIPTION).replace(/\s+/g, ' ').trim().slice(0, 300);
+ const canonical = abs(o.canonicalPath || '/');
+ const robots = o.robots || 'index,follow';
+ const nav = {
+ service: 'tips', apiBase: NETWORK_URL,
+ links: NAV_LINKS.map((l) => ({ ...l, active: o.active === l.label.toLowerCase() })),
+ history: { type: 'page', title: o.title || SITE_NAME },
+ silentLogin: `${config.baseUrl}/auth/login?silent=1&next={url}`,
+ sessionUrl: '/auth/me',
+ loginUrl: `/auth/login?next=${encodeURIComponent(o.canonicalPath || '/')}`,
+ };
+ const jsonLd = (o.jsonLd || []).map((x) => `<script type="application/ld+json">${JSON.stringify(x).replace(/</g, '\\u003c')}</script>`).join('\n');
+ const who = o.viewer
+ ? `<span class="who">Signed in as <b>${esc(o.viewer.name || o.viewer.username || 'you')}</b> · <a href="/auth/logout?next=/">Sign out</a></span>`
+ : `<a class="who" href="/auth/login?next=${encodeURIComponent(o.canonicalPath || '/')}">Sign in</a>`;
+ return `<!DOCTYPE html>
+<html lang="en" data-page="${esc(o.active || 'page')}">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>${esc(title)}</title>
+<meta name="description" content="${esc(description)}">
+<link rel="canonical" href="${esc(canonical)}">
+<meta name="robots" content="${esc(robots)}">
+<meta name="referrer" content="strict-origin-when-cross-origin">
+<meta property="og:site_name" content="${SITE_NAME}">
+<meta property="og:type" content="${o.ogType || 'website'}">
+<meta property="og:title" content="${esc(o.title || SITE_NAME)}">
+<meta property="og:description" content="${esc(description)}">
+<meta property="og:url" content="${esc(canonical)}">
+${o.ogImage ? `<meta property="og:image" content="${esc(o.ogImage)}">` : ''}
+<meta name="twitter:card" content="summary">
+${appIcon.headTags({ site: 'tips' })}
+${release ? release.metaTag() : ''}
+<script src="${NETWORK_URL}/shared/theme-loader.js" defer></script>
+<link rel="stylesheet" href="${asset('css/tips.css')}">
+${jsonLd}
+<script src="${NETWORK_URL}/shared/navbar.js" defer></script>
+<script src="${asset('js/tips.js')}" defer></script>
+</head>
+<body>
+<div id="navbar-mount"></div>
+${chrome.noscriptNav({ name: SITE_NAME, home: '/', links: NAV_LINKS })}
+<header class="site-head"><a class="brand" href="/">${SITE_NAME}</a><nav>${NAV_LINKS.map((l) => `<a href="${l.href}"${o.active === l.label.toLowerCase() ? ' aria-current="page"' : ''}>${l.label}</a>`).join('')}</nav>${who}</header>
+<main id="main" class="page">
+${o.body || ''}
+</main>
+${chrome.footer({ service: 'tips', variant: 'full' })}
+<script>
+window.__OV_PAGE = ${JSON.stringify({ navbar: nav }).replace(/</g, '\\u003c')};
+document.addEventListener('DOMContentLoaded', function () {
+ try { if (window.OpenVibeNavbar) { OpenVibeNavbar.init(window.__OV_PAGE.navbar); document.documentElement.classList.add('ov-has-navbar'); } } catch (e) { /* the SSR header stays */ }
+});
+</script>
+</body>
+</html>`;
+ }
+
+ return { page, abs };
+}
+
+module.exports = { createLayout, esc, asset, assetVersion, SITE_NAME, DEFAULT_DESCRIPTION };
new file mode 100644
new file mode 100644
@@ -0,0 +1,228 @@
@@ -0,0 +1,228 @@
+'use strict';
+
+/**
+ * Server-rendered page bodies. Plain HTML forms everywhere: a supporter can tip, and a creator can
+ * run the dashboard, with JavaScript switched off. Copy rules: no pricing claims, no pleading copy —
+ * the creator's own headline is the only pitch on their page.
+ */
+const { esc } = require('./layout');
+const { VOICES } = require('../domain/profiles');
+
+const n = (v) => Number(v || 0).toLocaleString('en-US');
+const when = (s) => (s ? esc(String(s).replace('T', ' ').slice(0, 16)) + ' UTC' : '');
+const KIND_LABEL = { tip: 'Tip', paid_message: 'Paid message', tts: 'Text-to-speech', media_request: 'Media request' };
+const PAY_LABEL = { pending: 'Payment pending', settled: 'Paid', reversed: 'Reversed', failed: 'Payment failed' };
+const DELIVERY_LABEL = { awaiting_payment: 'Waiting for payment', queued: 'Being delivered', delivered: 'Delivered', failed: 'Delivery failed', cancelled: 'Cancelled' };
+
+function hidden(fields) {
+ return Object.entries(fields).map(([k, v]) => `<input type="hidden" name="${esc(k)}" value="${esc(v)}">`).join('');
+}
+
+function goalCard(g) {
+ return `<article class="goal${g.reached ? ' reached' : ''}">
+ <h3>${esc(g.title)}</h3>
+ ${g.description ? `<p>${esc(g.description)}</p>` : ''}
+ <div class="bar" role="progressbar" aria-valuemin="0" aria-valuemax="${g.target_amount}" aria-valuenow="${g.current_amount}"><span style="width:${g.percent}%"></span></div>
+ <p class="meta">${n(g.current_amount)} of ${n(g.target_amount)} Vibes · ${g.percent}%${g.reached ? ' · reached' : ''}${g.status === 'closed' ? ' · closed' : ''}</p>
+</article>`;
+}
+
+function errorBox(msg) { return msg ? `<p class="notice error" role="alert">${esc(msg)}</p>` : ''; }
+
+function home({ creators }) {
+ return `<section class="hero">
+ <h1>Support the creators you watch</h1>
+ <p>Tip a creator with Vibes, send a highlighted paid message, have your message read out, or request a video for their stream. Creators set goals and show every tip on stream with an overlay.</p>
+ <p class="actions"><a class="btn" href="/dashboard">Open your creator dashboard</a> <a class="btn ghost" href="/receipts">Your receipts</a></p>
+</section>
+<section>
+ <h2>How it works</h2>
+ <ol class="steps">
+ <li><b>Pick a creator.</b> Every creator who switched their page on has one at openvibe.tips/<i>name</i>.</li>
+ <li><b>Choose what to send.</b> A tip, a paid message, text-to-speech or a media request, within the creator's own limits.</li>
+ <li><b>Pay with Vibes.</b> From your Vibes balance, or through checkout. Payments are handled by OpenVibe.Billing; your receipt shows when the payment settled and when your message was delivered.</li>
+ </ol>
+</section>
+${creators.length ? `<section><h2>Creators on OpenVibe.Tips</h2><ul class="creators">${creators.map((c) => `<li><a href="/${esc(c.handle)}">${c.avatar_url ? `<img src="${esc(c.avatar_url)}" alt="" width="40" height="40" loading="lazy">` : ''}<span>${esc(c.display_name)}</span></a></li>`).join('')}</ul></section>` : ''}`;
+}
+
+/** The tip form: every field works as a plain POST. */
+function tipForm({ profile, goals, viewer, csrf, idem, values = {}, providers, error }) {
+ const kinds = [['tip', 'Tip', profile.min_amount]];
+ kinds.push(['paid_message', 'Paid message (highlighted in chat)', Math.max(profile.min_amount, profile.paid_message_min)]);
+ if (profile.tts_enabled) kinds.push(['tts', 'Text-to-speech (read out on stream)', Math.max(profile.min_amount, profile.tts_min_amount)]);
+ if (profile.media_requests_enabled) kinds.push(['media_request', 'Media request', Math.max(profile.min_amount, profile.media_request_min)]);
+ const kind = values.kind || 'tip';
+ const active = goals.filter((g) => g.status === 'active');
+ if (!profile.accepting) return `<section class="card"><p>${esc(profile.display_name)} is not accepting tips right now.</p></section>`;
+ return `<form class="card tip-form" method="post" action="/${esc(profile.handle)}/tip">
+ <h2>Send ${esc(profile.display_name)} something</h2>
+ ${errorBox(error)}
+ ${hidden({ csrf: csrf || '', idem })}
+ <fieldset class="kinds"><legend>What to send</legend>
+ ${kinds.map(([k, label, min]) => `<label><input type="radio" name="kind" value="${k}"${k === kind ? ' checked' : ''}> ${esc(label)} <small>from ${n(min)} Vibes</small></label>`).join('\n ')}
+ </fieldset>
+ <label>Amount (Vibes) <input type="number" name="amount" min="1" max="10000000" step="1" required value="${esc(values.amount || '')}" inputmode="numeric"></label>
+ <label>Message <small>(optional for a tip, up to 300 characters)</small><textarea name="message" maxlength="300" rows="3">${esc(values.message || '')}</textarea></label>
+ ${profile.tts_enabled ? `<div class="when-tts"><label>Text to read out <small>(up to ${profile.tts_max_chars} characters; the message is used when empty)</small><textarea name="tts_text" maxlength="${profile.tts_max_chars}" rows="2">${esc(values.tts_text || '')}</textarea></label>
+ <label>Voice <select name="tts_voice">${VOICES.map((v) => `<option${v === (values.tts_voice || profile.tts_voice) ? ' selected' : ''}>${v}</option>`).join('')}</select></label></div>` : ''}
+ ${profile.media_requests_enabled ? `<label class="when-media">Media link <small>(YouTube, up to ${Math.floor(profile.media_max_seconds / 60)} minutes)</small><input type="url" name="media_url" value="${esc(values.media_url || '')}" placeholder="https://www.youtube.com/watch?v=…"></label>` : ''}
+ ${active.length ? `<label>Count toward <select name="goal_id">${active.length > 1 ? '<option value="">No goal</option>' : ''}${active.map((g) => `<option value="${esc(g.id)}"${values.goal_id === g.id ? ' selected' : ''}>${esc(g.title)}</option>`).join('')}</select></label>` : ''}
+ <fieldset class="pay"><legend>Pay with</legend>
+ <label><input type="radio" name="pay_with" value="credit"${values.pay_with !== 'checkout' ? ' checked' : ''}> My Vibes balance</label>
+ ${providers.length ? `<label><input type="radio" name="pay_with" value="checkout"${values.pay_with === 'checkout' ? ' checked' : ''}> Checkout (${providers.map(esc).join(', ')}) <small>buys exactly these Vibes and gives them</small></label>` : ''}
+ </fieldset>
+ <label>Show my name as <input type="text" name="supporter_name" maxlength="80" value="${esc(values.supporter_name || (viewer ? viewer.name || viewer.username || '' : ''))}"></label>
+ <button type="submit" class="btn">${viewer ? 'Send' : 'Sign in to send'}</button>
+ <p class="fine">Tips are final once paid. Your receipt shows the payment and delivery status separately.</p>
+</form>`;
+}
+
+function creatorPage({ profile, goals, viewer, csrf, idem, values, providers, error, ownerPreview }) {
+ const active = goals.filter((g) => g.status === 'active');
+ return `${ownerPreview ? '<p class="notice">Your page is switched off: only you can see it, and search engines are told not to index it. Switch it on in the <a href="/dashboard">dashboard</a>.</p>' : ''}
+<section class="creator-head">
+ ${profile.avatar_url ? `<img class="avatar" src="${esc(profile.avatar_url)}" alt="" width="96" height="96">` : ''}
+ <div><h1>${esc(profile.display_name)}</h1>${profile.headline ? `<p class="headline">${esc(profile.headline)}</p>` : ''}
+ <p class="links"><a href="https://openvibe.live/${esc(profile.handle)}">Watch on OpenVibe.Live</a> · <a href="/${esc(profile.handle)}/goals">Goals</a></p></div>
+</section>
+${active.length ? `<section><h2>Goals</h2>${active.map(goalCard).join('')}</section>` : ''}
+${tipForm({ profile, goals, viewer, csrf, idem, values, providers, error })}`;
+}
+
+function goalsPage({ profile, goals }) {
+ return `<h1>${esc(profile.display_name)} — goals</h1>
+<p><a href="/${esc(profile.handle)}">Back to ${esc(profile.display_name)}</a></p>
+${goals.length ? goals.map(goalCard).join('') : '<p>No goals yet.</p>'}
+<p class="fine">Goal totals count settled payments only.</p>`;
+}
+
+function receiptRow(i, as) {
+ const who = as === 'creator' ? esc(i.supporter_name || 'Someone') : `<a href="/${esc(i.creator_handle || '')}">${esc(i.creator_name || 'creator')}</a>`;
+ return `<tr${i.test ? ' class="test"' : ''}><td><a href="/receipts/${esc(i.id)}">${when(i.created_at)}</a></td><td>${who}</td><td>${esc(KIND_LABEL[i.kind])}${i.test ? ' <small>(simulation)</small>' : ''}</td><td class="num">${n(i.amount)}</td><td>${esc(PAY_LABEL[i.payment.state])}</td><td>${esc(DELIVERY_LABEL[i.delivery.state])}</td></tr>`;
+}
+
+function receiptsPage({ rows, next }) {
+ return `<h1>Your receipts</h1>
+${rows.length ? `<table class="list"><thead><tr><th>When</th><th>Creator</th><th>What</th><th class="num">Vibes</th><th>Payment</th><th>Delivery</th></tr></thead><tbody>${rows.map((r) => receiptRow(r, 'supporter')).join('')}</tbody></table>` : '<p>No tips yet.</p>'}
+${next ? `<p><a href="/receipts?cursor=${esc(next)}">Older</a></p>` : ''}`;
+}
+
+function receiptPage({ i, profile, as, cancelled }) {
+ const checkoutOpen = i.payment.state === 'pending' && i.checkout && i.checkout.url && as === 'supporter';
+ return `<h1>Receipt</h1>
+${cancelled && i.payment.state === 'pending' ? '<p class="notice">Checkout was cancelled. Nothing was charged; you can start again from the creator\'s page.</p>' : ''}
+<dl class="receipt">
+ <dt>Creator</dt><dd>${profile ? `<a href="/${esc(profile.handle)}">${esc(profile.display_name)}</a>` : 'unknown'}</dd>
+ <dt>From</dt><dd>${esc(i.supporter_name || 'Someone')}</dd>
+ <dt>What</dt><dd>${esc(KIND_LABEL[i.kind])}${i.test ? ' (simulation, not charged, not counted)' : ''}</dd>
+ <dt>Amount</dt><dd>${n(i.amount)} Vibes</dd>
+ ${i.message ? `<dt>Message</dt><dd>${esc(i.message)}</dd>` : ''}
+ ${i.tts ? `<dt>Read out</dt><dd>${esc(i.tts.text)} <small>(${esc(i.tts.voice)})</small></dd>` : ''}
+ ${i.media ? `<dt>Media</dt><dd>${esc(i.media.url)}</dd>` : ''}
+ <dt>Payment</dt><dd>${esc(PAY_LABEL[i.payment.state])}${i.payment.settled_at ? ` · ${when(i.payment.settled_at)}` : ''}${i.payment.failure ? ` · ${esc(i.payment.failure)}` : ''}</dd>
+ <dt>Delivery</dt><dd>${esc(DELIVERY_LABEL[i.delivery.state])}${i.delivery.delivered_at ? ` · ${when(i.delivery.delivered_at)}` : ''}</dd>
+ ${i.payment.billing_txn_id ? `<dt>Billing reference</dt><dd><code>${esc(i.payment.billing_txn_id)}</code></dd>` : ''}
+ <dt>Receipt id</dt><dd><code>${esc(i.id)}</code></dd>
+</dl>
+${checkoutOpen ? `<p><a class="btn" href="${esc(i.checkout.url)}" rel="noopener">Continue to checkout</a></p>` : ''}
+${i.payment.state === 'pending' && i.funding === 'checkout' && !i.checkout?.url ? '<p class="notice">This checkout has no payment link yet. Nothing is charged until the payment completes.</p>' : ''}
+<p class="fine">The payment state comes from OpenVibe.Billing. Delivery is tracked separately: a delivery problem never undoes a payment.</p>`;
+}
+
+function dashboard({ profile, goals, tokens, configs, totals, recent, deliveries, csrf, idem, flash, error, connected, chatAdapter }) {
+ const p = profile;
+ const f = (k) => (p[k] ? ' checked' : '');
+ const alertCfg = configs.find((c) => c.kind === 'alerts');
+ const s = alertCfg ? alertCfg.settings : {};
+ return `<h1>Creator dashboard</h1>
+${flash ? `<p class="notice">${esc(flash)}</p>` : ''}${errorBox(error)}
+<section class="grid">
+ <div class="card"><h2>Totals</h2>
+ <p class="big">${n(totals.settled_via_billing)} <small>Vibes settled through Billing</small></p>
+ <p>${n(totals.external)} Vibes-equivalent tipped on your own PowerChat · ${n(totals.interactions)} tips · ${n(totals.pending)} pending</p>
+ <p class="fine">Simulations (${n(totals.simulations)}) are never counted. Your balance and cash-outs are on OpenVibe.Live until Billing takes over.</p>
+ </div>
+ <div class="card"><h2>Your page</h2>
+ <p><a href="/${esc(p.handle)}">openvibe.tips/${esc(p.handle)}</a> — ${p.page_enabled ? 'on and indexable' : 'off (only you can see it)'}</p>
+ <form method="post" action="/dashboard/profile">${hidden({ csrf, idem: `${idem}-p` })}
+ <label><input type="checkbox" name="page_enabled" value="1"${f('page_enabled')}> Public page on (search engines may index it)</label>
+ <label><input type="checkbox" name="accepting" value="1"${f('accepting')}> Accepting tips</label>
+ <label>Headline <input type="text" name="headline" maxlength="280" value="${esc(p.headline || '')}"></label>
+ <label>Minimum tip <input type="number" name="min_amount" min="1" value="${p.min_amount}"></label>
+ <label>Minimum paid message <input type="number" name="paid_message_min" min="1" value="${p.paid_message_min}"></label>
+ <label><input type="checkbox" name="tts_enabled" value="1"${f('tts_enabled')}> Text-to-speech</label>
+ <label>Minimum for text-to-speech <input type="number" name="tts_min_amount" min="1" value="${p.tts_min_amount}"></label>
+ <label>Text-to-speech length <input type="number" name="tts_max_chars" min="20" max="1200" value="${p.tts_max_chars}"></label>
+ <label>Default voice <select name="tts_voice">${VOICES.map((v) => `<option${v === p.tts_voice ? ' selected' : ''}>${v}</option>`).join('')}</select></label>
+ <label><input type="checkbox" name="media_requests_enabled" value="1"${f('media_requests_enabled')}> Media requests</label>
+ <label>Minimum media request <input type="number" name="media_request_min" min="1" value="${p.media_request_min}"></label>
+ <label>Longest media (seconds) <input type="number" name="media_max_seconds" min="10" max="10800" value="${p.media_max_seconds}"></label>
+ <input type="hidden" name="revision" value="${p.revision}">
+ <button class="btn" type="submit">Save</button>
+ </form>
+ </div>
+</section>
+<section class="card"><h2>Goals</h2>
+ ${goals.length ? goals.map((g) => `${goalCard(g)}${g.status === 'active' ? `<form class="inline" method="post" action="/dashboard/goals/${esc(g.id)}">${hidden({ csrf, idem: `${idem}-g-${g.id}`, revision: g.revision })}
+ <label>Title <input name="title" maxlength="120" value="${esc(g.title)}"></label><label>Target <input type="number" name="target_amount" min="1" value="${g.target_amount}"></label>
+ <button class="btn ghost" type="submit">Update</button></form>
+ <form class="inline" method="post" action="/dashboard/goals/${esc(g.id)}/close">${hidden({ csrf, idem: `${idem}-c-${g.id}` })}<button class="btn ghost" type="submit">Close goal</button></form>` : ''}`).join('') : '<p>No goals yet.</p>'}
+ <form method="post" action="/dashboard/goals">${hidden({ csrf, idem: `${idem}-ng` })}
+ <h3>New goal</h3>
+ <label>Title <input name="title" maxlength="120" required></label>
+ <label>Target (Vibes) <input type="number" name="target_amount" min="1" required></label>
+ <label>Description <input name="description" maxlength="1000"></label>
+ <button class="btn" type="submit">Add goal</button>
+ </form>
+</section>
+<section class="card"><h2>Overlays</h2>
+ <p>Add an overlay to OBS as a Browser Source. Each overlay link carries its own token: it shows alerts and goals, nothing else, and you can revoke it at any time. ${connected ? `<b>${connected}</b> overlay${connected === 1 ? '' : 's'} connected now.` : 'No overlay is connected right now.'}</p>
+ ${tokens.length ? `<table class="list"><thead><tr><th>Label</th><th>Shows</th><th>Created</th><th>Last used</th><th></th></tr></thead><tbody>${tokens.map((t) => `<tr${t.active ? '' : ' class="revoked"'}><td>${esc(t.label || '—')}</td><td>${esc(t.scopes.join(', '))}</td><td>${when(t.created_at)}</td><td>${when(t.last_used_at) || 'never'}</td><td>${t.active ? `<form method="post" action="/dashboard/overlay-tokens/${esc(t.id)}/revoke">${hidden({ csrf, idem: `${idem}-r-${t.id}` })}<button class="btn ghost" type="submit">Revoke</button></form>` : 'revoked'}</td></tr>`).join('')}</tbody></table>` : ''}
+ <form method="post" action="/dashboard/overlay-tokens">${hidden({ csrf, idem: `${idem}-t` })}
+ <label>Label <input name="label" maxlength="80" placeholder="OBS main scene"></label>
+ <label><input type="checkbox" name="scope_alerts" value="1" checked> Alerts</label>
+ <label><input type="checkbox" name="scope_goals" value="1" checked> Goals</label>
+ <button class="btn" type="submit">Create overlay link</button>
+ </form>
+ ${alertCfg ? `<form method="post" action="/dashboard/overlay-configs/${esc(alertCfg.id)}">${hidden({ csrf, idem: `${idem}-a`, revision: alertCfg.revision })}
+ <h3>Alert settings</h3>
+ <label>Show alerts from <input type="number" name="min_amount" min="1" value="${s.min_amount}"> Vibes</label>
+ <label>Alert time (ms) <input type="number" name="duration_ms" min="1000" max="60000" value="${s.duration_ms}"></label>
+ <label><input type="checkbox" name="show_message" value="1"${s.show_message ? ' checked' : ''}> Show the message</label>
+ <label><input type="checkbox" name="speak_message" value="1"${s.speak_message ? ' checked' : ''}> Read text-to-speech requests in the overlay (browser voice)</label>
+ <label>Sound (https link) <input type="url" name="sound_url" value="${esc(s.sound_url || '')}"></label>
+ <label>Image (https link) <input type="url" name="image_url" value="${esc(s.image_url || '')}"></label>
+ <input type="hidden" name="show_amount" value="1">
+ <button class="btn ghost" type="submit">Save alert settings</button>
+ </form>` : ''}
+</section>
+<section class="card"><h2>Test your setup</h2>
+ <p>Runs the whole path — overlay alert, goal widget, delivery — marked as a simulation. Nothing is charged and nothing is counted. ${chatAdapter === 'none' ? '' : 'Chat delivery of simulations stays inside Tips.'}</p>
+ <form method="post" action="/dashboard/simulate">${hidden({ csrf, idem: `${idem}-s` })}
+ <label>Kind <select name="kind"><option value="tip">Tip</option><option value="paid_message">Paid message</option>${p.tts_enabled ? '<option value="tts">Text-to-speech</option>' : ''}${p.media_requests_enabled ? '<option value="media_request">Media request</option>' : ''}</select></label>
+ <label>Amount <input type="number" name="amount" min="1" value="${Math.max(p.min_amount, p.paid_message_min, 100)}"></label>
+ <label>Name <input name="supporter_name" value="Test supporter" maxlength="80"></label>
+ <label>Message <input name="message" value="This is a test alert" maxlength="300"></label>
+ <label class="when-media">Media link (for a media request) <input type="url" name="media_url" placeholder="https://www.youtube.com/watch?v=…"></label>
+ <button class="btn" type="submit">Send a test</button>
+ </form>
+</section>
+<section class="card"><h2>Recent</h2>
+ ${recent.length ? `<table class="list"><thead><tr><th>When</th><th>From</th><th>What</th><th class="num">Vibes</th><th>Payment</th><th>Delivery</th></tr></thead><tbody>${recent.map((r) => receiptRow(r, 'creator')).join('')}</tbody></table>` : '<p>Nothing yet.</p>'}
+ ${deliveries.length ? `<details><summary>Overlay deliveries</summary><table class="list"><thead><tr><th>#</th><th>Kind</th><th>Status</th><th>Sent</th><th>Created</th></tr></thead><tbody>${deliveries.map((d) => `<tr${d.test ? ' class="test"' : ''}><td>${d.seq}</td><td>${esc(d.kind)}${d.test ? ' (test)' : ''}</td><td>${esc(d.status)}</td><td>${d.sends}×</td><td>${when(d.created_at)}</td></tr>`).join('')}</tbody></table></details>` : ''}
+</section>`;
+}
+
+function tokenCreated({ out }) {
+ return `<h1>Overlay link created</h1>
+<p class="notice">Copy it now: this is the only time it is shown. Anyone with the link can see your alerts and goals (nothing else), so treat it like a password and revoke it if it leaks.</p>
+<p><label>Overlay URL (OBS Browser Source, 800 × 600, transparent)<input class="copy" type="text" readonly value="${esc(out.overlay_url)}" onclick="this.select()"></label></p>
+<p><a class="btn" href="/dashboard">Back to the dashboard</a></p>`;
+}
+
+function errorPage({ status, title, message }) {
+ return `<section class="card"><h1>${esc(title)}</h1><p>${esc(message)}</p><p><a href="/">OpenVibe.Tips home</a></p></section><!-- ${status} -->`;
+}
+
+module.exports = { home, creatorPage, goalsPage, receiptsPage, receiptPage, dashboard, tokenCreated, errorPage, tipForm };
new file mode 100644
new file mode 100644
@@ -0,0 +1,286 @@
@@ -0,0 +1,286 @@
+'use strict';
+
+/**
+ * Pages and overlay endpoints.
+ *
+ * GET / home
+ * GET /dashboard creator dashboard (sign-in; opens the profile on first visit)
+ * POST /dashboard/… profile, goals, overlay tokens/configs, simulation (anti-forgery token)
+ * GET /receipts, /receipts/:id the signed-in supporter's receipts (a creator may open theirs too)
+ * GET /:handle creator page: goals + tip form (indexable only when the page is on)
+ * GET /:handle/goals goals
+ * POST /:handle/tip the no-JS tip form → Billing (credit or checkout)
+ * GET /overlay/:token the overlay page for OBS (token = scoped, revocable, never a cookie)
+ * GET /overlay/:token/events SSE: alerts and goal updates (Last-Event-ID resumes/replays)
+ * GET /overlay/:token/state JSON: active goals and recent alerts (read-only, for other overlay clients)
+ * GET /robots.txt, /sitemap.xml
+ */
+const crypto = require('crypto');
+const express = require('express');
+const { TipsError, json: parseJson } = require('../util');
+const { viewerMiddleware } = require('./session');
+const pages = require('./pages');
+const { esc, asset } = require('./layout');
+
+const FLASH = new Set(['Saved', 'Page settings saved', 'Goal added', 'Goal updated', 'Goal closed', 'Overlay link revoked', 'Alert settings saved', 'Test sent — check your overlay']);
+
+function createWebRoutes({ domain, config, layout, userAuth }) {
+ const r = express.Router();
+ const { profiles, goals, interactions, overlays, db } = domain;
+ const withViewer = viewerMiddleware(userAuth);
+ const form = express.urlencoded({ extended: false, limit: '32kb' });
+ const secret = config.formSecret || crypto.randomBytes(32).toString('hex');
+
+ const html = (res, body, status = 200, extraHeaders = {}) => res.status(status).set({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache', ...extraHeaders }).send(body);
+ const pageFor = (req, o) => layout.page({ viewer: req.viewer, ...o });
+ const notFound = (req, res) => html(res, pageFor(req, { title: 'Not found', robots: 'noindex', body: pages.errorPage({ status: 404, title: 'Nothing here', message: 'That page does not exist, or its creator has not switched it on.' }) }), 404);
+
+ // Anti-forgery for signed-in forms: HMAC(subject, day), valid today and yesterday.
+ const day = () => Math.floor(domain.now() / 86_400_000);
+ const csrfFor = (subject, d = day()) => crypto.createHmac('sha256', secret).update(`${subject}|${d}`).digest('base64url');
+ function csrfOk(req) {
+ if (!req.viewer) return false;
+ const origin = req.get('origin');
+ if (origin && origin !== new URL(config.baseUrl).origin) return false;
+ const given = String((req.body && req.body.csrf) || '');
+ return [day(), day() - 1].some((d) => {
+ const want = csrfFor(req.viewer.subject, d);
+ return given.length === want.length && crypto.timingSafeEqual(Buffer.from(given), Buffer.from(want));
+ });
+ }
+ const idem = () => `form-${crypto.randomBytes(12).toString('base64url')}`;
+
+ // ── Home, robots, sitemap ────────────────────────────────
+ r.get('/', withViewer, (req, res) => {
+ const creators = db.prepare(`SELECT handle, display_name, avatar_url FROM creator_tip_profiles WHERE page_enabled = 1 ORDER BY updated_at DESC LIMIT 24`).all();
+ html(res, pageFor(req, { active: 'home', canonicalPath: '/', body: pages.home({ creators }) }));
+ });
+ r.get('/robots.txt', (req, res) => res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(
+ `User-agent: *\nAllow: /\nDisallow: /dashboard\nDisallow: /receipts\nDisallow: /overlay/\nDisallow: /auth/\nDisallow: /api/\nDisallow: /internal/\nSitemap: ${config.baseUrl}/sitemap.xml\n`));
+ r.get('/sitemap.xml', (req, res) => {
+ const rows = db.prepare('SELECT handle, updated_at FROM creator_tip_profiles WHERE page_enabled = 1 ORDER BY handle').all();
+ const urls = [`<url><loc>${esc(config.baseUrl)}/</loc></url>`, ...rows.map((p) => `<url><loc>${esc(`${config.baseUrl}/${p.handle}`)}</loc><lastmod>${esc(p.updated_at.slice(0, 10))}</lastmod></url>`)];
+ res.type('application/xml').set('Cache-Control', 'public, max-age=900').send(`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls.join('')}</urlset>\n`);
+ });
+
+ // ── Receipts ─────────────────────────────────────────────
+ const decorate = (i) => {
+ const p = profiles.bySubject(i.creator_subject);
+ return { ...interactions.present(i, { viewer: 'supporter' }), creator_handle: p && p.handle, creator_name: p && p.display_name };
+ };
+ r.get('/receipts', withViewer, (req, res) => {
+ if (!req.viewer) return res.redirect(`/auth/login?next=${encodeURIComponent('/receipts')}`);
+ const out = interactions.list({ supporter: req.viewer.subject, cursor: req.query.cursor, limit: 50 });
+ html(res, pageFor(req, { title: 'Receipts', active: 'receipts', robots: 'noindex,nofollow', canonicalPath: '/receipts', body: pages.receiptsPage({ rows: out.rows.map(decorate), next: out.next_cursor }) }));
+ });
+ r.get('/receipts/:id', withViewer, (req, res) => {
+ if (!req.viewer) return res.redirect(`/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
+ const i = interactions.get(req.params.id);
+ const as = i && (i.supporter_subject === req.viewer.subject ? 'supporter' : i.creator_subject === req.viewer.subject ? 'creator' : null);
+ if (!i || !as) return notFound(req, res);
+ const view = interactions.present(i, { viewer: as === 'creator' ? 'owner' : 'supporter' });
+ html(res, pageFor(req, { title: 'Receipt', active: 'receipts', robots: 'noindex,nofollow', canonicalPath: `/receipts/${i.id}`, body: pages.receiptPage({ i: view, profile: profiles.bySubject(i.creator_subject), as, cancelled: req.query.cancelled === '1' }) }));
+ });
+
+ // ── Dashboard ────────────────────────────────────────────
+ function ensureProfile(req) {
+ const v = req.viewer;
+ const claims = req.viewerClaims || {};
+ return profiles.ensure(v.subject, { username: v.username, displayName: v.name, avatarUrl: claims.avatar_url });
+ }
+ function ensureAlertsConfig(subject) {
+ if (!overlays.listConfigs(subject).some((c) => c.kind === 'alerts')) overlays.createConfig(subject, { kind: 'alerts', name: 'Alerts' });
+ }
+ function renderDashboard(req, res, { flash, error, status = 200 } = {}) {
+ const p = ensureProfile(req);
+ ensureAlertsConfig(p.creator_subject);
+ const recent = interactions.list({ creator: p.creator_subject, limit: 20 }).rows.map((i) => interactions.present(i, { viewer: 'owner' }));
+ html(res, pageFor(req, {
+ title: 'Dashboard', active: 'dashboard', robots: 'noindex,nofollow', canonicalPath: '/dashboard',
+ body: pages.dashboard({
+ profile: p, goals: goals.list(p.creator_subject).map((g) => goals.present(g)), tokens: overlays.listTokens(p.creator_subject).map(overlays.presentToken),
+ configs: overlays.listConfigs(p.creator_subject).map(overlays.presentConfig), totals: interactions.totals(p.creator_subject), recent,
+ deliveries: overlays.recentDeliveries(p.creator_subject, 20), csrf: csrfFor(req.viewer.subject), idem: idem(), flash, error,
+ connected: overlays.connected(p.creator_subject), chatAdapter: config.chat.adapter,
+ }),
+ }), status);
+ }
+ r.get('/dashboard', withViewer, (req, res) => {
+ if (!req.viewer) return res.redirect(`/auth/login?next=${encodeURIComponent('/dashboard')}`);
+ // Only our own confirmations are shown: a crafted ?done= link cannot put words on the page.
+ renderDashboard(req, res, { flash: FLASH.has(String(req.query.done || '')) ? String(req.query.done) : null });
+ });
+
+ /** A dashboard form action: sign-in + anti-forgery, errors re-render the dashboard. */
+ function action(path, fn) {
+ r.post(path, withViewer, form, (req, res) => {
+ if (!req.viewer) return res.redirect(`/auth/login?next=${encodeURIComponent('/dashboard')}`);
+ if (!csrfOk(req)) return renderDashboard(req, res, { error: 'That form expired. Please try again.', status: 403 });
+ try {
+ const p = ensureProfile(req);
+ const out = fn(req, p);
+ if (out && out.render) return out.render(res);
+ return res.redirect(303, `/dashboard?done=${encodeURIComponent((out && out.done) || 'Saved')}`);
+ } catch (e) {
+ if (e instanceof TipsError) return renderDashboard(req, res, { error: e.detail || e.message, status: e.status >= 500 ? 500 : 400 });
+ throw e;
+ }
+ });
+ }
+ const bool = (v) => v === '1' || v === 'on';
+ action('/dashboard/profile', (req, p) => {
+ const b = req.body;
+ profiles.update(p.creator_subject, {
+ page_enabled: bool(b.page_enabled), accepting: bool(b.accepting), tts_enabled: bool(b.tts_enabled), media_requests_enabled: bool(b.media_requests_enabled),
+ headline: b.headline, min_amount: b.min_amount, paid_message_min: b.paid_message_min, tts_min_amount: b.tts_min_amount, tts_max_chars: b.tts_max_chars,
+ tts_voice: b.tts_voice, media_request_min: b.media_request_min, media_max_seconds: b.media_max_seconds,
+ }, { expectedRevision: b.revision });
+ return { done: 'Page settings saved' };
+ });
+ action('/dashboard/goals', (req, p) => { goals.create(p.creator_subject, req.body); return { done: 'Goal added' }; });
+ const ownGoal = (req, p) => {
+ const g = goals.get(req.params.id);
+ if (!g || g.creator_subject !== p.creator_subject) throw new TipsError(404, 'tips.goal_not_found', 'no such goal');
+ return g;
+ };
+ action('/dashboard/goals/:id', (req, p) => { goals.update(ownGoal(req, p), { title: req.body.title, target_amount: req.body.target_amount, revision: req.body.revision }); return { done: 'Goal updated' }; });
+ action('/dashboard/goals/:id/close', (req, p) => { goals.close(ownGoal(req, p)); return { done: 'Goal closed' }; });
+ action('/dashboard/overlay-tokens', (req, p) => {
+ const scopes = [bool(req.body.scope_alerts) && 'alerts', bool(req.body.scope_goals) && 'goals'].filter(Boolean);
+ const alertCfg = overlays.listConfigs(p.creator_subject).find((c) => c.kind === 'alerts');
+ const out = overlays.createToken(p.creator_subject, { scopes, label: req.body.label, configId: alertCfg ? alertCfg.id : null, createdBy: req.viewer.subject });
+ return { render: (res) => html(res, pageFor(req, { title: 'Overlay link', active: 'dashboard', robots: 'noindex,nofollow', canonicalPath: '/dashboard', body: pages.tokenCreated({ out }) }), 201, { 'Cache-Control': 'no-store' }) };
+ });
+ action('/dashboard/overlay-tokens/:id/revoke', (req, p) => {
+ const t = overlays.getToken(req.params.id);
+ if (!t || t.creator_subject !== p.creator_subject) throw new TipsError(404, 'tips.overlay_token_not_found', 'no such overlay token');
+ overlays.revokeToken(t);
+ return { done: 'Overlay link revoked' };
+ });
+ action('/dashboard/overlay-configs/:id', (req, p) => {
+ const c = overlays.getConfig(req.params.id);
+ if (!c || c.creator_subject !== p.creator_subject) throw new TipsError(404, 'tips.overlay_config_not_found', 'no such overlay config');
+ const b = req.body;
+ overlays.updateConfig(c, { ...b, show_message: bool(b.show_message), speak_message: bool(b.speak_message), show_amount: bool(b.show_amount) });
+ return { done: 'Alert settings saved' };
+ });
+ action('/dashboard/simulate', (req, p) => {
+ const b = req.body;
+ interactions.simulate(p, {
+ kind: b.kind, amount: b.amount, message: b.message, supporter_name: b.supporter_name,
+ tts: b.kind === 'tts' ? { text: b.message } : undefined, media: b.kind === 'media_request' ? { url: b.media_url } : undefined,
+ }, { by: req.viewer.subject });
+ return { done: 'Test sent — check your overlay' };
+ });
+
+ // ── Overlays (token in the path; no cookies involved) ────
+ const overlayHeaders = { 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer', 'X-Robots-Tag': 'noindex, nofollow' };
+ function tokenOr404(req, res) {
+ const t = overlays.authenticate(req.params.token);
+ if (!t) { res.status(404).set(overlayHeaders).type('text/plain').send('This overlay link is not valid (it may have been revoked).'); return null; }
+ return t;
+ }
+ r.get('/overlay/:token', (req, res) => {
+ const t = tokenOr404(req, res);
+ if (!t) return;
+ res.status(200).set({ ...overlayHeaders, 'Content-Type': 'text/html; charset=utf-8' }).send(`<!DOCTYPE html>
+<html lang="en"><head><meta charset="utf-8"><meta name="robots" content="noindex,nofollow"><meta name="referrer" content="no-referrer">
+<meta name="viewport" content="width=device-width, initial-scale=1"><title>OpenVibe.Tips overlay</title>
+<link rel="stylesheet" href="${asset('css/overlay.css')}"></head>
+<body class="overlay"><div id="goals" aria-live="polite"></div><div id="alert" aria-live="assertive"></div>
+<noscript><p class="nojs">This overlay needs JavaScript (OBS Browser Sources have it on).</p></noscript>
+<script src="${asset('js/overlay.js')}" defer></script></body></html>`);
+ });
+ r.get('/overlay/:token/events', (req, res) => {
+ const t = tokenOr404(req, res);
+ if (!t) return;
+ res.status(200).set({ ...overlayHeaders, 'Content-Type': 'text/event-stream; charset=utf-8', Connection: 'keep-alive', 'X-Accel-Buffering': 'no' });
+ res.flushHeaders();
+ res.write('retry: 3000\n\n');
+ const config = t.config_id ? overlays.getConfig(t.config_id) : null;
+ const lastEventId = req.get('last-event-id') != null ? req.get('last-event-id') : req.query.last_event_id;
+ overlays.attach(t, res, { lastEventId: lastEventId != null && lastEventId !== '' ? lastEventId : undefined, config });
+ });
+ r.get('/overlay/:token/state', (req, res) => {
+ const t = tokenOr404(req, res);
+ if (!t) return;
+ const out = { creator: { type: 'user', id: t.creator_subject }, scopes: t.scopes };
+ if (t.scopes.includes('goals')) out.goals = goals.list(t.creator_subject, { status: 'active' }).map((g) => goals.present(g));
+ if (t.scopes.includes('alerts')) {
+ out.alerts = db.prepare("SELECT seq, payload, test, created_at FROM overlay_deliveries WHERE creator_subject = ? AND kind = 'alert' ORDER BY seq DESC LIMIT 20")
+ .all(t.creator_subject).map((d) => ({ seq: d.seq, ...parseJson(d.payload, {}), test: !!d.test, created_at: d.created_at }));
+ }
+ res.set(overlayHeaders).json(out);
+ });
+
+ // ── Creator pages (last: /:handle catches everything else) ──
+ const providers = config.billing.providers;
+ function creatorView(req, res, { values, error, status = 200 } = {}) {
+ const p = profiles.byHandle(req.params.handle);
+ const owner = !!(p && req.viewer && req.viewer.subject === p.creator_subject);
+ if (!p || (!p.page_enabled && !owner)) return notFound(req, res);
+ if (p.handle !== String(req.params.handle)) return res.redirect(301, `/${p.handle}`); // @name, Name → the canonical path
+ const list = goals.list(p.creator_subject).map((g) => goals.present(g));
+ const csrf = req.viewer ? csrfFor(req.viewer.subject) : '';
+ const jsonLd = p.page_enabled ? [{ '@context': 'https://schema.org', '@type': 'ProfilePage', name: `${p.display_name} on ${'OpenVibe.Tips'}`, url: `${config.baseUrl}/${p.handle}`, mainEntity: { '@type': 'Person', name: p.display_name, alternateName: p.handle, image: p.avatar_url || undefined } }] : [];
+ return html(res, pageFor(req, {
+ title: `Support ${p.display_name}`, active: 'creator', canonicalPath: `/${p.handle}`,
+ description: p.headline || `Tip ${p.display_name} on OpenVibe.Tips: tips, goals, paid messages${p.tts_enabled ? ', text-to-speech' : ''}${p.media_requests_enabled ? ', media requests' : ''}.`,
+ robots: p.page_enabled ? 'index,follow' : 'noindex,nofollow', ogImage: p.avatar_url || undefined, jsonLd,
+ body: pages.creatorPage({ profile: p, goals: list, viewer: req.viewer, csrf, idem: idem(), values, providers, error, ownerPreview: !p.page_enabled }),
+ }), status, p.page_enabled ? {} : { 'X-Robots-Tag': 'noindex, nofollow' });
+ }
+ r.get('/:handle', withViewer, (req, res, next) => (profiles.normalizeHandle(req.params.handle) ? creatorView(req, res) : next()));
+ r.get('/:handle/goals', withViewer, (req, res, next) => {
+ const p = profiles.byHandle(req.params.handle);
+ if (!p) return next();
+ const owner = req.viewer && req.viewer.subject === p.creator_subject;
+ if (!p.page_enabled && !owner) return notFound(req, res);
+ html(res, pageFor(req, {
+ title: `${p.display_name} — goals`, canonicalPath: `/${p.handle}/goals`, robots: p.page_enabled ? 'index,follow' : 'noindex,nofollow',
+ body: pages.goalsPage({ profile: p, goals: goals.list(p.creator_subject).map((g) => goals.present(g)) }),
+ }));
+ });
+
+ r.post('/:handle/tip', withViewer, form, async (req, res, next) => {
+ const p = profiles.byHandle(req.params.handle);
+ if (!p) return next();
+ if (!req.viewer) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/${p.handle}`)}`);
+ const b = req.body || {};
+ const values = { kind: b.kind, amount: b.amount, message: b.message, tts_text: b.tts_text, tts_voice: b.tts_voice, media_url: b.media_url, goal_id: b.goal_id, pay_with: b.pay_with, supporter_name: b.supporter_name };
+ if (!csrfOk(req)) return creatorView(req, res, { values, error: 'That form expired. Please send it again.', status: 403 });
+ const owner = req.viewer.subject === p.creator_subject;
+ if (!p.page_enabled && !owner) return notFound(req, res);
+ const key = /^form-[A-Za-z0-9_-]{8,40}$/.test(String(b.idem || '')) ? `form:${req.viewer.subject}:${b.idem}` : null;
+ try {
+ const input = {
+ kind: b.kind || 'tip', amount: b.amount, message: b.message, goal_id: b.goal_id || undefined,
+ tts: b.kind === 'tts' ? { text: b.tts_text || b.message, voice: b.tts_voice } : undefined,
+ media: b.kind === 'media_request' ? { url: b.media_url } : undefined,
+ };
+ const funding = b.pay_with === 'checkout' ? 'checkout' : 'credit';
+ const { interaction, replay } = interactions.request(p, input, { supporter: req.viewer.subject, supporterName: b.supporter_name || req.viewer.name, funding, idempotencyKey: key });
+ let i = interaction;
+ if (!replay) {
+ if (funding === 'credit') {
+ const out = await interactions.transfer(i);
+ if (out.refused) {
+ const msg = out.refused === 'billing.insufficient_funds' ? 'Your Vibes balance is too low for this. Pay by checkout instead, or top up on OpenVibe.Live.' : `Billing refused this tip (${out.refused}).`;
+ return creatorView(req, res, { values, error: msg, status: 409 });
+ }
+ } else {
+ i = (await interactions.startCheckout(i, { provider: b.provider })).interaction;
+ if (i.checkout_url) return res.redirect(303, i.checkout_url);
+ }
+ }
+ return res.redirect(303, `/receipts/${i.id}`);
+ } catch (e) {
+ if (e instanceof TipsError) return creatorView(req, res, { values, error: e.detail || e.message, status: e.status >= 500 ? 502 : 400 });
+ return next(e);
+ }
+ });
+
+ return r;
+}
+
+module.exports = { createWebRoutes };
new file mode 100644
new file mode 100644
@@ -0,0 +1,213 @@
@@ -0,0 +1,213 @@
+'use strict';
+
+/**
+ * openvibe.tips — OAuth2 client session layer (identity provider: OpenVibe.Network), the same
+ * shape as OpenVibe.Community's server/auth/routes.js. Registered in the Network as client `tips`
+ * with redirect https://openvibe.tips/auth/callback.
+ *
+ * GET /auth/login → Network /oauth/authorize (?silent=1 → prompt=none; ?next= same-site path)
+ * GET /auth/callback → server-side code exchange, session cookies
+ * POST /auth/fedcm → the shared navbar's FedCM assertion → tokens (jwt-bearer grant)
+ * GET /auth/logout → clear cookies (best-effort refresh revoke), hint=guest
+ * GET /auth/me → the signed-in user (offline JWT verification)
+ * POST /auth/refresh → rotate tokens
+ *
+ * Cookies (host-only): ov_token (access JWT, Lax, JS-readable for the navbar), ov_refresh (httpOnly,
+ * Path=/auth), ov_sso_hint. These cookies only ever sign a person into the pages of this site;
+ * overlays never use them (overlays hold their own revocable tokens).
+ */
+const express = require('express');
+const crypto = require('crypto');
+const { userPrincipal } = require('../api/auth');
+
+const ACCESS_COOKIE = 'ov_token';
+const REFRESH_COOKIE = 'ov_refresh';
+const HINT_COOKIE = 'ov_sso_hint';
+const STATE_COOKIE = 'ov_oauth_state';
+const NEXT_COOKIE = 'ov_oauth_next';
+const SILENT_COOKIE = 'ov_oauth_silent';
+
+function claimsToUser(claims) {
+ if (!claims) return null;
+ const { iat, exp, aud, iss, nbf, jti, ...user } = claims; // eslint-disable-line no-unused-vars
+ return user;
+}
+
+function decodeJwtPayload(token) {
+ const parts = String(token || '').split('.');
+ if (parts.length !== 3) return null;
+ try { const c = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); return c && typeof c === 'object' ? c : null; } catch { return null; }
+}
+
+function sanitizeNext(next, config) {
+ if (!next || typeof next !== 'string') return '/';
+ if (/^\/(?!\/|\\)/.test(next)) return next;
+ try {
+ const u = new URL(next);
+ if (u.protocol !== 'https:') return '/';
+ const allowed = [config.baseUrl, config.network.url].map((b) => { try { return new URL(b).hostname; } catch { return null; } }).filter(Boolean);
+ if (allowed.includes(u.hostname)) return u.toString();
+ } catch { /* fall through */ }
+ return '/';
+}
+
+function withParam(target, key, value) {
+ const hashAt = target.indexOf('#');
+ const hash = hashAt >= 0 ? target.slice(hashAt) : '';
+ const base = hashAt >= 0 ? target.slice(0, hashAt) : target;
+ return `${base}${base.includes('?') ? '&' : '?'}${encodeURIComponent(key)}=${encodeURIComponent(value)}${hash}`;
+}
+
+/** Page middleware: req.viewer = the signed-in person (principal shape) or null. Never blocks. */
+function viewerMiddleware(userAuth) {
+ return (req, _res, next) => {
+ const token = req.cookies && req.cookies[ACCESS_COOKIE];
+ const claims = token ? userAuth.verify(token) : null;
+ req.viewer = claims ? userPrincipal(claims) : null;
+ req.viewerClaims = claims ? claimsToUser(claims) : null;
+ next();
+ };
+}
+
+function createSessionRoutes(config, userAuth, { fetchImpl = globalThis.fetch } = {}) {
+ const router = express.Router();
+ const secure = config.cookies.secure;
+ const accessOpts = () => ({ sameSite: 'lax', secure, httpOnly: false, path: '/', maxAge: 24 * 3600 * 1000 });
+ const refreshOpts = () => ({ sameSite: 'lax', secure, httpOnly: true, path: '/auth', maxAge: 30 * 24 * 3600 * 1000 });
+ const flowOpts = () => ({ sameSite: 'lax', secure, httpOnly: true, path: '/auth', maxAge: 10 * 60 * 1000 });
+ const hintOpts = () => ({ sameSite: 'lax', secure, httpOnly: false, path: '/', maxAge: 365 * 24 * 3600 * 1000 });
+
+ function setSession(res, access, refresh) {
+ res.cookie(ACCESS_COOKIE, access, accessOpts());
+ if (refresh) res.cookie(REFRESH_COOKIE, refresh, refreshOpts());
+ res.cookie(HINT_COOKIE, 'account', hintOpts());
+ }
+ function clearSession(res) {
+ res.clearCookie(ACCESS_COOKIE, { ...accessOpts(), maxAge: undefined });
+ res.clearCookie(REFRESH_COOKIE, { ...refreshOpts(), maxAge: undefined });
+ }
+ function clearFlow(res) { for (const c of [STATE_COOKIE, NEXT_COOKIE, SILENT_COOKIE]) res.clearCookie(c, { path: '/auth' }); }
+
+ async function tokenGrant(body) {
+ let lastErr = null;
+ for (const base of [config.network.internalUrl, config.network.url]) {
+ if (!base) continue;
+ try {
+ const res = await fetchImpl(`${base}/oauth/token`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, ...body }),
+ signal: AbortSignal.timeout(10_000),
+ });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ const err = new Error(data.error_description || data.error || `token grant failed (${res.status})`);
+ err.status = res.status; err.error = data.error || 'invalid_grant';
+ throw err;
+ }
+ return data;
+ } catch (err) {
+ lastErr = err;
+ if (err.status && err.status < 500) throw err;
+ }
+ }
+ throw lastErr || new Error('Network unreachable');
+ }
+
+ router.get('/login', (req, res) => {
+ const silent = !!req.query.silent && req.query.silent !== '0';
+ if (silent && userAuth.verify(req.cookies && req.cookies[ACCESS_COOKIE])) { clearFlow(res); return res.redirect(sanitizeNext(req.query.next, config)); }
+ const { url, state } = userAuth.client.getAuthorizationUrl(config.oauth.scope);
+ let target = url;
+ if (silent) { const u = new URL(url); u.searchParams.set('prompt', 'none'); target = u.toString(); }
+ res.cookie(STATE_COOKIE, state, flowOpts());
+ const next = sanitizeNext(req.query.next, config);
+ if (next !== '/') res.cookie(NEXT_COOKIE, next, flowOpts()); else res.clearCookie(NEXT_COOKIE, { path: '/auth' });
+ if (silent) res.cookie(SILENT_COOKIE, '1', flowOpts()); else res.clearCookie(SILENT_COOKIE, { path: '/auth' });
+ res.redirect(target);
+ });
+
+ router.get('/callback', async (req, res) => {
+ const { code, state, error } = req.query;
+ const next = sanitizeNext(req.cookies && req.cookies[NEXT_COOKIE], config);
+ const silent = req.cookies && req.cookies[SILENT_COOKIE] === '1';
+ if (error) {
+ clearFlow(res);
+ if (silent || error === 'login_required') return res.redirect(withParam(next, 'sso', 'none'));
+ return res.redirect(withParam('/', 'auth_error', String(error)));
+ }
+ if (!code) return res.status(400).type('text/plain').send('Missing authorization code');
+ const expected = req.cookies && req.cookies[STATE_COOKIE];
+ clearFlow(res);
+ if (!expected || !state || !crypto.timingSafeEqual(Buffer.from(String(state).padEnd(64).slice(0, 64)), Buffer.from(String(expected).padEnd(64).slice(0, 64)))) {
+ return res.status(400).type('text/plain').send('Sign-in state mismatch — please try signing in again.');
+ }
+ try {
+ const data = await tokenGrant({ grant_type: 'authorization_code', redirect_uri: config.oauth.redirectUri, code });
+ setSession(res, data.access_token, data.refresh_token);
+ return res.redirect(next);
+ } catch (err) {
+ console.error('[Tips] code exchange failed:', err.message);
+ return res.status(502).type('text/plain').send('Sign-in failed — could not reach OpenVibe.Network. Please try again.');
+ }
+ });
+
+ const fedcmBody = express.json({ limit: '16kb' });
+ router.post('/fedcm', fedcmBody, async (req, res) => {
+ const { token, nonce } = req.body || {};
+ if (typeof token !== 'string' || !token || typeof nonce !== 'string' || !nonce || nonce.length > 256) {
+ return res.status(400).json({ error: 'invalid_request', error_description: 'token and nonce are required' });
+ }
+ const claims = decodeJwtPayload(token);
+ const a = Buffer.from(String((claims && claims.nonce) || '')); const b = Buffer.from(nonce);
+ if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.status(400).json({ error: 'invalid_request', error_description: 'nonce mismatch' });
+ try {
+ const data = await tokenGrant({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: token });
+ if (!data.access_token) throw Object.assign(new Error('no token'), { status: 401, error: 'invalid_grant' });
+ setSession(res, data.access_token, data.refresh_token);
+ return res.json({ ok: true, user: data.user || claimsToUser(userAuth.verify(data.access_token)) });
+ } catch (err) {
+ if (err.status && err.status < 500) return res.status(401).json({ error: err.error || 'invalid_grant' });
+ return res.status(502).json({ error: 'server_error', error_description: 'Could not reach OpenVibe.Network' });
+ }
+ });
+
+ router.get('/logout', async (req, res) => {
+ const refresh = req.cookies && req.cookies[REFRESH_COOKIE];
+ if (refresh) {
+ try {
+ await fetchImpl(`${config.network.internalUrl}/oauth/revoke`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, token: refresh }),
+ signal: AbortSignal.timeout(3000),
+ });
+ } catch { /* optional */ }
+ }
+ clearSession(res);
+ res.cookie(HINT_COOKIE, 'guest', hintOpts());
+ res.redirect(sanitizeNext(req.query.next, config));
+ });
+
+ router.get('/me', (req, res) => {
+ const token = (String(req.headers.authorization || '').startsWith('Bearer ') ? req.headers.authorization.slice(7) : null) || (req.cookies && req.cookies[ACCESS_COOKIE]);
+ const claims = userAuth.verify(token);
+ if (!claims) return res.status(401).json({ error: 'Not authenticated' });
+ res.json({ user: claimsToUser(claims), expires_at: claims.exp ? claims.exp * 1000 : null });
+ });
+
+ router.post('/refresh', async (req, res) => {
+ const refresh = req.cookies && req.cookies[REFRESH_COOKIE];
+ if (!refresh) return res.status(401).json({ error: 'No refresh token' });
+ try {
+ const data = await tokenGrant({ grant_type: 'refresh_token', refresh_token: refresh });
+ setSession(res, data.access_token, data.refresh_token);
+ return res.json({ token: data.access_token, user: claimsToUser(userAuth.verify(data.access_token)) });
+ } catch (err) {
+ if (err.status && err.status < 500) { clearSession(res); return res.status(401).json({ error: 'Refresh token rejected — please sign in again' }); }
+ return res.status(502).json({ error: 'Could not reach OpenVibe.Network' });
+ }
+ });
+
+ return router;
+}
+
+module.exports = { createSessionRoutes, viewerMiddleware, sanitizeNext, ACCESS_COOKIE };
new file mode 100644
new file mode 100644
@@ -0,0 +1,176 @@
@@ -0,0 +1,176 @@
+'use strict';
+// /api/v1: service tokens need the one capability each route checks; people act only on their own
+// things; idempotency keys; goals derive from settled interactions only.
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/app');
+
+(async () => {
+ const t = await boot();
+ const { domain, billing } = t;
+ const alex = await t.creator('alex');
+ const bob = await t.creator('bob', { settings: { page_enabled: false } });
+ const viewer = t.network.newUser('viewer');
+ billing.fund(viewer.subject, 10_000);
+
+ await check('health, ready and release.json', async () => {
+ const h = await t.call('GET', '/api/health', { token: null });
+ assert.strictEqual(h.json.service, 'tips');
+ const r = await t.call('GET', '/api/ready', { token: null });
+ assert.strictEqual(r.json.ready, true);
+ const rel = await t.call('GET', '/release.json', { token: null });
+ assert.strictEqual(rel.json.service, 'tips');
+ });
+
+ await check('a service token for another audience, or without the capability, is refused', async () => {
+ const wrongAud = t.network.signService({ aud: ['openvibe.billing'], cap: ['tips.*'] });
+ const a = await t.call('GET', '/api/v1/interactions?creator=alex', { token: wrongAud });
+ assert.strictEqual(a.status, 401);
+ const b = await t.call('GET', '/api/v1/interactions?creator=alex', { cap: ['tips.interaction.get'] });
+ assert.strictEqual(b.status, 403);
+ assert.strictEqual(b.headers.get('content-type'), 'application/problem+json');
+ const c = await t.call('GET', '/api/v1/interactions?creator=alex', { cap: ['tips.interaction.list'] });
+ assert.strictEqual(c.status, 200, c.text);
+ const garbage = await t.call('GET', '/api/v1/interactions', { token: 'not.a.token' });
+ assert.strictEqual(garbage.status, 401);
+ });
+
+ await check('profiles: public when the page is on; a switched-off page exists only for its creator and granted services', async () => {
+ assert.strictEqual((await t.call('GET', '/api/v1/profiles/alex', { token: null })).status, 200);
+ assert.strictEqual((await t.call('GET', '/api/v1/profiles/bob', { token: null })).status, 404);
+ assert.strictEqual((await t.call('GET', '/api/v1/profiles/bob', { user: viewer })).status, 404);
+ const own = await t.call('GET', '/api/v1/profiles/bob', { user: bob });
+ assert.strictEqual(own.status, 200);
+ assert.strictEqual(own.json.profile.page_enabled, false);
+ assert.ok(own.json.profile.revision >= 1);
+ assert.strictEqual((await t.call('GET', `/api/v1/profiles/${bob.subject}`, { cap: ['tips.profile.get'] })).status, 200);
+ const upd = await t.call('PATCH', '/api/v1/profiles/bob', { user: viewer, body: { accepting: false } });
+ assert.strictEqual(upd.status, 403);
+ const svc = await t.call('PATCH', `/api/v1/profiles/${bob.subject}`, { cap: ['tips.profile.update'], body: { headline: 'Streams at 8' } });
+ assert.strictEqual(svc.status, 200, svc.text);
+ assert.strictEqual(svc.json.profile.headline, 'Streams at 8');
+ const stale = await t.call('PATCH', '/api/v1/profiles/me', { user: bob, body: { headline: 'x', revision: 1 } });
+ assert.strictEqual(stale.status, 409);
+ });
+
+ await check('a service can open a profile for a creator subject (tips.profile.update)', async () => {
+ const carol = t.network.newUser('carol');
+ const r = await t.call('PATCH', `/api/v1/profiles/${carol.subject}`, { cap: ['tips.profile.update'], body: { handle: 'carol', display_name: 'Carol', page_enabled: true } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json.profile.handle, 'carol');
+ const reserved = t.network.newUser('dashboard');
+ const r2 = await t.call('PATCH', '/api/v1/profiles/me', { user: reserved, body: {} });
+ assert.notStrictEqual(r2.json.profile.handle, 'dashboard', 'reserved paths are never handles');
+ });
+
+ await check('a service tips on behalf of a supporter with tips.checkout.create; the capability is per kind', async () => {
+ const r = await t.call('POST', '/api/v1/checkout', { cap: ['tips.checkout.create'], body: { creator: 'alex', supporter: { type: 'user', id: viewer.subject }, supporter_name: 'Viewer', amount: 10 } });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual(r.json.interaction.payment.state, 'settled');
+ const pm = await t.call('POST', '/api/v1/paid-messages', { cap: ['tips.checkout.create'], body: { creator: 'alex', supporter: viewer.subject, amount: 200, message: 'hi' } });
+ assert.strictEqual(pm.status, 403, 'paid messages need tips.superchat.create');
+ const ok = await t.call('POST', '/api/v1/paid-messages', { cap: ['tips.superchat.create'], body: { creator: 'alex', supporter: viewer.subject, amount: 200, message: 'hi' } });
+ assert.strictEqual(ok.status, 201, ok.text);
+ const tts = await t.call('POST', '/api/v1/tts-requests', { cap: ['tips.tts.request'], body: { creator: 'alex', supporter: viewer.subject, amount: 200, tts: { text: 'x' } } });
+ assert.strictEqual(tts.status, 409, 'TTS is off on this page');
+ assert.strictEqual(tts.json.code, 'tips.tts_disabled');
+ });
+
+ await check('the creator\'s rules: minimums, message length, media hosts', async () => {
+ await t.call('PATCH', '/api/v1/profiles/me', { user: alex, body: { min_amount: 5, media_requests_enabled: true, media_request_min: 50 } });
+ const small = await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: 'alex', amount: 4 } });
+ assert.strictEqual(small.json.code, 'tips.amount_too_small');
+ const long = await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: 'alex', amount: 10, message: 'x'.repeat(301) } });
+ assert.strictEqual(long.json.code, 'tips.text_too_long');
+ const host = await t.call('POST', '/api/v1/media-requests', { user: viewer, body: { creator: 'alex', amount: 60, media: { url: 'https://evil.example/video' } } });
+ assert.strictEqual(host.json.code, 'tips.invalid_media');
+ const plain = await t.call('POST', '/api/v1/media-requests', { user: viewer, body: { creator: 'alex', amount: 60, media: { url: 'http://youtu.be/x' } } });
+ assert.strictEqual(plain.json.code, 'tips.invalid_media');
+ const smallCheckout = await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: 'alex', amount: 50, pay_with: 'checkout' } });
+ assert.strictEqual(smallCheckout.json.code, 'tips.amount_too_small');
+ await t.call('PATCH', '/api/v1/profiles/me', { user: alex, body: { min_amount: 1 } });
+ });
+
+ await check('idempotency: a replay returns the same interaction and moves nothing; another body with the key is refused', async () => {
+ const body = { creator: 'alex', amount: 33 };
+ const a = await t.call('POST', '/api/v1/checkout', { user: viewer, body, key: 'tip-key-000001' });
+ const before = billing.transfers().length;
+ const b = await t.call('POST', '/api/v1/checkout', { user: viewer, body, key: 'tip-key-000001' });
+ assert.strictEqual(b.headers.get('idempotent-replayed'), 'true');
+ assert.strictEqual(b.json.interaction.id, a.json.interaction.id);
+ assert.strictEqual(billing.transfers().length, before);
+ const c = await t.call('POST', '/api/v1/checkout', { user: viewer, body: { ...body, amount: 34 }, key: 'tip-key-000001' });
+ assert.strictEqual(c.status, 422);
+ assert.strictEqual(c.json.code, 'idempotency.key_reused');
+ const none = await t.call('POST', '/api/v1/checkout', { user: viewer, body, key: null });
+ assert.strictEqual(none.status, 400);
+ });
+
+ await check('receipts: supporters see their own, creators theirs, nobody else', async () => {
+ const mine = await t.call('GET', '/api/v1/interactions', { user: viewer });
+ assert.ok(mine.json.interactions.length >= 2);
+ assert.ok(mine.json.interactions.every((i) => i.supporter.id === viewer.subject));
+ assert.ok(mine.json.interactions.every((i) => i.effects === undefined), 'supporters do not see delivery internals');
+ const theirs = await t.call('GET', '/api/v1/interactions?as=creator', { user: alex });
+ assert.ok(theirs.json.interactions.every((i) => i.creator.id === alex.subject));
+ const id = mine.json.interactions[0].id;
+ assert.strictEqual((await t.call('GET', `/api/v1/interactions/${id}`, { user: bob })).status, 404);
+ assert.strictEqual((await t.call('GET', `/api/v1/interactions/${id}`, { token: null })).status, 401);
+ assert.strictEqual((await t.call('GET', `/api/v1/interactions/${id}`, { user: alex })).json.interaction.effects.length >= 1, true);
+ const page1 = await t.call('GET', '/api/v1/interactions?limit=1', { user: viewer });
+ assert.ok(page1.json.next_cursor);
+ const page2 = await t.call('GET', `/api/v1/interactions?limit=1&cursor=${page1.json.next_cursor}`, { user: viewer });
+ assert.notStrictEqual(page2.json.interactions[0].id, page1.json.interactions[0].id);
+ });
+
+ await check('goals: CRUD by the owner only; contributions come from settled interactions only', async () => {
+ const g = await t.call('POST', '/api/v1/goals', { user: alex, body: { title: 'Lights', target_amount: 100 } });
+ assert.strictEqual(g.status, 201, g.text);
+ const id = g.json.goal.id;
+ assert.strictEqual((await t.call('PATCH', `/api/v1/goals/${id}`, { user: viewer, body: { title: 'mine now' } })).status, 403);
+ assert.strictEqual((await t.call('POST', '/api/v1/goals', { cap: ['tips.goal.update'], body: { creator: 'alex', title: 'x', target_amount: 5 } })).status, 403);
+ // Pending (checkout not paid) and failed payments never count.
+ const buyer = t.network.newUser('buyer');
+ await t.call('POST', '/api/v1/checkout', { user: buyer, body: { creator: 'alex', amount: 150, pay_with: 'checkout', provider: 'stripe', goal_id: id } });
+ const broke = t.network.newUser('broke');
+ await t.call('POST', '/api/v1/checkout', { user: broke, body: { creator: 'alex', amount: 70, goal_id: id } });
+ let goal = (await t.call('GET', `/api/v1/goals/${id}`, { token: null })).json.goal;
+ assert.strictEqual(goal.current_amount, 0);
+ await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: 'alex', amount: 60, goal_id: id } });
+ await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: 'alex', amount: 50, goal_id: id } });
+ goal = (await t.call('GET', `/api/v1/goals/${id}`, { user: alex })).json.goal;
+ assert.strictEqual(goal.current_amount, 110);
+ assert.strictEqual(goal.reached, true);
+ assert.strictEqual(goal.contributions.length, 2);
+ assert.strictEqual(goal.carried_over_amount, 0);
+ const edit = await t.call('PATCH', `/api/v1/goals/${id}`, { user: alex, body: { target_amount: 500, revision: goal.revision } });
+ assert.strictEqual(edit.status, 200, edit.text);
+ assert.strictEqual(edit.json.goal.reached, false);
+ const stale = await t.call('PATCH', `/api/v1/goals/${id}`, { user: alex, body: { target_amount: 600, revision: goal.revision } });
+ assert.strictEqual(stale.status, 409);
+ const closed = await t.call('POST', `/api/v1/goals/${id}/close`, { user: alex });
+ assert.strictEqual(closed.json.goal.status, 'closed');
+ const list = await t.call('GET', '/api/v1/goals?creator=alex&status=closed', { token: null });
+ assert.ok(list.json.goals.some((x) => x.id === id));
+ assert.strictEqual((await t.call('GET', '/api/v1/goals?creator=bob', { token: null })).status, 404, 'a switched-off page shows no goals');
+ });
+
+ await check('totals: derived from settled interactions, never from a stored balance', async () => {
+ const r = await t.call('GET', '/api/v1/profiles/alex/totals', { user: alex });
+ const sum = t.db.prepare("SELECT SUM(amount - reversed_bits) AS n FROM tip_interactions WHERE creator_subject = ? AND test = 0 AND settlement IN ('billing','imported') AND payment_state IN ('settled','reversed')").get(alex.subject).n;
+ assert.strictEqual(r.json.totals.settled_via_billing, sum);
+ assert.strictEqual(r.json.totals.settled_via_billing, billing.payable.get(alex.subject), 'equals what Billing holds for the creator');
+ assert.strictEqual((await t.call('GET', '/api/v1/profiles/alex/totals', { user: viewer })).status, 403);
+ });
+
+ await check('overlay configs are the owner\'s (tips.overlay.config.* for services)', async () => {
+ const c = await t.call('POST', '/api/v1/overlay-configs', { user: alex, body: { kind: 'alerts', sound_url: 'http://insecure.example/a.mp3' } });
+ assert.strictEqual(c.status, 422);
+ const ok = await t.call('POST', '/api/v1/overlay-configs', { cap: ['tips.overlay.config.update'], body: { creator: 'alex', kind: 'goal', name: 'Goal bar' } });
+ assert.strictEqual(ok.status, 201, ok.text);
+ assert.strictEqual((await t.call('GET', '/api/v1/overlay-configs?creator=alex', { cap: ['tips.overlay.config.update'] })).status, 403);
+ assert.strictEqual((await t.call('GET', '/api/v1/overlay-configs?creator=alex', { cap: ['tips.overlay.config.get'] })).status, 200);
+ });
+
+ await t.close();
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,81 @@
@@ -0,0 +1,81 @@
+'use strict';
+// The live-chat delivery adapter: Tips' service token for Live, one delivery id per effect, retries
+// for outages, an immediate stop for refusals — and never a change to the payment.
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/app');
+
+(async () => {
+ const t = await boot({ live: true });
+ const { domain, billing, live } = t;
+ await t.creator('alex', { settings: { tts_enabled: true } });
+ const viewer = t.network.newUser('viewer');
+ billing.fund(viewer.subject, 5000);
+
+ await check('a settled tip is posted to Live with a service token (openvibe.live, live.tips_delivery.write)', async () => {
+ const r = await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: 'alex', amount: 120, message: 'gg', target: { service: 'live', type: 'stream', id: '42' } } });
+ await domain.effects.drain();
+ const d = live.deliveries.find((x) => x.job.interaction.id === r.json.interaction.id);
+ assert.ok(d, 'delivered to Live');
+ assert.strictEqual(d.key, `${r.json.interaction.id}:chat_line`);
+ assert.strictEqual(d.job.effect, 'chat_line');
+ assert.strictEqual(d.job.text, 'Viewer tipped 120 Vibes: gg');
+ assert.deepStrictEqual(d.job.target, { service: 'live', type: 'stream', id: '42' });
+ assert.strictEqual(d.job.creator.handle, 'alex');
+ assert.strictEqual(d.job.test, false);
+ const grant = t.network.grants.find((g) => g.audience === 'openvibe.live');
+ assert.strictEqual(grant.scope, 'live.tips_delivery.write');
+ const e = t.db.prepare("SELECT * FROM interaction_effects WHERE interaction_id = ? AND effect = 'chat_line'").get(r.json.interaction.id);
+ assert.deepStrictEqual(JSON.parse(e.result).ref, { chat_message_id: 1 });
+ });
+
+ await check('TTS goes out as its own effect with the text and voice', async () => {
+ const r = await t.call('POST', '/api/v1/tts-requests', { user: viewer, body: { creator: 'alex', amount: 150, tts: { text: 'hello https://x.example there' } } });
+ assert.strictEqual(r.status, 201, r.text);
+ await domain.effects.drain();
+ const d = live.deliveries.find((x) => x.job.interaction.id === r.json.interaction.id && x.job.effect === 'tts');
+ assert.deepStrictEqual(d.job.tts, { text: 'hello link there', voice: 'gary' });
+ });
+
+ await check('Live down (503): retried with backoff; the payment stays settled throughout', async () => {
+ live.state.fail = 503;
+ const r = await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: 'alex', amount: 7 } });
+ const id = r.json.interaction.id;
+ await domain.effects.drain();
+ let e = t.db.prepare("SELECT * FROM interaction_effects WHERE interaction_id = ? AND effect = 'chat_line'").get(id);
+ assert.strictEqual(e.state, 'queued');
+ assert.match(e.last_error, /Live 503/);
+ assert.strictEqual(domain.interactions.get(id).payment_state, 'settled');
+ live.state.fail = null;
+ t.clock.offset += 5000;
+ await domain.effects.drain();
+ t.clock.offset = 0;
+ e = t.db.prepare("SELECT * FROM interaction_effects WHERE interaction_id = ? AND effect = 'chat_line'").get(id);
+ assert.strictEqual(e.state, 'delivered');
+ assert.strictEqual(domain.interactions.get(id).delivery_state, 'delivered');
+ });
+
+ await check('Live refuses (422): the effect fails at once, tips.interaction.failed, payment untouched', async () => {
+ live.state.fail = 422;
+ const r = await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: 'alex', amount: 8 } });
+ const id = r.json.interaction.id;
+ await domain.effects.drain();
+ live.state.fail = null;
+ const i = domain.interactions.get(id);
+ assert.strictEqual(i.delivery_state, 'failed');
+ assert.strictEqual(i.payment_state, 'settled');
+ assert.strictEqual(billing.payable.get(t.domain.profiles.byHandle('alex').creator_subject) >= 8, true);
+ assert.strictEqual(t.outboxRows('tips.interaction.failed').filter((ev) => ev.subject.id === id).length, 1);
+ });
+
+ await check('simulations never reach Live: their chat effects stay in the test adapter', async () => {
+ const alex = t.domain.profiles.byHandle('alex');
+ const before = live.deliveries.length;
+ const sim = domain.interactions.simulate(alex, { kind: 'tip', amount: 100 }, { by: alex.creator_subject });
+ await domain.effects.drain();
+ assert.strictEqual(live.deliveries.length, before);
+ assert.ok(t.adapters.test.jobs.some((j) => j.interaction.id === sim.id && j.test));
+ });
+
+ await t.close();
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,156 @@
@@ -0,0 +1,156 @@
+'use strict';
+/**
+ * Boots Tips against the stubs on a random port with a temp database. Jobs are off: tests drive the
+ * effects worker and transfer retries explicitly (domain.effects.drain(), processDueTransfers()).
+ *
+ * t.call(method, path, { body, user, cap, sub, key }) user: a stub user → Bearer user JWT;
+ * otherwise a service token with `cap`
+ * t.deliver(envelope) a signed OpenVibe.Events delivery to /internal/events
+ * t.sse(path, { lastEventId }) an SSE reader
+ */
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const crypto = require('crypto');
+const { signDelivery } = require('openvibe-sdk/events');
+const { startNetwork, startBilling, startEvents, startLive } = require('./stubs');
+
+const EVENTS_SECRET = 'e'.repeat(48);
+
+async function boot(opts = {}) {
+ const network = await startNetwork();
+ const billing = await startBilling(network);
+ const events = await startEvents();
+ const live = opts.live ? await startLive(network) : null;
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tips-test-'));
+ const env = {
+ NODE_ENV: 'test',
+ TIPS_DB_PATH: path.join(dir, 'tips.db'),
+ BASE_URL: 'http://tips.test',
+ OV_NETWORK_URL: network.url,
+ OV_NETWORK_INTERNAL_URL: network.url,
+ OV_NETWORK_ISSUER: network.url,
+ OV_OAUTH_CLIENT_ID: 'tips',
+ OV_OAUTH_CLIENT_SECRET: 'shh',
+ TIPS_FORM_SECRET: 'form-secret-for-tests',
+ BILLING_URL: billing.url,
+ TIPS_CHECKOUT_PROVIDERS: 'powerchat,stripe',
+ TIPS_EVENTS_SECRET: EVENTS_SECRET,
+ EVENTS_URL: events.url,
+ EVENTS_RELAY_INTERVAL_MS: '50',
+ TIPS_JOBS: 'off',
+ TIPS_CHAT_ADAPTER: live ? 'live-chat' : 'test',
+ LIVE_INTERNAL_URL: live ? live.url : 'http://127.0.0.1:9',
+ TIPS_OVERLAY_HEARTBEAT_MS: '60000',
+ ...(opts.env || {}),
+ };
+ for (const k of Object.keys(require.cache)) if (k.includes(`${path.sep}server${path.sep}`)) delete require.cache[k];
+ const { loadConfig } = require('../../server/config');
+ const { createApp } = require('../../server/app');
+ const config = loadConfig(env);
+ const clock = { offset: 0 };
+ const logs = [];
+ const log = { log: (...a) => logs.push(a.join(' ')), warn: (...a) => logs.push(a.join(' ')), error: (...a) => logs.push(a.join(' ')) };
+ const app = createApp({ config, now: () => Date.now() + clock.offset, log });
+ await app.locals.keys.load();
+ const server = await new Promise((resolve) => { const s = http.createServer(app); s.listen(0, '127.0.0.1', () => resolve(s)); });
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const domain = app.locals.domain;
+
+ let n = 0;
+ async function call(method, p, { body, user, cap = ['tips.*'], sub = 'svc:live', key, token, headers = {} } = {}) {
+ const h = { ...headers };
+ if (token !== null) h.Authorization = `Bearer ${token || (user ? network.signUser(user) : network.signService({ sub, cap }))}`;
+ if (body !== undefined) h['Content-Type'] = 'application/json';
+ if ((method === 'POST' || method === 'PATCH') && key !== null) h['Idempotency-Key'] = key || `test-${process.pid}-${++n}-${crypto.randomBytes(4).toString('hex')}`;
+ const res = await fetch(base + p, { method, headers: h, body: body !== undefined ? JSON.stringify(body) : undefined });
+ const text = await res.text();
+ let json = null; try { json = JSON.parse(text); } catch { /* html */ }
+ return { status: res.status, headers: res.headers, json, text };
+ }
+
+ /** POST a signed Events delivery of `event` (as OpenVibe.Events would). */
+ async function deliver(event, { secret = EVENTS_SECRET, seq = 1 } = {}) {
+ const raw = JSON.stringify({ event, seq });
+ const res = await fetch(`${base}/internal/events`, {
+ method: 'POST', body: raw,
+ headers: { 'Content-Type': 'application/json', 'X-OpenVibe-Signature': signDelivery(raw, secret), 'X-OpenVibe-Seq': String(seq) },
+ });
+ return { status: res.status, json: await res.json().catch(() => null) };
+ }
+
+ /** Minimal SSE reader: .events [{ id, event, data }], .waitFor(pred), .close(), .ended */
+ function sse(p, { lastEventId } = {}) {
+ return new Promise((resolve, reject) => {
+ const headers = { Accept: 'text/event-stream' };
+ if (lastEventId != null) headers['Last-Event-ID'] = String(lastEventId);
+ const req = http.get(base + p, { headers }, (res) => {
+ const reader = { status: res.status || res.statusCode, events: [], ended: false, waiters: [] };
+ let buf = '';
+ const check = () => { reader.waiters = reader.waiters.filter((w) => { const hit = reader.events.find(w.pred); if (hit) { w.resolve(hit); return false; } return true; }); };
+ res.setEncoding('utf8');
+ res.on('data', (chunk) => {
+ buf += chunk;
+ let i;
+ while ((i = buf.indexOf('\n\n')) >= 0) {
+ const block = buf.slice(0, i); buf = buf.slice(i + 2);
+ const e = { id: null, event: 'message', data: null };
+ for (const line of block.split('\n')) {
+ if (line.startsWith('id: ')) e.id = Number(line.slice(4));
+ else if (line.startsWith('event: ')) e.event = line.slice(7);
+ else if (line.startsWith('data: ')) e.data = JSON.parse(line.slice(6));
+ }
+ if (e.data != null) reader.events.push(e);
+ }
+ check();
+ });
+ res.on('end', () => { reader.ended = true; reader.waiters.forEach((w) => w.resolve(null)); reader.waiters = []; });
+ reader.waitFor = (pred, ms = 3000) => new Promise((ok, fail) => {
+ const hit = reader.events.find(pred);
+ if (hit) return ok(hit);
+ if (reader.ended) return ok(null);
+ const w = { pred, resolve: ok };
+ reader.waiters.push(w);
+ setTimeout(() => { reader.waiters = reader.waiters.filter((x) => x !== w); fail(new Error(`SSE wait timed out; got ${JSON.stringify(reader.events.map((x) => x.event))}`)); }, ms).unref();
+ });
+ reader.close = () => req.destroy();
+ reader.statusCode = res.statusCode;
+ resolve(reader);
+ });
+ req.on('error', reject);
+ });
+ }
+
+ /** A creator with a profile (page on unless told otherwise). */
+ async function creator(username, profile = {}) {
+ const u = network.newUser(username, profile.liveId);
+ const r = await call('PATCH', '/api/v1/profiles/me', { user: u, body: { page_enabled: true, ...profile.settings } });
+ if (r.status !== 200) throw new Error(`creator setup: ${r.status} ${r.text}`);
+ return u;
+ }
+
+ const outboxRows = (type) => domain.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all().map((r) => JSON.parse(r.envelope)).filter((e) => !type || e.event_type === type);
+
+ return {
+ app, base, call, deliver, sse, creator, domain, db: domain.db, config, clock, network, billing, events, live, logs, dir, outboxRows,
+ adapters: app.locals.adapters,
+ close: async () => {
+ domain.overlays.closeAll();
+ await new Promise((r) => server.close(r));
+ await app.locals.outbox.stop();
+ await Promise.all([network.close(), billing.close(), events.close(), live && live.close()]);
+ try { domain.db.close(); } catch { /* */ }
+ fs.rmSync(dir, { recursive: true, force: true });
+ },
+ };
+}
+
+let failures = 0;
+async function check(name, fn) {
+ try { await fn(); console.log(' ✓', name); }
+ catch (e) { failures++; console.log(' ✗', name, '\n ', (e.stack || String(e)).split('\n').slice(0, 8).join('\n ')); }
+}
+function done() { console.log(failures ? `\n${failures} failed` : '\nall passed'); process.exit(failures ? 1 : 0); }
+
+module.exports = { boot, check, done, EVENTS_SECRET };
new file mode 100644
new file mode 100644
@@ -0,0 +1,217 @@
@@ -0,0 +1,217 @@
+'use strict';
+/**
+ * Stand-ins for the services Tips talks to, each on a random port.
+ *
+ * startNetwork() JWKS, client-credentials token endpoint (scope → cap), user JWTs (signUser)
+ * startBilling() /api/v1/intents, /transfers, /transfers/:id/refund with Billing's rules that
+ * matter here (idempotency keys, funds, self-dealing, capability + audience checks),
+ * and the billing.transaction.* envelopes it would publish (billing.events)
+ * startEvents() POST /api/v1/events recording what Tips' outbox relays
+ * startLive() POST /internal/tips/deliveries recording chat deliveries (the live-chat adapter)
+ */
+const http = require('http');
+const crypto = require('crypto');
+const jwt = require('jsonwebtoken');
+const { serviceAuth, ids } = require('openvibe-contracts');
+
+function listen(server) {
+ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(`http://127.0.0.1:${server.address().port}`)));
+}
+function readBody(req) {
+ return new Promise((resolve) => { const c = []; req.on('data', (d) => c.push(d)); req.on('end', () => resolve(Buffer.concat(c).toString('utf8'))); });
+}
+const send = (res, status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
+
+async function startNetwork() {
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
+ const publicPem = publicKey.export({ type: 'spki', format: 'pem' });
+ const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });
+ const grants = [];
+ const legacy = {};
+ let issuer = 'http://network.test';
+ let n = 100;
+
+ function signService({ sub = 'svc:live', aud = ['openvibe.tips'], cap = [], expSec = 300 } = {}) {
+ const now = Math.floor(Date.now() / 1000);
+ return serviceAuth.signServiceToken({ iss: issuer, sub, actor_type: 'service', aud, cap, iat: now, exp: now + expSec, jti: crypto.randomBytes(8).toString('hex') }, privatePem);
+ }
+ /** A Network user access token (what the browser holds in ov_token). */
+ function signUser(u) {
+ return jwt.sign({ sub: String(u.networkId || ++n), subject_id: u.subject, username: u.username, display_name: u.display_name || u.username, role: u.role || 'user' },
+ privatePem, { algorithm: 'RS256', issuer, expiresIn: '1h' });
+ }
+ function newUser(username, liveId) {
+ const subject = ids.newId('user');
+ if (liveId != null) legacy[String(liveId)] = { subject, username };
+ return { subject, username, display_name: username[0].toUpperCase() + username.slice(1) };
+ }
+
+ const server = http.createServer(async (req, res) => {
+ const raw = await readBody(req);
+ if (req.url === '/api/.well-known/jwks') return send(res, 200, { public_key: publicPem, algorithm: 'RS256' });
+ if (req.url === '/oauth/token' && req.method === 'POST') {
+ const body = Object.fromEntries(new URLSearchParams(raw));
+ grants.push(body);
+ if (body.client_secret !== 'shh') return send(res, 401, { error: 'invalid_client' });
+ const cap = String(body.scope || '').split(/\s+/).filter(Boolean);
+ return send(res, 200, { access_token: signService({ sub: `svc:${body.client_id}`, aud: [body.audience], cap }), token_type: 'Bearer', expires_in: 300 });
+ }
+ if (req.url === '/internal/identity/resolve-batch' && req.method === 'POST') {
+ const v = serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: publicPem, issuer, audience: 'openvibe.network' });
+ if (!v.ok) return send(res, 401, { code: v.code });
+ if (!(v.claims.cap || []).includes('identity.subject.resolve')) return send(res, 403, { code: 'capability.denied' });
+ const body = JSON.parse(raw || '{}');
+ const results = {};
+ for (const id of body.ids || []) {
+ const u = body.system === 'live' ? legacy[String(id)] : null;
+ results[String(id)] = u ? { subject: { type: 'user', id: u.subject }, username: u.username } : null;
+ }
+ return send(res, 200, { results });
+ }
+ send(res, 404, { error: 'not found' });
+ });
+ const url = await listen(server);
+ issuer = url;
+ return { url, publicPem, signService, signUser, newUser, legacy, grants, close: () => new Promise((r) => server.close(r)) };
+}
+
+/** Billing as Tips sees it. */
+async function startBilling(network) {
+ const credit = new Map();
+ const payable = new Map();
+ const txns = new Map();
+ const intents = new Map();
+ const idem = new Map();
+ const calls = [];
+ const events = [];
+ const state = { down: false };
+ const bal = (m, s) => m.get(s) || 0;
+ const move = (m, s, d) => m.set(s, bal(m, s) + d);
+
+ function envelope(type, payload, subjectId) {
+ return {
+ event_id: ids.newId('event'), event_type: type, version: 1, source: 'billing', actor: { type: 'service', id: 'billing' },
+ timestamp: new Date().toISOString(), priority: 'important', visibility: 'internal', subject: { type: 'transaction', id: subjectId }, payload,
+ };
+ }
+ const summary = (t) => ({ transaction_id: t.id, type: t.type, test: !!t.test, from_subject: t.from_subject, to_subject: t.to_subject, provider: t.provider || null, metadata: t.metadata, ...(t.reverses_txn ? { reverses_txn: t.reverses_txn } : {}) });
+ function record(t, type) { txns.set(t.id, t); const e = envelope(type, summary(t), t.id); events.push(e); return e; }
+ const newTxn = (fields) => ({ id: `txn_${ids.ulid()}`, status: 'settled', test: false, created_at: new Date().toISOString(), ...fields });
+
+ function fund(subject, bits) {
+ move(credit, subject, bits);
+ return record(newTxn({ type: 'purchase', to_subject: subject, provider: 'powerchat', metadata: { bits } }), 'billing.transaction.settled');
+ }
+ /** The provider paid a checkout: the purchase settles (credit to the buyer) → the settled event. */
+ function settlePurchase(intentId, { test = false } = {}) {
+ const i = intents.get(intentId);
+ move(credit, i.subject.id, i.bits);
+ i.status = 'settled';
+ return record(newTxn({ type: 'purchase', to_subject: i.subject.id, provider: i.provider, test, metadata: { bits: i.bits, intent_id: i.id } }), 'billing.transaction.settled');
+ }
+ /** A donation Tips did not start (e.g. Live's own donate flow, or a site-routed PowerChat tip). */
+ function foreignDonation({ from, to, amount, provider = null, target = null, message = null, test = false, kind = 'donation' }) {
+ if (from) move(credit, from, -amount);
+ move(payable, to, amount);
+ return record(newTxn({ type: 'donation', from_subject: from || null, to_subject: to, provider, test, metadata: { kind, amount_bits: amount, target, message } }), 'billing.transaction.settled');
+ }
+ /** Refund a credit-funded transfer (payable → giver credit) outside Tips (operator / Live). */
+ function refund(txnId, amount) {
+ const o = txns.get(txnId);
+ const n = amount || o.metadata.amount_bits;
+ move(payable, o.to_subject, -n); move(credit, o.from_subject, n);
+ return record(newTxn({ type: 'refund', from_subject: o.to_subject, to_subject: o.from_subject, reverses_txn: o.id, metadata: { amount_bits: n } }), 'billing.transaction.reversed');
+ }
+
+ const server = http.createServer(async (req, res) => {
+ const raw = await readBody(req);
+ const body = raw ? JSON.parse(raw) : {};
+ calls.push({ method: req.method, url: req.url, body, key: req.headers['idempotency-key'] || null });
+ if (state.down) return send(res, 503, { code: 'billing.unavailable' });
+ const v = serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: network.publicPem, issuer: network.url, audience: 'openvibe.billing' });
+ if (!v.ok) return send(res, 401, { code: v.code });
+ const need = req.url.startsWith('/api/v1/intents') ? 'billing.intent.create' : 'billing.transfer.create';
+ if (!(v.claims.cap || []).includes(need)) return send(res, 403, { code: 'capability.denied', detail: `${need} not granted` });
+ const key = req.headers['idempotency-key'];
+ if (req.method === 'POST') {
+ if (!key) return send(res, 400, { code: 'idempotency.key_required' });
+ if (idem.has(key)) return send(res, idem.get(key).status, idem.get(key).body);
+ }
+ const ok = (status, out) => { idem.set(key, { status, body: out }); return send(res, status, out); };
+ if (req.method === 'POST' && req.url === '/api/v1/intents') {
+ if (body.kind !== 'purchase') return send(res, 422, { code: 'billing.invalid_input' });
+ if (body.bits < 100) return send(res, 422, { code: 'billing.amount_too_small', detail: 'the minimum purchase is 100 bits' });
+ const id = `pi_${ids.ulid()}`;
+ const intent = { id, provider: body.provider, kind: 'purchase', subject: body.subject, bits: body.bits, amount_cents: Math.round(body.bits * 1.5), status: 'created', success_url: body.success_url };
+ if (body.provider === 'powerchat') intent.checkout_ref = `pcorder:${id}`;
+ intents.set(id, intent);
+ return ok(201, { intent, checkout_url: body.provider === 'stripe' ? `https://checkout.stripe.test/${id}` : null });
+ }
+ if (req.method === 'POST' && req.url === '/api/v1/transfers') {
+ const from = body.from.id; const to = body.to.id;
+ if (from === to) return send(res, 422, { code: 'billing.self_dealing' });
+ if (bal(credit, from) < body.amount) return send(res, 409, { code: 'billing.insufficient_funds', detail: `insufficient credit: ${bal(credit, from)} < ${body.amount} vibes-bits` });
+ move(credit, from, -body.amount); move(payable, to, body.amount);
+ const t = newTxn({ type: 'donation', from_subject: from, to_subject: to, metadata: { kind: body.kind, amount_bits: body.amount, target: body.target || null, message: body.message || null } });
+ record(t, 'billing.transaction.settled');
+ return ok(201, { transaction: { ...t, reverses_txn: null } });
+ }
+ const m = req.url.match(/^\/api\/v1\/transfers\/([^/]+)\/refund$/);
+ if (req.method === 'POST' && m) {
+ const o = txns.get(m[1]);
+ if (!o) return send(res, 404, { code: 'billing.transaction_not_found' });
+ refund(o.id, body.amount);
+ return ok(201, { transaction: [...txns.values()].pop() });
+ }
+ send(res, 404, { code: 'not_found' });
+ });
+ const url = await listen(server);
+ return {
+ url, credit, payable, txns, intents, calls, events, state, fund, settlePurchase, foreignDonation, refund, envelope,
+ transfers: () => calls.filter((c) => c.url === '/api/v1/transfers'),
+ close: () => new Promise((r) => server.close(r)),
+ };
+}
+
+async function startEvents() {
+ const published = [];
+ const tokens = [];
+ const server = http.createServer(async (req, res) => {
+ const raw = await readBody(req);
+ tokens.push(req.headers.authorization);
+ if (req.url === '/api/v1/events' && req.method === 'POST') {
+ const b = JSON.parse(raw);
+ const list = b.events || [b];
+ const results = list.map((e) => { published.push(e); return { event_id: e.event_id, seq: published.length, duplicate: false }; });
+ return send(res, 201, b.events ? { results } : results[0]);
+ }
+ send(res, 404, {});
+ });
+ const url = await listen(server);
+ return { url, published, tokens, close: () => new Promise((r) => server.close(r)) };
+}
+
+async function startLive(network) {
+ const deliveries = [];
+ const state = { fail: null };
+ const seen = new Map();
+ const server = http.createServer(async (req, res) => {
+ const raw = await readBody(req);
+ if (req.url !== '/internal/tips/deliveries' || req.method !== 'POST') return send(res, 404, {});
+ const v = serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: network.publicPem, issuer: network.url, audience: 'openvibe.live' });
+ if (!v.ok) return send(res, 401, { code: v.code });
+ if (!(v.claims.cap || []).includes('live.tips_delivery.write')) return send(res, 403, { code: 'capability.denied' });
+ if (state.fail) return send(res, state.fail, { code: 'live.refused', detail: 'stub refusal' });
+ const key = req.headers['idempotency-key'];
+ if (seen.has(key)) return send(res, 200, seen.get(key));
+ const job = JSON.parse(raw);
+ deliveries.push({ key, job });
+ const out = { ok: true, ref: { chat_message_id: deliveries.length } };
+ seen.set(key, out);
+ return send(res, 200, out);
+ });
+ const url = await listen(server);
+ return { url, deliveries, state, close: () => new Promise((r) => server.close(r)) };
+}
+
+module.exports = { startNetwork, startBilling, startEvents, startLive };
new file mode 100644
new file mode 100644
@@ -0,0 +1,191 @@
@@ -0,0 +1,191 @@
+'use strict';
+// Import from a Live snapshot, linked to Billing's imported journal, with the reconciliation:
+// creator totals must equal Billing's. Fixtures follow Live's schema and Billing's importer keys.
+const assert = require('assert');
+const path = require('path');
+const Database = require('better-sqlite3');
+const { boot, check, done } = require('./helpers/app');
+
+function liveFixture(file) {
+ const db = new Database(file);
+ db.exec(`
+ CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT, display_name TEXT, openvibe_bucks_balance INTEGER DEFAULT 0);
+ CREATE TABLE transactions (id INTEGER PRIMARY KEY, from_user_id INTEGER, to_user_id INTEGER, stream_id INTEGER, amount INTEGER NOT NULL,
+ type TEXT NOT NULL, status TEXT DEFAULT 'completed', message TEXT, paypal_transaction_id TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);
+ CREATE TABLE chat_messages (id INTEGER PRIMARY KEY, stream_id INTEGER, channel_user_id INTEGER, user_id INTEGER, username TEXT, message TEXT,
+ message_type TEXT DEFAULT 'chat', metadata TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);
+ CREATE TABLE donation_goals (id INTEGER PRIMARY KEY, user_id INTEGER, title TEXT, target_amount INTEGER, current_amount INTEGER DEFAULT 0,
+ is_active INTEGER DEFAULT 1, image_url TEXT, media_type TEXT, reached_at DATETIME, sort_order INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);
+ CREATE TABLE site_settings (key TEXT PRIMARY KEY, value TEXT);
+ `);
+ const u = db.prepare('INSERT INTO users (id, username, display_name) VALUES (?, ?, ?)');
+ [[1, 'alex', 'Alex'], [2, 'viewer', 'Viewer'], [3, 'ghost', 'Ghost'], [4, 'held', 'Held'], [5, 'bob', 'Bob']].forEach((r) => u.run(...r));
+ db.prepare("INSERT INTO site_settings (key, value) VALUES ('stats_vibes_reset_at', '2026-01-01T00:00:00.000Z')").run();
+ const t = db.prepare('INSERT INTO transactions (id, from_user_id, to_user_id, stream_id, amount, type, status, message, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)');
+ t.run(1, 2, 1, 7, 100, 'donation', 'completed', 'test era', '2025-12-01 10:00:00');
+ t.run(2, 2, 1, 7, 500, 'donation', 'completed', 'nice', '2026-02-01 10:00:00');
+ t.run(3, 3, 1, null, 200, 'donation', 'completed', null, '2026-02-02 10:00:00');
+ t.run(4, 2, 4, null, 300, 'donation', 'completed', null, '2026-02-02 11:00:00');
+ t.run(5, 2, 1, null, 50, 'donation', 'failed', null, '2026-02-02 12:00:00');
+ t.run(6, 2, 1, 7, 250, 'donation', 'completed', 'Media request: Song', '2026-02-03 10:00:00');
+ t.run(7, 1, 2, null, 250, 'refund', 'completed', 'Refund: Song', '2026-02-04 10:00:00');
+ t.run(8, null, 5, null, 1000, 'donation', 'completed', 'PowerChat tip via site account ($10.00)', '2026-02-05 12:00:00');
+ t.run(9, null, 2, null, 1000, 'purchase', 'completed', null, '2026-01-20 12:00:00');
+ const c = db.prepare("INSERT INTO chat_messages (id, channel_user_id, username, message, message_type, metadata, created_at) VALUES (?, ?, ?, ?, 'donation', ?, ?)");
+ c.run(1, 5, 'Fan', 'Fan tipped 1000 Vibes (PowerChat)', JSON.stringify({ kind: 'donation', amount: 1000, message: '', username: 'Fan', source: 'powerchat' }), '2026-02-05 12:00:30');
+ c.run(2, 1, 'Direct', 'Direct tipped 700 Vibes (PowerChat)', JSON.stringify({ kind: 'donation', amount: 700, message: 'love it', username: 'Direct', source: 'powerchat' }), '2026-02-06 09:00:00');
+ c.run(3, 1, 'Viewer', 'Viewer donated 500 Vibes: nice', JSON.stringify({ kind: 'donation', amount: 500, username: 'Viewer' }), '2026-02-01 10:00:01');
+ const g = db.prepare('INSERT INTO donation_goals (id, user_id, title, target_amount, current_amount, is_active, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)');
+ g.run(1, 1, 'Synth', 5000, 1200, 1, '2026-01-10 10:00:00');
+ g.run(2, 4, 'Held goal', 100, 0, 1, '2026-01-10 10:00:00');
+ return db;
+}
+
+/** The rows OpenVibe.Billing's importer writes for that snapshot (server/importer/live.js keys). */
+function billingFixture(file, subjects, { withNative = false } = {}) {
+ const db = new Database(file);
+ db.exec(`CREATE TABLE transactions (id TEXT PRIMARY KEY, type TEXT NOT NULL, status TEXT NOT NULL, idempotency_key TEXT UNIQUE, reverses_txn TEXT,
+ test INTEGER NOT NULL DEFAULT 0, from_subject TEXT, to_subject TEXT, provider TEXT, receipt_ref TEXT, metadata TEXT, created_at TEXT)`);
+ const ins = db.prepare('INSERT INTO transactions (id, type, status, idempotency_key, reverses_txn, test, from_subject, to_subject, metadata, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
+ const live = [
+ [1, 2, 1, 100, 'donation', 'completed', true], [2, 2, 1, 500, 'donation', 'completed'], [3, 3, 1, 200, 'donation', 'completed'],
+ [4, 2, 4, 300, 'donation', 'completed'], [5, 2, 1, 50, 'donation', 'failed'], [6, 2, 1, 250, 'donation', 'completed'],
+ [7, 1, 2, 250, 'refund', 'completed'], [8, null, 5, 1000, 'donation', 'completed'], [9, null, 2, 1000, 'purchase', 'completed'],
+ ];
+ for (const [id, from, to, amount, type, status, test] of live) {
+ const note = status === 'completed' ? null : `status ${status}: no balance moved`;
+ ins.run(`txn_import_${id}`, 'import', 'imported', `import:live:txn:${id}`, null, test ? 1 : 0, from ? subjects[from] || null : null, to ? subjects[to] || null : null,
+ JSON.stringify({ live: { id, type, status, amount, from_user_id: from, to_user_id: to }, note }), '2026-09-22T00:00:00Z');
+ }
+ if (withNative) {
+ ins.run('txn_native_1', 'donation', 'settled', 'api:svc:live:k1', null, 0, subjects[2], subjects[1], JSON.stringify({ kind: 'donation', amount_bits: 40 }), '2026-09-22T01:00:00Z');
+ ins.run('txn_native_test', 'donation', 'settled', 'api:svc:live:k2', null, 1, subjects[2], subjects[1], JSON.stringify({ kind: 'donation', amount_bits: 999 }), '2026-09-22T01:00:00Z');
+ }
+ return db;
+}
+
+(async () => {
+ const t = await boot();
+ const { domain } = t;
+ const { importLive, billingSnapshotSource } = require('../server/importer/live');
+ const { createIdentity } = require('../server/network');
+ const identity = createIdentity(t.config);
+ const users = { 1: t.network.newUser('alex', 1), 2: t.network.newUser('viewer', 2), 5: t.network.newUser('bob', 5) };
+ const subjects = { 1: users[1].subject, 2: users[2].subject, 5: users[5].subject };
+ const live = liveFixture(path.join(t.dir, 'live-snapshot.db'));
+ const bdb = billingFixture(path.join(t.dir, 'billing-snapshot.db'), subjects, { withNative: true });
+ const quiet = { log() {} };
+ const run = (opts = {}) => importLive(domain, { live, billingSource: billingSnapshotSource(bdb), resolveLiveUsers: identity.resolveLiveUsers, log: quiet, ...opts });
+
+ await check('a dry run reports and keeps nothing', async () => {
+ const r = await run({ dryRun: true });
+ assert.strictEqual(r.dry_run, true);
+ assert.ok(r.counts.imported > 0);
+ assert.strictEqual(t.db.prepare('SELECT COUNT(*) AS n FROM tip_interactions').get().n, 0);
+ assert.strictEqual(t.db.prepare('SELECT COUNT(*) AS n FROM migration_maps').get().n, 0);
+ });
+
+ let report;
+ await check('donations import settled and linked to Billing; failed excluded; unmapped creators held; test era flagged', async () => {
+ report = await run();
+ const byLegacy = (id) => t.db.prepare('SELECT * FROM tip_interactions WHERE legacy_source = ?').get(`live:transactions:${id}`);
+ const two = byLegacy(2);
+ assert.strictEqual(two.payment_state, 'settled');
+ assert.strictEqual(two.settlement, 'imported');
+ assert.strictEqual(two.billing_txn_id, 'txn_import_2');
+ assert.strictEqual(two.supporter_subject, subjects[2]);
+ assert.strictEqual(two.message, 'nice');
+ assert.strictEqual(two.created_at, '2026-02-01T10:00:00.000Z');
+ assert.strictEqual(byLegacy(1).test, 1, 'before stats_vibes_reset_at');
+ const ghost = byLegacy(3);
+ assert.strictEqual(ghost.supporter_subject, null);
+ assert.strictEqual(ghost.supporter_name, 'Ghost');
+ assert.strictEqual(byLegacy(6).kind, 'media_request');
+ assert.strictEqual(byLegacy(8).funding, 'provider');
+ assert.strictEqual(byLegacy(4), undefined, 'held, not dropped');
+ const held = t.db.prepare("SELECT * FROM migration_maps WHERE source_table = 'transactions' AND source_id = '4'").get();
+ assert.strictEqual(held.status, 'held');
+ const excluded = t.db.prepare("SELECT * FROM migration_maps WHERE source_table = 'transactions' AND source_id = '5'").get();
+ assert.strictEqual(excluded.status, 'excluded');
+ assert.match(excluded.reason, /status failed/);
+ assert.strictEqual(report.counts.linked_to_billing, report.counts.imported);
+ // Imports are history: no effects, no overlay alerts, no events.
+ assert.strictEqual(t.db.prepare('SELECT COUNT(*) AS n FROM interaction_effects').get().n, 0);
+ assert.strictEqual(t.db.prepare('SELECT COUNT(*) AS n FROM overlay_deliveries').get().n, 0);
+ assert.strictEqual(t.outboxRows('tips.interaction.ready').length, 0);
+ });
+
+ await check('the refund nets out against the donation it undoes', async () => {
+ const six = t.db.prepare("SELECT * FROM tip_interactions WHERE legacy_source = 'live:transactions:6'").get();
+ assert.strictEqual(six.reversed_bits, 250);
+ assert.strictEqual(six.payment_state, 'reversed');
+ assert.strictEqual(report.counts.refunds_applied, 1);
+ });
+
+ await check('external PowerChat tips: the direct one is imported as EXTERNAL; the site-routed celebration is not counted twice', async () => {
+ const ext = t.db.prepare("SELECT * FROM tip_interactions WHERE legacy_source = 'live:chat_messages:2'").get();
+ assert.strictEqual(ext.settlement, 'external');
+ assert.strictEqual(ext.amount, 700);
+ assert.strictEqual(ext.supporter_name, 'Direct');
+ const twin = t.db.prepare("SELECT * FROM migration_maps WHERE source_table = 'chat_messages' AND source_id = '1'").get();
+ assert.strictEqual(twin.status, 'excluded');
+ assert.match(twin.reason, /site-routed tip \(Live transaction 8\)/);
+ assert.strictEqual(t.db.prepare("SELECT COUNT(*) AS n FROM migration_maps WHERE source_table = 'chat_messages' AND source_id = '3'").get().n, 0, 'on-site donation lines are not PowerChat');
+ });
+
+ await check('goals import with Live\'s current amount carried over (not invented contributions)', async () => {
+ const g = t.db.prepare("SELECT * FROM tip_goals WHERE legacy_source = 'live:donation_goals:1'").get();
+ const view = domain.goals.present(g);
+ assert.strictEqual(view.current_amount, 1200);
+ assert.strictEqual(view.carried_over_amount, 1200);
+ assert.strictEqual(view.supporters_count, 0);
+ });
+
+ await check('reconciliation: a Billing donation Tips never saw is reported, not absorbed', async () => {
+ const r = report.reconciliation;
+ assert.strictEqual(r.ok, false);
+ assert.deepStrictEqual(r.mismatches, [{ creator: subjects[1], tips: 700, billing: 740, difference: -40 }]);
+ assert.ok(r.rows.find((x) => x.creator === subjects[5] && x.tips === 1000 && x.billing === 1000));
+ });
+
+ await check('once the Billing event arrives, creator totals equal Billing exactly (test money excluded on both sides)', async () => {
+ const ev = t.billing.envelope('billing.transaction.settled', { transaction_id: 'txn_native_1', type: 'donation', test: false, from_subject: subjects[2], to_subject: subjects[1], provider: null, metadata: { kind: 'donation', amount_bits: 40 } }, 'txn_native_1');
+ assert.strictEqual((await t.deliver(ev)).json.outcome, 'recorded');
+ const evTest = t.billing.envelope('billing.transaction.settled', { transaction_id: 'txn_native_test', type: 'donation', test: true, from_subject: subjects[2], to_subject: subjects[1], provider: null, metadata: { kind: 'donation', amount_bits: 999 } }, 'txn_native_test');
+ await t.deliver(evTest);
+ const { reconcile } = require('../server/importer/live');
+ const r = reconcile(domain, billingSnapshotSource(bdb));
+ assert.strictEqual(r.ok, true, JSON.stringify(r.mismatches));
+ assert.strictEqual(domain.interactions.totals(subjects[1]).settled_via_billing, 740);
+ assert.strictEqual(domain.interactions.totals(subjects[1]).external, 700);
+ });
+
+ await check('a re-run changes nothing; a newly mapped creator is released from hold', async () => {
+ const before = t.db.prepare('SELECT COUNT(*) AS n FROM tip_interactions').get().n;
+ const again = await run();
+ assert.strictEqual(again.counts.imported, 0);
+ assert.strictEqual(again.counts.goals, 0);
+ assert.ok(again.counts.unchanged > 0);
+ assert.strictEqual(t.db.prepare('SELECT COUNT(*) AS n FROM tip_interactions').get().n, before);
+ const heldUser = t.network.newUser('held', 4);
+ const r = await run();
+ assert.strictEqual(r.counts.released, 1);
+ assert.strictEqual(r.counts.goals, 1);
+ const four = t.db.prepare("SELECT * FROM tip_interactions WHERE legacy_source = 'live:transactions:4'").get();
+ assert.strictEqual(four.creator_subject, heldUser.subject);
+ assert.strictEqual(four.billing_txn_id, 'txn_import_4');
+ assert.strictEqual(t.db.prepare("SELECT status FROM migration_maps WHERE source_table = 'transactions' AND source_id = '4'").get().status, 'imported');
+ });
+
+ await check('every source row is imported, excluded with a reason, or held — none silently dropped', async () => {
+ const tipRows = live.prepare("SELECT id FROM transactions WHERE type IN ('donation', 'refund')").all().map((x) => String(x.id));
+ const mapped = new Set(t.db.prepare("SELECT source_id FROM migration_maps WHERE source_table = 'transactions'").all().map((x) => x.source_id));
+ assert.deepStrictEqual(tipRows.filter((id) => !mapped.has(id)), []);
+ const excluded = t.db.prepare("SELECT * FROM migration_maps WHERE status = 'excluded'").all();
+ assert.ok(excluded.every((x) => x.reason));
+ });
+
+ live.close(); bdb.close();
+ await t.close();
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,178 @@
@@ -0,0 +1,178 @@
+'use strict';
+// Overlays: scoped revocable tokens, SSE delivery, replay that never charges or re-counts, the
+// delivery window, and simulations that are never counted.
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/app');
+
+(async () => {
+ const t = await boot();
+ const { domain, billing } = t;
+ const alex = await t.creator('alex');
+ const viewer = t.network.newUser('viewer');
+ billing.fund(viewer.subject, 10_000);
+ const goal = (await t.call('POST', '/api/v1/goals', { user: alex, body: { title: 'Desk', target_amount: 1000 } })).json.goal;
+
+ let secret;
+ let tokenId;
+ await check('an overlay token is shown once, stored hashed, and scoped', async () => {
+ const r = await t.call('POST', '/api/v1/overlay-tokens', { user: alex, body: { label: 'OBS', scopes: ['alerts', 'goals'] } });
+ assert.strictEqual(r.status, 201, r.text);
+ secret = r.json.secret;
+ tokenId = r.json.token.id;
+ assert.match(secret, /^tovl_[A-Za-z0-9_-]{43}$/);
+ assert.strictEqual(r.json.overlay_url, `http://tips.test/overlay/${secret}`);
+ const row = t.db.prepare('SELECT * FROM overlay_tokens WHERE id = ?').get(tokenId);
+ assert.notStrictEqual(row.token_hash, secret);
+ assert.ok(!JSON.stringify(row).includes(secret), 'the secret is not stored');
+ const list = await t.call('GET', '/api/v1/overlay-tokens', { user: alex });
+ assert.ok(!list.text.includes(secret), 'listing never shows the secret');
+ const other = t.network.newUser('mallory');
+ const denied = await t.call('POST', '/api/v1/overlay-tokens', { user: other, body: { creator: 'alex' } });
+ assert.strictEqual(denied.status, 403);
+ const bad = await t.sse('/overlay/tovl_not-a-real-token-at-all-000000000000000000/events');
+ assert.strictEqual(bad.statusCode, 404);
+ });
+
+ let firstAlertSeq;
+ await check('a settled tip reaches a connected overlay once (tips.overlay.delivered once)', async () => {
+ const s = await t.sse(`/overlay/${secret}/events`);
+ assert.strictEqual(s.statusCode, 200);
+ const hello = await s.waitFor((e) => e.event === 'hello');
+ assert.strictEqual(hello.data.goals[0].id, goal.id);
+ const r = await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: 'alex', amount: 300, message: 'hi' } });
+ const alert = await s.waitFor((e) => e.event === 'alert');
+ assert.strictEqual(alert.data.interaction_id, r.json.interaction.id);
+ assert.strictEqual(alert.data.amount, 300);
+ assert.strictEqual(alert.data.test, false);
+ const g = await s.waitFor((e) => e.event === 'goal' && e.data.goal.current_amount === 300);
+ assert.strictEqual(g.data.goal.id, goal.id);
+ firstAlertSeq = alert.id;
+ s.close();
+ const d = t.db.prepare("SELECT * FROM overlay_deliveries WHERE kind = 'alert' AND interaction_id = ?").get(r.json.interaction.id);
+ assert.strictEqual(d.status, 'delivered');
+ assert.strictEqual(t.outboxRows('tips.overlay.delivered').filter((e) => e.payload.delivery_id === d.id).length, 1);
+ });
+
+ await check('overlay replay (Last-Event-ID) resends but never charges, re-counts or re-emits', async () => {
+ const before = {
+ transfers: billing.transfers().length, contributions: t.db.prepare('SELECT COUNT(*) AS n FROM tip_goal_contributions').get().n,
+ goal: domain.goals.present(domain.goals.get(goal.id)).current_amount, delivered: t.outboxRows('tips.overlay.delivered').length,
+ interactions: t.db.prepare('SELECT COUNT(*) AS n FROM tip_interactions').get().n, payable: billing.payable.get(alex.subject),
+ };
+ for (let k = 0; k < 3; k++) {
+ const s = await t.sse(`/overlay/${secret}/events`, { lastEventId: firstAlertSeq - 1 });
+ const again = await s.waitFor((e) => e.event === 'alert');
+ assert.strictEqual(again.id, firstAlertSeq);
+ s.close();
+ }
+ assert.strictEqual(billing.transfers().length, before.transfers);
+ assert.strictEqual(t.db.prepare('SELECT COUNT(*) AS n FROM tip_goal_contributions').get().n, before.contributions);
+ assert.strictEqual(domain.goals.present(domain.goals.get(goal.id)).current_amount, before.goal);
+ assert.strictEqual(t.outboxRows('tips.overlay.delivered').length, before.delivered);
+ assert.strictEqual(t.db.prepare('SELECT COUNT(*) AS n FROM tip_interactions').get().n, before.interactions);
+ assert.strictEqual(billing.payable.get(alex.subject), before.payable);
+ assert.ok(t.db.prepare('SELECT sends FROM overlay_deliveries WHERE seq = ?').get(firstAlertSeq).sends >= 4);
+ });
+
+ await check('a tip while no overlay is connected waits, is delivered on connect, else fails after the window', async () => {
+ const r1 = await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: 'alex', amount: 11 } });
+ const s = await t.sse(`/overlay/${secret}/events`);
+ const late = await s.waitFor((e) => e.event === 'alert' && e.data.interaction_id === r1.json.interaction.id);
+ assert.ok(late);
+ s.close();
+ await new Promise((ok) => setTimeout(ok, 50));
+ const r2 = await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: 'alex', amount: 12 } });
+ t.clock.offset += 11 * 60 * 1000;
+ const n = domain.overlays.sweepFailed();
+ t.clock.offset = 0;
+ assert.ok(n >= 1);
+ const d = t.db.prepare("SELECT * FROM overlay_deliveries WHERE kind = 'alert' AND interaction_id = ?").get(r2.json.interaction.id);
+ assert.strictEqual(d.status, 'failed');
+ assert.strictEqual(t.outboxRows('tips.overlay.failed').filter((e) => e.payload.delivery_id === d.id).length, 1);
+ // The payment is untouched by the overlay failure.
+ assert.strictEqual(domain.interactions.get(r2.json.interaction.id).payment_state, 'settled');
+ });
+
+ await check('simulation runs the full path with test = 1, no Billing call, never counted', async () => {
+ const before = {
+ transfers: billing.calls.length, goal: domain.goals.present(domain.goals.get(goal.id)).current_amount,
+ totals: domain.interactions.totals(alex.subject), events: t.outboxRows().length,
+ };
+ const s = await t.sse(`/overlay/${secret}/events`);
+ await s.waitFor((e) => e.event === 'hello');
+ const r = await t.call('POST', '/api/v1/simulate', { user: alex, body: { kind: 'paid_message', amount: 500, message: 'test run', supporter_name: 'Tester' } });
+ assert.strictEqual(r.status, 201, r.text);
+ const i = r.json.interaction;
+ assert.strictEqual(i.test, true);
+ assert.strictEqual(i.settlement, 'simulated');
+ const alert = await s.waitFor((e) => e.event === 'alert' && e.data.interaction_id === i.id);
+ assert.strictEqual(alert.data.test, true);
+ const g = await s.waitFor((e) => e.event === 'goal' && e.data.test === true);
+ assert.strictEqual(g.data.goal.current_amount, before.goal + 500, 'the widget shows the would-be total');
+ s.close();
+ await domain.effects.drain();
+ const job = t.adapters.test.jobs.find((j) => j.interaction.id === i.id);
+ assert.ok(job && job.test === true, 'chat effect delivered to the test adapter, flagged test');
+ assert.strictEqual(billing.calls.length, before.transfers, 'no Billing call');
+ assert.strictEqual(domain.goals.present(domain.goals.get(goal.id)).current_amount, before.goal, 'goal unchanged');
+ assert.strictEqual(t.db.prepare('SELECT COUNT(*) AS n FROM tip_goal_contributions WHERE interaction_id = ?').get(i.id).n, 0);
+ const after = domain.interactions.totals(alex.subject);
+ assert.strictEqual(after.settled_via_billing, before.totals.settled_via_billing);
+ assert.strictEqual(after.interactions, before.totals.interactions);
+ assert.strictEqual(after.simulations, before.totals.simulations + 1);
+ assert.strictEqual(t.outboxRows().length, before.events, 'no durable events for a simulation');
+ const denied = await t.call('POST', '/api/v1/simulate', { user: viewer, body: { creator: 'alex' } });
+ assert.strictEqual(denied.status, 403);
+ });
+
+ await check('a config change reaches open overlays; minimum alert amount filters small tips', async () => {
+ const cfg = (await t.call('POST', '/api/v1/overlay-configs', { user: alex, body: { kind: 'alerts', name: 'Big only', min_amount: 1000 } })).json.config;
+ const tok = (await t.call('POST', '/api/v1/overlay-tokens', { user: alex, body: { scopes: ['alerts'], config_id: cfg.id } })).json;
+ const s = await t.sse(`/overlay/${tok.secret}/events`);
+ const hello = await s.waitFor((e) => e.event === 'hello');
+ assert.strictEqual(hello.data.config.settings.min_amount, 1000);
+ assert.deepStrictEqual(hello.data.goals, [], 'no goals scope, no goals');
+ await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: 'alex', amount: 5 } });
+ const big = await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: 'alex', amount: 1500 } });
+ const a = await s.waitFor((e) => e.event === 'alert');
+ assert.strictEqual(a.data.interaction_id, big.json.interaction.id, 'the small tip was filtered');
+ assert.ok(!s.events.some((e) => e.event === 'goal'));
+ await t.call('PATCH', `/api/v1/overlay-configs/${cfg.id}`, { user: alex, body: { duration_ms: 3000 } });
+ const c = await s.waitFor((e) => e.event === 'config');
+ assert.strictEqual(c.data.config.settings.duration_ms, 3000);
+ s.close();
+ });
+
+ await check('revocation is immediate: the open stream is closed and the link stops working', async () => {
+ const s = await t.sse(`/overlay/${secret}/events`);
+ await s.waitFor((e) => e.event === 'hello');
+ const r = await t.call('POST', `/api/v1/overlay-tokens/${tokenId}/revoke`, { user: alex });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json.token.active, false);
+ const rev = await s.waitFor((e) => e.event === 'revoked');
+ assert.ok(rev);
+ await new Promise((ok) => setTimeout(ok, 50));
+ assert.strictEqual(s.ended, true, 'stream closed by the server');
+ const again = await t.sse(`/overlay/${secret}/events`);
+ assert.strictEqual(again.statusCode, 404);
+ const page = await fetch(`${t.base}/overlay/${secret}`);
+ assert.strictEqual(page.status, 404);
+ const state = await fetch(`${t.base}/overlay/${secret}/state`);
+ assert.strictEqual(state.status, 404);
+ });
+
+ await check('the overlay page and state need only the token (no cookie) and are never indexed or cached', async () => {
+ const tok = (await t.call('POST', '/api/v1/overlay-tokens', { user: alex, body: { scopes: ['goals'] } })).json;
+ const page = await fetch(`${t.base}/overlay/${tok.secret}`);
+ assert.strictEqual(page.status, 200);
+ assert.strictEqual(page.headers.get('cache-control'), 'no-store');
+ assert.match(page.headers.get('x-robots-tag'), /noindex/);
+ assert.strictEqual(page.headers.get('referrer-policy'), 'no-referrer');
+ const state = await (await fetch(`${t.base}/overlay/${tok.secret}/state`)).json();
+ assert.strictEqual(state.goals[0].id, goal.id);
+ assert.strictEqual(state.alerts, undefined, 'goals-only token sees no alerts');
+ });
+
+ await t.close();
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,159 @@
@@ -0,0 +1,159 @@
+'use strict';
+// Server-rendered pages: useful without JavaScript, indexable only when the creator switched the
+// page on, forms protected against cross-site posts, and the copy rules.
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/app');
+
+(async () => {
+ const t = await boot();
+ const { billing, domain } = t;
+ const alex = await t.creator('alex', { settings: { headline: 'Late-night synth streams', tts_enabled: true } });
+ const bob = await t.creator('bob', { settings: { page_enabled: false } });
+ const viewer = t.network.newUser('viewer');
+ billing.fund(viewer.subject, 1000);
+ await t.call('POST', '/api/v1/goals', { user: alex, body: { title: 'New synth', target_amount: 5000 } });
+
+ const cookie = (u) => `ov_token=${t.network.signUser(u)}`;
+ const get = async (p, u) => { const r = await fetch(t.base + p, { headers: u ? { Cookie: cookie(u) } : {}, redirect: 'manual' }); return { status: r.status, headers: r.headers, text: await r.text() }; };
+ const post = async (p, u, form) => {
+ const r = await fetch(t.base + p, { method: 'POST', redirect: 'manual', headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...(u ? { Cookie: cookie(u) } : {}) }, body: new URLSearchParams(form).toString() });
+ return { status: r.status, headers: r.headers, text: await r.text() };
+ };
+ const field = (html, name) => { const m = html.match(new RegExp(`name="${name}" value="([^"]*)"`)); return m ? m[1] : null; };
+ const COPY = /\bfree\b|\$0\b|no ads/i;
+
+ await check('home: server-rendered with the shared chrome, a noscript nav and the release meta', async () => {
+ const r = await get('/');
+ assert.strictEqual(r.status, 200);
+ assert.match(r.text, /<noscript><nav/);
+ assert.match(r.text, /https:\/\/openvibe\.network\/shared\/navbar\.js/);
+ assert.match(r.text, /<meta name="ov-release"/);
+ assert.match(r.text, /id="ov-footer"|class="ovf/);
+ assert.match(r.text, /href="\/alex"/);
+ assert.ok(!r.text.includes('href="/bob"'), 'switched-off pages are not listed');
+ });
+
+ await check('creator page: indexable when on, goals and a no-JS tip form', async () => {
+ const r = await get('/alex');
+ assert.strictEqual(r.status, 200);
+ assert.match(r.text, /<meta name="robots" content="index,follow">/);
+ assert.match(r.text, /<link rel="canonical" href="http:\/\/tips\.test\/alex">/);
+ assert.match(r.text, /New synth/);
+ assert.match(r.text, /<form class="card tip-form" method="post" action="\/alex\/tip">/);
+ assert.match(r.text, /Sign in to send/);
+ assert.match(r.text, /Late-night synth streams/);
+ assert.match(r.text, /"@type":"ProfilePage"/);
+ assert.match((await get('/@alex')).headers.get('location') || '', /\/alex$/);
+ });
+
+ await check('a switched-off page: 404 + noindex for everyone else; a noindex preview for its creator', async () => {
+ const anon = await get('/bob');
+ assert.strictEqual(anon.status, 404);
+ assert.match(anon.text, /noindex/);
+ const other = await get('/bob', viewer);
+ assert.strictEqual(other.status, 404);
+ const own = await get('/bob', bob);
+ assert.strictEqual(own.status, 200);
+ assert.match(own.text, /<meta name="robots" content="noindex,nofollow">/);
+ assert.match(own.headers.get('x-robots-tag'), /noindex/);
+ assert.match(own.text, /Your page is switched off/);
+ });
+
+ await check('robots.txt and sitemap.xml list only switched-on pages', async () => {
+ const robots = await get('/robots.txt');
+ assert.match(robots.text, /Disallow: \/dashboard/);
+ assert.match(robots.text, /Disallow: \/overlay\//);
+ const sm = await get('/sitemap.xml');
+ assert.match(sm.text, /<loc>http:\/\/tips\.test\/alex<\/loc>/);
+ assert.ok(!sm.text.includes('/bob<'));
+ });
+
+ await check('tipping without JavaScript: sign-in first, then a form post settles and lands on the receipt', async () => {
+ const anon = await post('/alex/tip', null, { amount: '10' });
+ assert.strictEqual(anon.status, 303);
+ assert.match(anon.headers.get('location'), /^\/auth\/login\?next=%2Falex/);
+ const page = await get('/alex', viewer);
+ const csrf = field(page.text, 'csrf');
+ const idem = field(page.text, 'idem');
+ assert.ok(csrf && idem);
+ const form = { csrf, idem, kind: 'tip', amount: '25', message: 'no js needed', pay_with: 'credit', supporter_name: 'Viewer' };
+ const r = await post('/alex/tip', viewer, form);
+ assert.strictEqual(r.status, 303, r.text.slice(0, 300));
+ const loc = r.headers.get('location');
+ assert.match(loc, /^\/receipts\/tint_/);
+ const id = loc.split('/').pop();
+ assert.strictEqual(domain.interactions.get(id).payment_state, 'settled');
+ // Double submit (same form nonce): the same interaction, no second transfer.
+ const before = billing.transfers().length;
+ const again = await post('/alex/tip', viewer, form);
+ assert.strictEqual(again.headers.get('location'), loc);
+ assert.strictEqual(billing.transfers().length, before);
+ const receipt = await get(loc, viewer);
+ assert.strictEqual(receipt.status, 200);
+ assert.match(receipt.text, /Paid/);
+ assert.match(receipt.text, /no js needed/);
+ assert.match(receipt.text, /noindex/);
+ assert.strictEqual((await get(loc, bob)).status, 404, 'someone else\'s receipt');
+ const list = await get('/receipts', viewer);
+ assert.match(list.text, new RegExp(id));
+ });
+
+ await check('a forged cross-site form (no valid token) is refused', async () => {
+ const r = await post('/alex/tip', viewer, { csrf: 'forged', idem: 'form-abcdefghijkl', amount: '10' });
+ assert.strictEqual(r.status, 403);
+ const other = await get('/alex', bob);
+ const r2 = await post('/alex/tip', viewer, { csrf: field(other.text, 'csrf'), idem: 'form-abcdefghijkm', amount: '10' });
+ assert.strictEqual(r2.status, 403, 'a token minted for someone else does not work');
+ const d = await post('/dashboard/profile', alex, { csrf: 'nope', page_enabled: '0' });
+ assert.strictEqual(d.status, 403);
+ assert.strictEqual(domain.profiles.byHandle('alex').page_enabled, true);
+ });
+
+ await check('the form reports Billing\'s refusal in words; checkout redirects to the provider', async () => {
+ const page = await get('/alex', viewer);
+ const low = await post('/alex/tip', viewer, { csrf: field(page.text, 'csrf'), idem: 'form-low-balance-1', amount: '99999', pay_with: 'credit' });
+ assert.strictEqual(low.status, 409);
+ assert.match(low.text, /balance is too low/);
+ const co = await post('/alex/tip', viewer, { csrf: field(page.text, 'csrf'), idem: 'form-checkout-0001', amount: '200', pay_with: 'checkout', provider: 'stripe' });
+ assert.strictEqual(co.status, 303);
+ assert.match(co.headers.get('location'), /^https:\/\/checkout\.stripe\.test\//);
+ });
+
+ await check('dashboard: sign-in, settings, goals, a show-once overlay link, a simulation', async () => {
+ assert.match((await get('/dashboard')).headers.get('location'), /^\/auth\/login/);
+ const d = await get('/dashboard', alex);
+ assert.strictEqual(d.status, 200);
+ assert.match(d.text, /Creator dashboard/);
+ assert.match(d.text, /noindex/);
+ const csrf = field(d.text, 'csrf');
+ const tok = await post('/dashboard/overlay-tokens', alex, { csrf, idem: 'x', label: 'Main', scope_alerts: '1', scope_goals: '1' });
+ assert.strictEqual(tok.status, 201);
+ assert.strictEqual(tok.headers.get('cache-control'), 'no-store');
+ const url = tok.text.match(/value="(http:\/\/tips\.test\/overlay\/tovl_[^"]+)"/)[1];
+ const secret = url.split('/').pop();
+ assert.strictEqual((await fetch(`${t.base}/overlay/${secret}`)).status, 200);
+ const d2 = await get('/dashboard', alex);
+ assert.ok(!d2.text.includes(secret), 'the dashboard never shows it again');
+ const g = await post('/dashboard/goals', alex, { csrf, idem: 'x', title: 'Second goal', target_amount: '300' });
+ assert.strictEqual(g.status, 303);
+ const sim = await post('/dashboard/simulate', alex, { csrf, idem: 'x', kind: 'tip', amount: '100', supporter_name: 'Test supporter', message: 'test' });
+ assert.strictEqual(sim.status, 303);
+ assert.strictEqual(t.db.prepare("SELECT COUNT(*) AS n FROM tip_interactions WHERE creator_subject = ? AND test = 1").get(alex.subject).n, 1);
+ const off = await post('/dashboard/profile', alex, { csrf, idem: 'x', accepting: '1', revision: String(domain.profiles.byHandle('alex').revision) });
+ assert.strictEqual(off.status, 303);
+ assert.strictEqual(domain.profiles.byHandle('alex').page_enabled, false, 'an unchecked box switches the page off');
+ assert.strictEqual((await get('/alex')).status, 404);
+ });
+
+ await check('copy rules: no pricing claims ("free", "$0", "no ads") on any page', async () => {
+ await t.call('PATCH', '/api/v1/profiles/me', { user: alex, body: { page_enabled: true } });
+ for (const [p, u] of [['/', null], ['/alex', viewer], ['/alex/goals', null], ['/dashboard', alex], ['/receipts', viewer]]) {
+ const r = await get(p, u);
+ const visible = r.text.replace(/<style[\s\S]*?<\/style>/g, '').replace(/<script[\s\S]*?<\/script>/g, '');
+ assert.ok(!COPY.test(visible), `${p} has forbidden copy: ${(visible.match(COPY) || [])[0]}`);
+ }
+ });
+
+ await t.close();
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,57 @@
@@ -0,0 +1,57 @@
+#!/usr/bin/env node
+/**
+ * Runs every test in test/ — the files named *.test.js — each in its own process, and fails
+ * if any of them fails. They use in-process stubs of OpenVibe.Network, OpenVibe.Billing,
+ * OpenVibe.Events and OpenVibe.Live, temp databases and random ports; none needs the network or a
+ * running service.
+ *
+ * npm test # everything
+ * npm test -- overlays import # only files whose name contains one of the words
+ */
+'use strict';
+const { spawn } = require('child_process');
+const fs = require('fs');
+const path = require('path');
+
+const filters = process.argv.slice(2).filter((a) => !a.startsWith('-'));
+const files = fs.readdirSync(__dirname)
+ .filter((f) => f.endsWith('.test.js'))
+ .filter((f) => !filters.length || filters.some((w) => f.includes(w)))
+ .sort();
+const TIMEOUT_MS = 60000;
+
+function runOne(file) {
+ return new Promise((resolve) => {
+ const started = Date.now();
+ const child = spawn(process.execPath, [path.join(__dirname, file)], {
+ cwd: path.join(__dirname, '..'),
+ env: { ...process.env, NODE_ENV: 'test' },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+ let output = '';
+ child.stdout.on('data', (c) => { output += c; });
+ child.stderr.on('data', (c) => { output += c; });
+ const timer = setTimeout(() => { output += `\n[run] timed out after ${TIMEOUT_MS}ms`; child.kill('SIGKILL'); }, TIMEOUT_MS);
+ child.on('close', (code, signal) => {
+ clearTimeout(timer);
+ resolve({ file, ok: code === 0, code: code ?? signal, ms: Date.now() - started, output });
+ });
+ });
+}
+
+(async () => {
+ if (!files.length) { console.error('no test files matched'); process.exit(1); }
+ const results = [];
+ for (const f of files) {
+ const r = await runOne(f);
+ results.push(r);
+ console.log(`${r.ok ? '✓' : '✗'} ${r.file.padEnd(28)} ${String(r.ms).padStart(6)}ms`);
+ }
+ const failed = results.filter((r) => !r.ok);
+ for (const r of failed) {
+ console.log(`\n── ${r.file} (exit ${r.code}) ──`);
+ console.log(r.output.split('\n').slice(-60).join('\n'));
+ }
+ console.log(`\n${results.length - failed.length}/${results.length} test files passed`);
+ process.exit(failed.length ? 1 : 0);
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,262 @@
@@ -0,0 +1,262 @@
+'use strict';
+// Payment settles through Billing; delivery is separate. Duplicate Billing events and provider
+// redeliveries yield one logical interaction; reversals flip payment state without erasing delivery.
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/app');
+
+(async () => {
+ const t = await boot();
+ const { domain, billing } = t;
+ const alex = await t.creator('alex');
+ const viewer = t.network.newUser('viewer');
+ billing.fund(viewer.subject, 5000);
+
+ let tipId;
+ await check('a tip from credit settles through a Billing transfer (target = the interaction)', async () => {
+ const r = await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: 'alex', amount: 250, message: 'great stream' } });
+ assert.strictEqual(r.status, 201, r.text);
+ const i = r.json.interaction;
+ tipId = i.id;
+ assert.strictEqual(i.payment.state, 'settled');
+ assert.strictEqual(i.delivery.state, 'queued');
+ assert.match(i.payment.billing_txn_id, /^txn_/);
+ const call = billing.transfers()[0];
+ assert.strictEqual(call.key, `tips:transfer:${i.id}`);
+ assert.deepStrictEqual(call.body.target, { service: 'tips', type: 'interaction', id: i.id });
+ assert.strictEqual(call.body.kind, 'tip');
+ assert.strictEqual(billing.payable.get(alex.subject), 250);
+ });
+
+ await check('the settled event for the same transaction is a no-op; a redelivery is an inbox duplicate', async () => {
+ const ev = billing.events.find((e) => e.payload.metadata && e.payload.metadata.target && e.payload.metadata.target.id === tipId);
+ const a = await t.deliver(ev);
+ assert.strictEqual(a.status, 200);
+ assert.strictEqual(a.json.duplicate, false);
+ assert.strictEqual(a.json.outcome, 'duplicate_transaction');
+ const b = await t.deliver(ev);
+ assert.strictEqual(b.json.duplicate, true);
+ // Same transaction republished under a new event id (a replay): still one interaction.
+ const c = await t.deliver({ ...ev, event_id: billing.envelope('x', {}, 'x').event_id });
+ assert.strictEqual(c.json.duplicate, false);
+ assert.strictEqual(t.db.prepare('SELECT COUNT(*) AS n FROM tip_interactions WHERE billing_txn_id = ?').get(ev.payload.transaction_id).n, 1);
+ assert.strictEqual(t.db.prepare('SELECT COUNT(*) AS n FROM tip_interactions').get().n, 1);
+ });
+
+ await check('a duplicate provider webhook (Billing settles once) → one logical interaction for a donation Tips did not start', async () => {
+ // Billing turned one PowerChat site-routed tip into one transaction; Events may deliver it more than once.
+ const ev = billing.foreignDonation({ from: null, to: alex.subject, amount: 300, provider: 'powerchat', message: 'from PowerChat' });
+ for (let k = 0; k < 3; k++) await t.deliver(k === 2 ? { ...ev, event_id: billing.envelope('x', {}, 'x').event_id } : ev);
+ const rows = t.db.prepare('SELECT * FROM tip_interactions WHERE billing_txn_id = ?').all(ev.payload.transaction_id);
+ assert.strictEqual(rows.length, 1);
+ assert.strictEqual(rows[0].origin, 'billing');
+ assert.strictEqual(rows[0].funding, 'provider');
+ assert.strictEqual(rows[0].payment_state, 'settled');
+ // Tips did not start it, so Tips does not announce it in chat a second time — overlay only.
+ const effects = t.db.prepare('SELECT effect FROM interaction_effects WHERE interaction_id = ?').all(rows[0].id).map((e) => e.effect);
+ assert.deepStrictEqual(effects, ['overlay_alert']);
+ assert.strictEqual(rows[0].delivery_state, 'delivered');
+ });
+
+ await check('delivery runs after settlement; a delivery failure never touches payment state', async () => {
+ const test = t.adapters.test;
+ await domain.effects.drain(); // the first tip delivers normally
+ assert.strictEqual(domain.interactions.get(tipId).delivery_state, 'delivered');
+ test.state.failWith = 'chat is down';
+ test.state.permanent = true;
+ const r = await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: alex.subject, amount: 40 } });
+ const id = r.json.interaction.id;
+ await domain.effects.drain();
+ const i = domain.interactions.get(id);
+ assert.strictEqual(i.payment_state, 'settled');
+ assert.strictEqual(i.delivery_state, 'failed');
+ assert.strictEqual(t.outboxRows('tips.interaction.failed').filter((e) => e.subject.id === id).length, 1);
+ test.state.failWith = null;
+ assert.strictEqual(test.jobs.find((j) => j.interaction.id === tipId).text, 'Viewer tipped 250 Vibes: great stream');
+ });
+
+ await check('a retryable delivery failure stays queued and is retried with backoff', async () => {
+ const test = t.adapters.test;
+ test.state.failWith = 'timeout'; test.state.permanent = false;
+ const r = await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: alex.subject, amount: 10 } });
+ const id = r.json.interaction.id;
+ await domain.effects.drain();
+ const e = t.db.prepare("SELECT * FROM interaction_effects WHERE interaction_id = ? AND effect = 'chat_line'").get(id);
+ assert.strictEqual(e.state, 'queued');
+ assert.strictEqual(e.attempts, 1);
+ test.state.failWith = null;
+ t.clock.offset += 60_000;
+ await domain.effects.drain();
+ assert.strictEqual(domain.interactions.get(id).delivery_state, 'delivered');
+ t.clock.offset = 0;
+ });
+
+ await check('reversal after delivery keeps the delivery record and flips payment state; goals drop by what Billing took back', async () => {
+ const g = await t.call('POST', '/api/v1/goals', { user: alex, body: { title: 'New mic', target_amount: 1000 } });
+ assert.strictEqual(g.status, 201, g.text);
+ const r = await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: 'alex', amount: 400 } });
+ const id = r.json.interaction.id;
+ await domain.effects.drain();
+ assert.strictEqual(domain.interactions.get(id).delivery_state, 'delivered');
+ assert.strictEqual(domain.goals.present(domain.goals.get(g.json.goal.id)).current_amount, 400);
+ const rev = billing.refund(r.json.interaction.payment.billing_txn_id);
+ const d = await t.deliver(rev);
+ assert.strictEqual(d.json.outcome, 'reversed');
+ const i = domain.interactions.get(id);
+ assert.strictEqual(i.payment_state, 'reversed');
+ assert.strictEqual(i.delivery_state, 'delivered');
+ assert.strictEqual(i.reversed_bits, 400);
+ assert.ok(t.db.prepare("SELECT COUNT(*) AS n FROM interaction_effects WHERE interaction_id = ? AND state = 'delivered'").get(id).n >= 2);
+ assert.strictEqual(domain.goals.present(domain.goals.get(g.json.goal.id)).current_amount, 0);
+ // The same reversal again changes nothing.
+ const again = await t.deliver({ ...rev, event_id: billing.envelope('x', {}, 'x').event_id });
+ assert.strictEqual(again.json.outcome, 'duplicate_reversal');
+ assert.strictEqual(domain.interactions.get(id).reversed_bits, 400);
+ await t.call('POST', `/api/v1/goals/${g.json.goal.id}/close`, { user: alex });
+ });
+
+ await check('reversal before delivery cancels the queued effects (tips.interaction.cancelled)', async () => {
+ const r = await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: 'alex', amount: 20 } });
+ const id = r.json.interaction.id;
+ await t.deliver(billing.refund(r.json.interaction.payment.billing_txn_id));
+ const i = domain.interactions.get(id);
+ assert.strictEqual(i.payment_state, 'reversed');
+ assert.strictEqual(i.delivery_state, 'cancelled');
+ await domain.effects.drain();
+ assert.ok(!t.adapters.test.jobs.some((j) => j.interaction.id === id), 'nothing delivered after the reversal');
+ assert.strictEqual(t.outboxRows('tips.interaction.cancelled').filter((e) => e.subject.id === id).length, 1);
+ });
+
+ await check('insufficient credit: payment failed, nothing delivered, a replay of the key answers the same', async () => {
+ const poor = t.network.newUser('poor');
+ const r = await t.call('POST', '/api/v1/checkout', { user: poor, body: { creator: 'alex', amount: 50 }, key: 'poor-key-00001' });
+ assert.strictEqual(r.status, 409, r.text);
+ assert.strictEqual(r.json.code, 'billing.insufficient_funds');
+ assert.strictEqual(r.json.details.interaction.payment.state, 'failed');
+ const again = await t.call('POST', '/api/v1/checkout', { user: poor, body: { creator: 'alex', amount: 50 }, key: 'poor-key-00001' });
+ assert.strictEqual(again.status, 409);
+ assert.strictEqual(t.db.prepare('SELECT COUNT(*) AS n FROM tip_interactions WHERE supporter_subject = ?').get(poor.subject).n, 1);
+ });
+
+ await check('self-tipping is refused by subject', async () => {
+ const r = await t.call('POST', '/api/v1/checkout', { user: alex, body: { creator: 'alex', amount: 50 } });
+ assert.strictEqual(r.status, 422);
+ assert.strictEqual(r.json.code, 'tips.self_dealing');
+ });
+
+ await check('checkout: Billing intent → purchase settles → transfer → settled; goal counts only then', async () => {
+ const g = await t.call('POST', '/api/v1/goals', { user: alex, body: { title: 'Camera', target_amount: 500 } });
+ const buyer = t.network.newUser('buyer');
+ const r = await t.call('POST', '/api/v1/checkout', { user: buyer, body: { creator: 'alex', amount: 300, pay_with: 'checkout', provider: 'stripe' } });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.match(r.json.checkout_url, /^https:\/\/checkout\.stripe\.test\/pi_/);
+ const id = r.json.interaction.id;
+ assert.strictEqual(r.json.interaction.payment.state, 'pending');
+ assert.strictEqual(domain.goals.present(domain.goals.get(g.json.goal.id)).current_amount, 0, 'pending never counts');
+ const intentId = domain.interactions.get(id).billing_intent_id;
+ assert.strictEqual(billing.calls.find((c) => c.url === '/api/v1/intents' && c.body.subject.id === buyer.subject).key, `tips:intent:${id}`);
+ const purchase = billing.settlePurchase(intentId);
+ const d = await t.deliver(purchase);
+ assert.strictEqual(d.json.outcome, 'funded');
+ await new Promise((ok) => setTimeout(ok, 100));
+ await domain.interactions.processDueTransfers();
+ const i = domain.interactions.get(id);
+ assert.strictEqual(i.payment_state, 'settled');
+ assert.strictEqual(i.funding_txn_id, purchase.payload.transaction_id);
+ assert.strictEqual(domain.goals.present(domain.goals.get(g.json.goal.id)).current_amount, 300);
+ // The purchase event again, and the transfer's own event: nothing new.
+ assert.strictEqual((await t.deliver(purchase)).json.duplicate, true);
+ const own = billing.events.find((e) => e.payload.metadata && e.payload.metadata.target && e.payload.metadata.target.id === id);
+ assert.strictEqual((await t.deliver(own)).json.outcome, 'duplicate_transaction');
+ assert.strictEqual(billing.transfers().filter((c) => c.key === `tips:transfer:${id}`).length, 1);
+ });
+
+ await check('PowerChat checkout without a link template returns Billing\'s checkout_ref, no URL invented', async () => {
+ const buyer = t.network.newUser('pcbuyer');
+ const r = await t.call('POST', '/api/v1/checkout', { user: buyer, body: { creator: 'alex', amount: 150, pay_with: 'checkout', provider: 'powerchat' } });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual(r.json.checkout_url, null);
+ assert.match(r.json.checkout_ref, /^pcorder:pi_/);
+ });
+
+ await check('Billing unreachable during a credit tip: pending, retried later with the same key, settles once', async () => {
+ billing.state.down = true;
+ const r = await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: 'alex', amount: 15 } });
+ assert.strictEqual(r.status, 201);
+ const id = r.json.interaction.id;
+ assert.strictEqual(r.json.interaction.payment.state, 'pending');
+ billing.state.down = false;
+ t.clock.offset += 5000;
+ await domain.interactions.processDueTransfers();
+ t.clock.offset = 0;
+ assert.strictEqual(domain.interactions.get(id).payment_state, 'settled');
+ });
+
+ await check('paid messages, TTS and media requests exist only after settlement', async () => {
+ await t.call('PATCH', '/api/v1/profiles/me', { user: alex, body: { tts_enabled: true, media_requests_enabled: true } });
+ const buyer = t.network.newUser('supporter2');
+ const kinds = [
+ ['/api/v1/paid-messages', { amount: 500, message: 'hello chat' }],
+ ['/api/v1/tts-requests', { amount: 150, tts: { text: 'read me please', voice: 'amy' } }],
+ ['/api/v1/media-requests', { amount: 150, media: { url: 'https://www.youtube.com/watch?v=abc' } }],
+ ];
+ const ids = [];
+ for (const [p, body] of kinds) {
+ const r = await t.call('POST', p, { user: buyer, body: { creator: 'alex', pay_with: 'checkout', provider: 'stripe', ...body } });
+ assert.strictEqual(r.status, 201, r.text);
+ ids.push(r.json.interaction.id);
+ }
+ const count = () => t.db.prepare('SELECT (SELECT COUNT(*) FROM paid_messages WHERE interaction_id IN (?, ?, ?)) + (SELECT COUNT(*) FROM paid_media_requests WHERE interaction_id IN (?, ?, ?)) AS n').get(...ids, ...ids).n;
+ assert.strictEqual(count(), 0);
+ for (const id of ids) await t.deliver(billing.settlePurchase(domain.interactions.get(id).billing_intent_id));
+ await new Promise((ok) => setTimeout(ok, 100));
+ await domain.interactions.processDueTransfers();
+ assert.strictEqual(count(), 3);
+ const pm = t.db.prepare('SELECT * FROM paid_messages WHERE interaction_id = ?').get(ids[0]);
+ assert.strictEqual(pm.highlight_seconds, 60);
+ await domain.effects.drain();
+ assert.strictEqual(t.db.prepare('SELECT status FROM paid_messages WHERE interaction_id = ?').get(ids[1]).status, 'delivered');
+ assert.strictEqual(t.db.prepare('SELECT status FROM paid_media_requests WHERE interaction_id = ?').get(ids[2]).status, 'accepted');
+ const tts = t.adapters.test.jobs.find((j) => j.effect === 'tts' && j.interaction.id === ids[1]);
+ assert.deepStrictEqual(tts.tts, { text: 'read me please', voice: 'amy' });
+ });
+
+ await check('an EXTERNAL PowerChat tip is recorded once by provider ref and kept out of the Billing total', async () => {
+ const body = { creator: 'alex', provider: 'powerchat', provider_ref: 'evt-direct-1', amount_cents: 500, supporter_name: 'Fan', message: 'direct' };
+ const a = await t.call('POST', '/api/v1/interactions/external', { cap: ['tips.interaction.record'], body });
+ assert.strictEqual(a.status, 201, a.text);
+ const b = await t.call('POST', '/api/v1/interactions/external', { cap: ['tips.interaction.record'], body });
+ assert.strictEqual(b.status, 200);
+ assert.strictEqual(b.json.duplicate, true);
+ assert.strictEqual(a.json.interaction.id, b.json.interaction.id);
+ const denied = await t.call('POST', '/api/v1/interactions/external', { cap: ['tips.checkout.create'], body: { ...body, provider_ref: 'x2' } });
+ assert.strictEqual(denied.status, 403);
+ const totals = domain.interactions.totals(alex.subject);
+ assert.strictEqual(totals.external, 500);
+ });
+
+ await check('the Billing webhook: bad signatures refused, proxied requests hidden, other sources ignored', async () => {
+ const ev = billing.foreignDonation({ from: null, to: alex.subject, amount: 5 });
+ const bad = await t.deliver(ev, { secret: 'x'.repeat(48) });
+ assert.strictEqual(bad.status, 401);
+ const raw = JSON.stringify({ event: ev, seq: 1 });
+ const { signDelivery } = require('openvibe-sdk/events');
+ const proxied = await fetch(`${t.base}/internal/events`, { method: 'POST', body: raw, headers: { 'Content-Type': 'application/json', 'X-OpenVibe-Signature': signDelivery(raw, 'e'.repeat(48)), 'X-Forwarded-For': '203.0.113.9' } });
+ assert.strictEqual(proxied.status, 404);
+ const forged = await t.deliver({ ...ev, source: 'live' });
+ assert.strictEqual(forged.json.outcome, 'ignored:source');
+ assert.strictEqual(t.db.prepare('SELECT COUNT(*) AS n FROM tip_interactions WHERE billing_txn_id = ?').get(ev.payload.transaction_id).n, 0);
+ });
+
+ await check('events: ready/goal events are relayed to OpenVibe.Events with the tips service token', async () => {
+ const flushed = await t.app.locals.outbox.outbox.flush();
+ assert.ok(flushed.sent > 0, JSON.stringify(flushed));
+ const types = new Set(t.events.published.map((e) => e.event_type));
+ for (const ty of ['tips.interaction.ready', 'tips.goal.updated', 'tips.interaction.failed', 'tips.interaction.cancelled']) assert.ok(types.has(ty), `${ty} relayed`);
+ assert.ok(t.events.published.every((e) => e.source === 'tips'));
+ const grant = t.network.grants.find((g) => g.audience === 'openvibe.events');
+ assert.strictEqual(grant.scope, 'events.event.publish');
+ });
+
+ await t.close();
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });