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

OP OpenVibers committed 23 Sept 2026 · e79904a189 · 69 files, +8317 −55

- Ten charter tables in Tips' own SQLite; payment state mirrored from Billing (keyed by Billing
  transaction id) kept apart from delivery state; overlay tokens scoped, hashed, revocable
- /api/v1 with capability-guarded service tokens and Network user tokens; checkout through
  Billing intents, tips from credit through Billing transfers (ADR-012: Tips never moves money)
- Billing settlement consumer: signed Events webhook + openvibe-sdk inbox; unique Billing txn id
- Effects worker with a delivery adapter seam (live-chat, test, none); delivery failure never
  touches payment state; docs/live-patch.diff adds Live's /internal/tips/deliveries
- SSE overlays with Last-Event-ID replay that never charges or re-counts; simulation mode
- SSR creator page, goals, receipts, dashboard (shared chrome, noindex unless the page is on)
- Events via the openvibe-sdk outbox: tips.interaction.ready|failed|cancelled, tips.goal.updated,
  tips.overlay.delivered|failed
- scripts/import-live.js: Live snapshot → interactions linked to Billing's imported journal,
  holds, exclusions, and a per-creator reconciliation against Billing
- Tests, README, STATUS alpha, .env.example, systemd/nginx, CI, capability and manifest proposals

Changes

ddiff --git a/.env.example b/.env.example
nnew file mode 100644
iindex 0000000..6754e3d
---- /dev/null
++++ b/.env.example
@@@ -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
ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
nnew file mode 100644
iindex 0000000..b31a090
---- /dev/null
++++ b/.github/workflows/ci.yml
@@@ -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
ddiff --git a/.gitignore b/.gitignore
nnew file mode 100644
iindex 0000000..0f84c2e
---- /dev/null
++++ b/.gitignore
@@@ -0,0 +1,12 @@
++node_modules/
++data/
++dist/
++.env
++.env.*
++!.env.example
++*.db
++*.db-wal
++*.db-shm
++*.log
++*.pem
++*.key
ddiff --git a/README.md b/README.md
iindex 7fc8fb3..3956b5f 100644
---- a/README.md
++++ b/README.md
@@@ -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 rule
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.
---
ddiff --git a/STATUS.json b/STATUS.json
iindex 173c4e6..65d7a2b 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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"
}
ddiff --git a/deploy/nginx/openvibe.tips.conf b/deploy/nginx/openvibe.tips.conf
nnew file mode 100644
iindex 0000000..3f9fe42
---- /dev/null
++++ b/deploy/nginx/openvibe.tips.conf
@@@ -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; }
++}
ddiff --git a/deploy/systemd/openvibe-tips.service b/deploy/systemd/openvibe-tips.service
nnew file mode 100644
iindex 0000000..f1f33e1
---- /dev/null
++++ b/deploy/systemd/openvibe-tips.service
@@@ -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
ddiff --git a/docs/capabilities-proposal/live.tips_delivery.write.json b/docs/capabilities-proposal/live.tips_delivery.write.json
nnew file mode 100644
iindex 0000000..0c84fe6
---- /dev/null
++++ b/docs/capabilities-proposal/live.tips_delivery.write.json
@@@ -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"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/tips.checkout.create.json b/docs/capabilities-proposal/tips.checkout.create.json
nnew file mode 100644
iindex 0000000..fa434eb
---- /dev/null
++++ b/docs/capabilities-proposal/tips.checkout.create.json
@@@ -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"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/tips.goal.close.json b/docs/capabilities-proposal/tips.goal.close.json
nnew file mode 100644
iindex 0000000..39e7ba4
---- /dev/null
++++ b/docs/capabilities-proposal/tips.goal.close.json
@@@ -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"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/tips.goal.create.json b/docs/capabilities-proposal/tips.goal.create.json
nnew file mode 100644
iindex 0000000..8b84cc7
---- /dev/null
++++ b/docs/capabilities-proposal/tips.goal.create.json
@@@ -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"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/tips.goal.update.json b/docs/capabilities-proposal/tips.goal.update.json
nnew file mode 100644
iindex 0000000..bb230ff
---- /dev/null
++++ b/docs/capabilities-proposal/tips.goal.update.json
@@@ -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"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/tips.interaction.get.json b/docs/capabilities-proposal/tips.interaction.get.json
nnew file mode 100644
iindex 0000000..9377296
---- /dev/null
++++ b/docs/capabilities-proposal/tips.interaction.get.json
@@@ -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"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/tips.interaction.list.json b/docs/capabilities-proposal/tips.interaction.list.json
nnew file mode 100644
iindex 0000000..774edd1
---- /dev/null
++++ b/docs/capabilities-proposal/tips.interaction.list.json
@@@ -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"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/tips.interaction.record.json b/docs/capabilities-proposal/tips.interaction.record.json
nnew file mode 100644
iindex 0000000..0d0382c
---- /dev/null
++++ b/docs/capabilities-proposal/tips.interaction.record.json
@@@ -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"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/tips.media_request.create.json b/docs/capabilities-proposal/tips.media_request.create.json
nnew file mode 100644
iindex 0000000..dca5156
---- /dev/null
++++ b/docs/capabilities-proposal/tips.media_request.create.json
@@@ -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"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/tips.overlay.config.get.json b/docs/capabilities-proposal/tips.overlay.config.get.json
nnew file mode 100644
iindex 0000000..7ba9310
---- /dev/null
++++ b/docs/capabilities-proposal/tips.overlay.config.get.json
@@@ -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"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/tips.overlay.config.update.json b/docs/capabilities-proposal/tips.overlay.config.update.json
nnew file mode 100644
iindex 0000000..6a85668
---- /dev/null
++++ b/docs/capabilities-proposal/tips.overlay.config.update.json
@@@ -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"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/tips.overlay.token.create.json b/docs/capabilities-proposal/tips.overlay.token.create.json
nnew file mode 100644
iindex 0000000..ce788b8
---- /dev/null
++++ b/docs/capabilities-proposal/tips.overlay.token.create.json
@@@ -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"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/tips.overlay.token.revoke.json b/docs/capabilities-proposal/tips.overlay.token.revoke.json
nnew file mode 100644
iindex 0000000..0d44a7d
---- /dev/null
++++ b/docs/capabilities-proposal/tips.overlay.token.revoke.json
@@@ -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"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/tips.profile.get.json b/docs/capabilities-proposal/tips.profile.get.json
nnew file mode 100644
iindex 0000000..2db984f
---- /dev/null
++++ b/docs/capabilities-proposal/tips.profile.get.json
@@@ -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"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/tips.profile.update.json b/docs/capabilities-proposal/tips.profile.update.json
nnew file mode 100644
iindex 0000000..16e0a75
---- /dev/null
++++ b/docs/capabilities-proposal/tips.profile.update.json
@@@ -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"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/tips.simulation.run.json b/docs/capabilities-proposal/tips.simulation.run.json
nnew file mode 100644
iindex 0000000..4818394
---- /dev/null
++++ b/docs/capabilities-proposal/tips.simulation.run.json
@@@ -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"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/tips.superchat.create.json b/docs/capabilities-proposal/tips.superchat.create.json
nnew file mode 100644
iindex 0000000..bae194c
---- /dev/null
++++ b/docs/capabilities-proposal/tips.superchat.create.json
@@@ -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"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/tips.tts.request.json b/docs/capabilities-proposal/tips.tts.request.json
nnew file mode 100644
iindex 0000000..9e2bfac
---- /dev/null
++++ b/docs/capabilities-proposal/tips.tts.request.json
@@@ -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"
++ ]
++}
ddiff --git a/docs/live-patch.diff b/docs/live-patch.diff
nnew file mode 100644
iindex 0000000..7b6ec7e
---- /dev/null
++++ b/docs/live-patch.diff
@@@ -0,0 +1,232 @@
++diff --git a/server/index.js b/server/index.js
++index 4bba504..63c0969 100644
++--- a/server/index.js
+++++ b/server/index.js
++@@ -564,6 +564,8 @@ app.get('/internal/analytics-summary', (req, res) => {
++ const chatLiveContext = require('./chat/live-context-routes');
++ app.use('/internal/chat-context', chatLiveContext.contextRouter);
++ app.use('/internal/chat-effects', chatLiveContext.effectsRouter);
+++// OpenVibe.Tips announces settled tips in the creator's chat here (service token, live.tips_delivery.write).
+++app.use('/internal/tips', require('./tips/delivery-routes'));
++ app.use('/internal', require('./internal/routes'));
++
++
++diff --git a/server/tips/delivery-routes.js b/server/tips/delivery-routes.js
++new file mode 100644
++index 0000000..ff08b97
++--- /dev/null
+++++ b/server/tips/delivery-routes.js
++@@ -0,0 +1,108 @@
+++'use strict';
+++/**
+++ * OpenVibe.Tips → Live: announce a settled tip in the creator's Live chat (Tips' live-chat
+++ * delivery adapter, roadmap Wave 9). The money already moved in OpenVibe.Billing; this route only
+++ * does what the tail of POST /api/funds/donate does after the balance changed.
+++ *
+++ * POST /internal/tips/deliveries capability live.tips_delivery.write (Network service token,
+++ * audience openvibe.live; loopback-only like every /internal route)
+++ * Idempotency-Key: <interaction id>:<effect> a repeat answers the first result and does nothing
+++ * body { delivery_id, effect, test, creator: { type, id: usr_… }, supporter: { name, subject? },
+++ * interaction: { id, kind, amount, currency, message }, text, tts?: { text, voice },
+++ * media?: { url }, highlight_seconds?, target?: { service: 'live', type: 'stream', id } }
+++ * → 200 { ok: true, ref } 404 creator has no Live channel (permanent) 409 not live (retried)
+++ *
+++ * chat_line | paid_message donation event to the channel room + global, saved as a 'donation'
+++ * chat message (metadata.source 'tips'), the donation alert sound
+++ * tts synthesizeAndBroadcastTTS on the live stream (or the offline channel room)
+++ * media_request into the media queue at cost 0 — Billing charged it, Tips holds the record
+++ */
+++const express = require('express');
+++const db = require('../db/database');
+++const { guard } = require('../net/service-guard');
+++
+++const router = express.Router();
+++const done = new Map(); // Idempotency-Key → response (Tips retries within minutes; bounded)
+++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+++
+++function channelUserId(subject) {
+++ if (!SUBJECT_RE.test(String(subject || ''))) return null;
+++ const row = db.getDb().prepare("SELECT user_id FROM linked_accounts WHERE service = 'network' AND subject_id = ? ORDER BY id DESC LIMIT 1").get(subject);
+++ return row ? row.user_id : null;
+++}
+++
+++function streamIdFor(target, userId) {
+++ if (target && target.service === 'live' && target.type === 'stream') {
+++ const s = db.getStreamById(Number(target.id));
+++ if (s && s.user_id === userId && s.is_live) return s.id;
+++ }
+++ const live = db.getStreamByUserId(userId);
+++ return live ? live.id : null;
+++}
+++
+++function remember(key, out) {
+++ done.set(key, out);
+++ if (done.size > 5000) done.delete(done.keys().next().value);
+++ return out;
+++}
+++
+++router.post('/deliveries', guard('live.tips_delivery.write'), express.json({ limit: '32kb' }), async (req, res) => {
+++ const key = String(req.get('idempotency-key') || '');
+++ if (!/^[A-Za-z0-9_:.-]{8,200}$/.test(key)) return res.status(400).json({ error: 'Idempotency-Key required' });
+++ if (done.has(key)) return res.json(done.get(key));
+++ const b = req.body || {};
+++ const i = b.interaction || {};
+++ const userId = channelUserId(b.creator && b.creator.id);
+++ if (!userId) return res.status(404).json({ error: 'this creator has no Live channel' });
+++ const chatServer = require('../chat/chat-server');
+++ const streamId = streamIdFor(b.target, userId);
+++ const name = String((b.supporter && b.supporter.name) || 'Someone').slice(0, 80);
+++ const amount = Math.max(0, Math.round(Number(i.amount) || 0));
+++ const message = String(i.message || '').slice(0, 500);
+++ try {
+++ if (b.effect === 'chat_line' || b.effect === 'paid_message') {
+++ const event = {
+++ type: 'donation', username: name, user_id: null, avatar_url: null, amount, message, timestamp: new Date().toISOString(),
+++ source: 'tips', paid_message: b.effect === 'paid_message', highlight_seconds: Number(b.highlight_seconds) || 0,
+++ };
+++ chatServer.broadcastToChannelRoom(userId, streamId, event);
+++ try { chatServer.broadcastGlobal({ ...event, global: true, channel_user_id: userId }); } catch { /* non-critical */ }
+++ const saved = db.saveChatMessage({
+++ stream_id: streamId, channel_user_id: userId, user_id: null, username: name,
+++ message: String(b.text || `${name} tipped ${amount.toLocaleString()} Vibes${message ? ': ' + message : ''}`).slice(0, 1000),
+++ message_type: 'donation',
+++ metadata: { kind: 'donation', amount, message, username: name, source: 'tips', interaction_id: i.id || null, paid_message: event.paid_message, highlight_seconds: event.highlight_seconds, test: !!b.test },
+++ });
+++ require('../monetization/alerts').playAlertSound(chatServer, userId, streamId, 'donation');
+++ return res.json(remember(key, { ok: true, ref: { chat_message_id: saved && saved.lastInsertRowid != null ? Number(saved.lastInsertRowid) : null } }));
+++ }
+++ if (b.effect === 'tts') {
+++ const text = String((b.tts && b.tts.text) || '').slice(0, 1200);
+++ if (!text) return res.status(422).json({ error: 'no text' });
+++ await chatServer.synthesizeAndBroadcastTTS(streamId, name, text, null, 'tips', `tips:${i.id}`, userId, `tips-${i.id}`);
+++ return res.json(remember(key, { ok: true, ref: { stream_id: streamId } }));
+++ }
+++ if (b.effect === 'media_request') {
+++ const mq = require('../media/media-queue');
+++ const settings = mq.getSettings(userId);
+++ const normalized = await mq.normalizeInput(String((b.media && b.media.url) || ''), settings);
+++ const max = Number(settings.max_duration_seconds) || 600;
+++ if (Number.isFinite(normalized.duration_seconds) && normalized.duration_seconds > max) return res.status(422).json({ error: `longer than ${max} seconds` });
+++ const r = db.createMediaRequest({
+++ streamer_id: userId, stream_id: streamId, user_id: null, username: name, input: String(b.media.url),
+++ canonical_url: normalized.canonical_url, embed_url: normalized.embed_url, provider: normalized.provider, title: normalized.title,
+++ thumbnail_url: normalized.thumbnail_url, duration_seconds: normalized.duration_seconds,
+++ // Paid through OpenVibe.Billing and recorded by OpenVibe.Tips: Live charges nothing here.
+++ cost: 0, currency: 'free', queue_position: db.getMediaRequestMaxQueuePosition(userId) + 1,
+++ });
+++ mq.broadcastQueueUpdate(userId);
+++ return res.json(remember(key, { ok: true, ref: { media_request_id: Number(r.lastInsertRowid) } }));
+++ }
+++ return res.status(422).json({ error: `unknown effect ${b.effect}` });
+++ } catch (err) {
+++ console.warn('[Tips delivery]', b.effect, err.message);
+++ return res.status(422).json({ error: err.message });
+++ }
+++});
+++
+++module.exports = router;
++diff --git a/test/tips-delivery.test.js b/test/tips-delivery.test.js
++new file mode 100644
++index 0000000..2c3730a
++--- /dev/null
+++++ b/test/tips-delivery.test.js
++@@ -0,0 +1,99 @@
+++'use strict';
+++
+++// OpenVibe.Tips → Live (roadmap Wave 9): POST /internal/tips/deliveries announces a settled tip in the
+++// creator's chat — service token with live.tips_delivery.write, idempotent per delivery id, the
+++// creator found by Network subject, no Live balance touched.
+++
+++const assert = require('assert');
+++const fs = require('fs');
+++const os = require('os');
+++const path = require('path');
+++const http = require('http');
+++const crypto = require('crypto');
+++const express = require('express');
+++const { serviceAuth } = require('openvibe-contracts');
+++
+++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-tips-delivery-'));
+++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
+++fs.writeFileSync(path.join(tmp, 'network.pem'), keys.publicKey);
+++fs.mkdirSync(path.join(tmp, 'sounds'));
+++process.env.DB_PATH = path.join(tmp, 'live.db');
+++process.env.OV_NETWORK_PUBLIC_KEY = path.join(tmp, 'network.pem');
+++process.env.OV_NETWORK_URL = 'https://openvibe.network';
+++process.env.SOUNDS_PATH = path.join(tmp, 'sounds');
+++console.log = () => {};
+++console.warn = () => {};
+++
+++const ISS = 'https://openvibe.network';
+++const SUBJECT = 'usr_01J0000000000000000000000Z';
+++const now = () => Math.floor(Date.now() / 1000);
+++let jti = 0;
+++const token = (cap, aud = 'openvibe.live') => serviceAuth.signServiceToken({ iss: ISS, sub: 'svc:tips', actor_type: 'service', aud: [aud], cap, iat: now(), exp: now() + 300, jti: `tok_tips_${++jti}` }, keys.privateKey);
+++
+++(async () => {
+++ const db = require('../server/db/database');
+++ db.initDb();
+++ const d = db.getDb();
+++ d.prepare("INSERT INTO users (id, username, display_name, password_hash, openvibe_bucks_balance, openvibe_bucks_cashout_balance) VALUES (501, 'alex', 'Alex', 'x', 0, 0)").run();
+++ d.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (501, 'network', '77', ?)").run(SUBJECT);
+++
+++ const chatServer = require('../server/chat/chat-server');
+++ const broadcasts = [];
+++ chatServer.broadcastToChannelRoom = (uid, sid, ev) => broadcasts.push({ uid, sid, ev });
+++ chatServer.broadcastGlobal = () => {};
+++ const tts = [];
+++ chatServer.synthesizeAndBroadcastTTS = async (...a) => { tts.push(a); };
+++
+++ const app = express();
+++ app.use('/internal/tips', require('../server/tips/delivery-routes'));
+++ const server = await new Promise((r) => { const s = http.createServer(app); s.listen(0, '127.0.0.1', () => r(s)); });
+++ const base = `http://127.0.0.1:${server.address().port}`;
+++ const post = (body, { cap = ['live.tips_delivery.write'], key = 'tint_1:chat_line', headers = {} } = {}) => fetch(`${base}/internal/tips/deliveries`, {
+++ method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token(cap)}`, 'Idempotency-Key': key, ...headers }, body: JSON.stringify(body),
+++ }).then(async (r) => ({ status: r.status, json: await r.json().catch(() => null) }));
+++
+++ const job = {
+++ delivery_id: 'tint_1:chat_line', effect: 'chat_line', test: false, creator: { type: 'user', id: SUBJECT },
+++ supporter: { name: 'Viewer' }, interaction: { id: 'tint_1', kind: 'tip', amount: 250, currency: 'vibes-bits', message: 'gg' },
+++ text: 'Viewer tipped 250 Vibes: gg',
+++ };
+++
+++ assert.strictEqual((await post(job, { cap: ['live.chat_effects.write'] })).status, 403, 'needs live.tips_delivery.write');
+++ assert.strictEqual((await post(job, { headers: { 'X-Forwarded-For': '1.2.3.4' } })).status, 403, 'loopback only');
+++
+++ const a = await post(job);
+++ assert.strictEqual(a.status, 200);
+++ assert.ok(a.json.ref.chat_message_id > 0);
+++ assert.strictEqual(broadcasts.length, 1);
+++ assert.strictEqual(broadcasts[0].uid, 501);
+++ assert.strictEqual(broadcasts[0].ev.type, 'donation');
+++ assert.strictEqual(broadcasts[0].ev.amount, 250);
+++ const saved = d.prepare("SELECT * FROM chat_messages WHERE message_type = 'donation'").get();
+++ assert.strictEqual(saved.message, 'Viewer tipped 250 Vibes: gg');
+++ assert.strictEqual(JSON.parse(saved.metadata).source, 'tips');
+++
+++ // A retry of the same delivery changes nothing.
+++ const b = await post(job);
+++ assert.deepStrictEqual(b.json, a.json);
+++ assert.strictEqual(broadcasts.length, 1);
+++ assert.strictEqual(d.prepare("SELECT COUNT(*) AS n FROM chat_messages WHERE message_type = 'donation'").get().n, 1);
+++
+++ // TTS on the (offline) channel room.
+++ const t = await post({ ...job, effect: 'tts', tts: { text: 'read me', voice: 'gary' } }, { key: 'tint_1:tts' });
+++ assert.strictEqual(t.status, 200);
+++ assert.strictEqual(tts[0][2], 'read me');
+++ assert.strictEqual(tts[0][6], 501);
+++
+++ // Unknown creator: permanent refusal.
+++ const u = await post({ ...job, creator: { type: 'user', id: 'usr_01J0000000000000000000000Y' } }, { key: 'tint_2:chat_line' });
+++ assert.strictEqual(u.status, 404);
+++
+++ // No Live balance moved.
+++ const user = d.prepare('SELECT openvibe_bucks_balance, openvibe_bucks_cashout_balance FROM users WHERE id = 501').get();
+++ assert.deepStrictEqual(user, { openvibe_bucks_balance: 0, openvibe_bucks_cashout_balance: 0 });
+++ assert.strictEqual(d.prepare("SELECT COUNT(*) AS n FROM transactions").get().n, 0);
+++
+++ server.close();
+++ process.stdout.write('tips-delivery: all passed\n');
+++ process.exit(0);
+++})().catch((e) => { process.stderr.write(`${e.stack}\n`); process.exit(1); });
ddiff --git a/docs/service-manifest-proposal.json b/docs/service-manifest-proposal.json
nnew file mode 100644
iindex 0000000..f047c1d
---- /dev/null
++++ b/docs/service-manifest-proposal.json
@@@ -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."
++}
ddiff --git a/package-lock.json b/package-lock.json
nnew file mode 100644
iindex 0000000..8fe5b25
---- /dev/null
++++ b/package-lock.json
@@@ -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"
++ }
++ }
++}
ddiff --git a/package.json b/package.json
nnew file mode 100644
iindex 0000000..56792b1
---- /dev/null
++++ b/package.json
@@@ -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"
++ }
++}
ddiff --git a/public/css/overlay.css b/public/css/overlay.css
nnew file mode 100644
iindex 0000000..971b96b
---- /dev/null
++++ b/public/css/overlay.css
@@@ -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; }
ddiff --git a/public/css/tips.css b/public/css/tips.css
nnew file mode 100644
iindex 0000000..d0a5b67
---- /dev/null
++++ b/public/css/tips.css
@@@ -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; } }
ddiff --git a/public/js/overlay.js b/public/js/overlay.js
nnew file mode 100644
iindex 0000000..4a64455
---- /dev/null
++++ b/public/js/overlay.js
@@@ -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 = ''; });
++})();
ddiff --git a/public/js/tips.js b/public/js/tips.js
nnew file mode 100644
iindex 0000000..74a7bf0
---- /dev/null
++++ b/public/js/tips.js
@@@ -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();
++ });
++})();
ddiff --git a/scripts/import-live.js b/scripts/import-live.js
nnew file mode 100644
iindex 0000000..09a3e55
---- /dev/null
++++ b/scripts/import-live.js
@@@ -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); });
ddiff --git a/scripts/subscribe.js b/scripts/subscribe.js
nnew file mode 100644
iindex 0000000..9bb61c2
---- /dev/null
++++ b/scripts/subscribe.js
@@@ -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); });
ddiff --git a/server/api/auth.js b/server/api/auth.js
nnew file mode 100644
iindex 0000000..cd89335
---- /dev/null
++++ b/server/api/auth.js
@@@ -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 };
ddiff --git a/server/api/idempotency.js b/server/api/idempotency.js
nnew file mode 100644
iindex 0000000..91a1ca7
---- /dev/null
++++ b/server/api/idempotency.js
@@@ -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 };
ddiff --git a/server/api/v1.js b/server/api/v1.js
nnew file mode 100644
iindex 0000000..9f2bc34
---- /dev/null
++++ b/server/api/v1.js
@@@ -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 };
ddiff --git a/server/app.js b/server/app.js
nnew file mode 100644
iindex 0000000..d7825ca
---- /dev/null
++++ b/server/app.js
@@@ -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 };
ddiff --git a/server/billing-client.js b/server/billing-client.js
nnew file mode 100644
iindex 0000000..3c4717a
---- /dev/null
++++ b/server/billing-client.js
@@@ -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 };
ddiff --git a/server/config.js b/server/config.js
nnew file mode 100644
iindex 0000000..613b00a
---- /dev/null
++++ b/server/config.js
@@@ -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 };
ddiff --git a/server/db.js b/server/db.js
nnew file mode 100644
iindex 0000000..9c509ff
---- /dev/null
++++ b/server/db.js
@@@ -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 };
ddiff --git a/server/delivery/index.js b/server/delivery/index.js
nnew file mode 100644
iindex 0000000..e9d585e
---- /dev/null
++++ b/server/delivery/index.js
@@@ -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 };
ddiff --git a/server/delivery/live-chat.js b/server/delivery/live-chat.js
nnew file mode 100644
iindex 0000000..dc0d642
---- /dev/null
++++ b/server/delivery/live-chat.js
@@@ -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 };
ddiff --git a/server/domain/effects.js b/server/domain/effects.js
nnew file mode 100644
iindex 0000000..a91e7b2
---- /dev/null
++++ b/server/domain/effects.js
@@@ -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 };
ddiff --git a/server/domain/goals.js b/server/domain/goals.js
nnew file mode 100644
iindex 0000000..15e2a09
---- /dev/null
++++ b/server/domain/goals.js
@@@ -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 };
ddiff --git a/server/domain/index.js b/server/domain/index.js
nnew file mode 100644
iindex 0000000..4b12a3c
---- /dev/null
++++ b/server/domain/index.js
@@@ -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 };
ddiff --git a/server/domain/interactions.js b/server/domain/interactions.js
nnew file mode 100644
iindex 0000000..78fc687
---- /dev/null
++++ b/server/domain/interactions.js
@@@ -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 };
ddiff --git a/server/domain/overlays.js b/server/domain/overlays.js
nnew file mode 100644
iindex 0000000..678dd06
---- /dev/null
++++ b/server/domain/overlays.js
@@@ -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 };
ddiff --git a/server/domain/profiles.js b/server/domain/profiles.js
nnew file mode 100644
iindex 0000000..7fc6d94
---- /dev/null
++++ b/server/domain/profiles.js
@@@ -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 };
ddiff --git a/server/events/consumer.js b/server/events/consumer.js
nnew file mode 100644
iindex 0000000..dc721b1
---- /dev/null
++++ b/server/events/consumer.js
@@@ -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 };
ddiff --git a/server/events/outbox.js b/server/events/outbox.js
nnew file mode 100644
iindex 0000000..932346a
---- /dev/null
++++ b/server/events/outbox.js
@@@ -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 };
ddiff --git a/server/importer/live.js b/server/importer/live.js
nnew file mode 100644
iindex 0000000..e37e646
---- /dev/null
++++ b/server/importer/live.js
@@@ -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 };
ddiff --git a/server/index.js b/server/index.js
nnew file mode 100644
iindex 0000000..db97ea4
---- /dev/null
++++ b/server/index.js
@@@ -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'));
ddiff --git a/server/network.js b/server/network.js
nnew file mode 100644
iindex 0000000..6ce4cc2
---- /dev/null
++++ b/server/network.js
@@@ -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 };
ddiff --git a/server/util.js b/server/util.js
nnew file mode 100644
iindex 0000000..d05dcc2
---- /dev/null
++++ b/server/util.js
@@@ -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 };
ddiff --git a/server/web/layout.js b/server/web/layout.js
nnew file mode 100644
iindex 0000000..177819b
---- /dev/null
++++ b/server/web/layout.js
@@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 };
ddiff --git a/server/web/pages.js b/server/web/pages.js
nnew file mode 100644
iindex 0000000..c6593ac
---- /dev/null
++++ b/server/web/pages.js
@@@ -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 };
ddiff --git a/server/web/routes.js b/server/web/routes.js
nnew file mode 100644
iindex 0000000..d1c6843
---- /dev/null
++++ b/server/web/routes.js
@@@ -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 };
ddiff --git a/server/web/session.js b/server/web/session.js
nnew file mode 100644
iindex 0000000..db52413
---- /dev/null
++++ b/server/web/session.js
@@@ -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 };
ddiff --git a/test/api.test.js b/test/api.test.js
nnew file mode 100644
iindex 0000000..a0b5923
---- /dev/null
++++ b/test/api.test.js
@@@ -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); });
ddiff --git a/test/delivery.test.js b/test/delivery.test.js
nnew file mode 100644
iindex 0000000..2aae6d0
---- /dev/null
++++ b/test/delivery.test.js
@@@ -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); });
ddiff --git a/test/helpers/app.js b/test/helpers/app.js
nnew file mode 100644
iindex 0000000..cf52638
---- /dev/null
++++ b/test/helpers/app.js
@@@ -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 };
ddiff --git a/test/helpers/stubs.js b/test/helpers/stubs.js
nnew file mode 100644
iindex 0000000..36ffa4c
---- /dev/null
++++ b/test/helpers/stubs.js
@@@ -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 };
ddiff --git a/test/import.test.js b/test/import.test.js
nnew file mode 100644
iindex 0000000..b7edd84
---- /dev/null
++++ b/test/import.test.js
@@@ -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); });
ddiff --git a/test/overlays.test.js b/test/overlays.test.js
nnew file mode 100644
iindex 0000000..46b6d16
---- /dev/null
++++ b/test/overlays.test.js
@@@ -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); });
ddiff --git a/test/pages.test.js b/test/pages.test.js
nnew file mode 100644
iindex 0000000..41e14b2
---- /dev/null
++++ b/test/pages.test.js
@@@ -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); });
ddiff --git a/test/run.js b/test/run.js
nnew file mode 100644
iindex 0000000..938da0f
---- /dev/null
++++ b/test/run.js
@@@ -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);
++})();
ddiff --git a/test/settlement.test.js b/test/settlement.test.js
nnew file mode 100644
iindex 0000000..2a9250f
---- /dev/null
++++ b/test/settlement.test.js
@@@ -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); });