openvibers/openvibe.tips Commits
Tips keeps no Live-import or SQLite code (the import is long done; no compatibility kept): server/importer/live.js, scripts/import-live.js and migrate-to-postgres.js, the import and SQLite-move tests and fixture, the better-sqlite3 dependency, sqlitePath/TIPS_DB_PATH, and the legacy_source columns and migration_maps table (dropped by the new migration 0002); goal:<id> takes Tips goal ids only. The earlier await fixes (13 files: async store calls inside transactions and routes) ship with it. Reviewed independently; its findings (untracked migration, lockfile, two stale README lines) are resolved here. npm test 16/16 test files passed.
Changes
@@@ -17,16 +17,12 @@ TRUST_PROXY=1# an embedded PGlite database in data/pglite (one process). Tests need neither (PGlite, or the containers# of node_modules/openvibe-sdk/scripts/test-services.sh).DATABASE_URL=--# DATABASE_DIRECT_URL: the owner role on a direct connection, for the migrations at boot and--# scripts/migrate-to-postgres.js.++# DATABASE_DIRECT_URL: the owner role on a direct connection, for the migrations at boot.DATABASE_DIRECT_URL=# VALKEY_URL: shared, never authoritative state (per-actor limits, overlay fan-out and stream slots).# Unset: kept in each process, right for one process only.VALKEY_URL=VALKEY_PREFIX=ov:tips:--# The SQLite file of the release before PostgreSQL: read (never written) only by--# scripts/migrate-to-postgres.js. 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@@@ -3,10 +3,9 @@> Creator support: tips, goals, paid messages, TTS and media requests, overlays.**Status:** alpha — runtime built and tested against stubs (roadmap Wave 9). Deployed internally, not--launched: running on the host since 2026-09-23 on `127.0.0.1:4610` only, holding the Live import and no++launched: running on the host since 2026-09-23 on `127.0.0.1:4610` only, with nocreator profile yet, with no public route. OpenVibe.Live remains where tips happen until the Billing cutover (see *What waits*).--Data: PostgreSQL through PgBouncer, and Valkey for shared state ([ADR-035](https://github.com/OpenVibers/OpenVibe.Contracts/blob/main/docs/adr/ADR-035-postgresql-and-valkey.md));--production runs the SQLite release until the switch in *Deploy*.++Data: PostgreSQL through PgBouncer, and Valkey for shared state ([ADR-035](https://github.com/OpenVibers/OpenVibe.Contracts/blob/main/docs/adr/ADR-035-postgresql-and-valkey.md));**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.@@@ -22,7 +21,7 @@ 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`++ `paid_media_requests`, `overlay_configs`, `overlay_deliveries`, `interaction_effects`(its own PostgreSQL database `ov_tips`, schema in [migrations/](migrations/), access in `server/db.js`) —interaction state and **references** to Billing transaction ids, never a mutable cash balance- revocable scoped overlay tokens (`overlay_tokens`, hashed at rest; never creator cookies)@@@ -45,7 +44,7 @@ from *interaction delivered*.PowerChat, once Billing receives the PowerChat webhook)- **OpenVibe.Events** — Billing's events in (signed webhook + openvibe-sdk inbox), Tips' events out(openvibe-sdk transactional outbox)--- **OpenVibe.Network** — SSO for people, service tokens, JWKS, identity resolve (importer)++- **OpenVibe.Network** — SSO for people, service tokens, JWKS, identity resolve- **OpenVibe.Live** — chat delivery through `/internal/tips/deliveries` (deployed in Live since `f11f809`; [docs/live-patch.diff](docs/live-patch.diff) is the original patch) until OpenVibe.Chat exposes paid messages; overlay consumer- **OpenVibe.Shared** v1.25.0 (app icon, SSR footer, noscript nav, release manifest, legal pages) andthe Network's `navbar.js`; **openvibe-contracts** v0.76.0 and **openvibe-sdk** v0.18.0 (service tokens,@@@ -65,7 +64,6 @@ Called elsewhere, as the service principal `tips`:|---|---|---|| OpenVibe.Billing | `billing.intent.create`, `billing.transfer.create` | checkout, and a tip from credit || OpenVibe.Events | `events.event.publish`; `events.subscription.manage` once (`npm run subscribe`) | the outbox relay; the `billing.transaction.*` and `billing.receipt.*` subscriptions |--| OpenVibe.Network | `identity.subject.resolve` | the Live importer's creator mapping || OpenVibe.Live | `live.tips_delivery.write` (only with `TIPS_CHAT_ADAPTER=live-chat`) | chat lines and alerts through `/internal/tips/deliveries` |## Run it@@@ -79,8 +77,6 @@ eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)" # PostgreSQL 1npm test # … now also test/integration.test.js on the containersnpm run test:pg # every test file through PgBouncer and Valkeynpm run subscribe # create the billing.transaction.* and billing.receipt.* subscriptions in OpenVibe.Events--node scripts/import-live.js --live-db <live snapshot> --billing-db <billing snapshot> [--dry-run] [--json]--npm run migrate-to-postgres -- [--sqlite <copy>] [--pglite] [--json] # the one-time SQLite import (Deploy)```Production (deployed, loopback only): `/opt/openvibe.tips`, env `/etc/openvibe/tips.env`, unit@@@ -140,8 +136,8 @@ minus what Billing took back) — the number that must equal Billing's books —(settings changes and per-creator limits), the interaction row, its goals by id, the creator's overlaystream (an advisory lock, so a creator's deliveries commit in seq order), then the rest. Serializationfailures and deadlocks are retried by the data layer.--- Text is made storable on the way in: PostgreSQL keeps no NUL character, and jsonb no unpaired surrogate-- (SQLite kept both), so they are dropped or replaced (U+FFFD) in what people and Billing's events send.++- Text is made storable on the way in: PostgreSQL keeps no NUL character, and jsonb no unpaired surrogate,++ so they are dropped or replaced (U+FFFD) in what people and Billing's events send.- **Background work runs in every process** and claims its rows: due effects and due transfers with alease (`FOR UPDATE SKIP LOCKED`, the lease in `next_attempt_at` / `next_transfer_at`), the overlaywindow sweep with `SKIP LOCKED`, the outbox relay by the SDK's lease; prunes are idempotent deletes.@@@ -269,8 +265,8 @@ Consumed:`donor_name` — null when `anonymous` — `message`, `provider`, `provider_event_id`, `app_purpose`/`app_ref`,`test`) is sent by Billing only once it is the money authority (`BILLING_AUTHORITY=billing` inbilling.env); before that Live's own PowerChat webhook announces those tips, so nothing is announced--twice. A `goal:<id>` in `app_purpose`/`app_ref` picks the goal: a Tips goal id, or a Live `donation_goals`--id mapped by the Live import; otherwise Live's rule (the only active goal).++twice. A `goal:<id>` in `app_purpose`/`app_ref` picks the goal (a Tips goal id); otherwise the creator's++only active goal.## Overlays@@@ -358,22 +354,6 @@ Never limited: `/api/health`, `/api/ready`, `/release.json`, `/metrics`, the ove`TIPS_OVERLAY_MAX_STREAMS`) and the signed Events deliveries at `/internal/events`. `test/actor-limits.test.js`.With `VALKEY_URL` every process counts one caller together (`test/integration.test.js`).--## 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")| Criterion | Evidence |@@@ -381,12 +361,11 @@ a copy of Billing's database for the links and totals:| 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) |++| creator totals reconcile exactly to Billing | `test/api.test.js` (totals = the stub Billing payable) || simulation never counted; token revocation immediate; goals from settled only; reversal keeps the delivery record | `test/overlays.test.js`, `test/api.test.js`, `test/settlement.test.js` |--| an EXTERNAL PowerChat tip Billing announced is celebrated once: Live chat line, overlay, goal; never Billing money | `test/external.test.js` (redelivery and republish, same key as `POST /interactions/external`, Tips and Live goal ids, anonymous, test receipts, malformed payloads) |++| an EXTERNAL PowerChat tip Billing announced is celebrated once: Live chat line, overlay, goal; never Billing money | `test/external.test.js` (redelivery and republish, same key as `POST /interactions/external`, Tips goal ids, anonymous, test receipts, malformed payloads) || paid messages are filtered before they are shown or read; the creator and their moderators hide and show them without touching the money | `test/moderation.test.js` (mask, hold and release, invisible/full-width evasion, TTS text, invitation links, hide cancels queued chat/TTS and retracts the overlay live/replay/state, restore, pending payments, who may moderate, event payloads) || the suite passes on PostgreSQL behind PgBouncer in transaction mode; one settlement or reversal raced across two processes counts once; the unpaid-checkout limit holds under concurrency; leased work runs once; flushing Valkey changes no money answer (ADR-007 2026-09-24, ADR-035) | `npm run test:pg`; `test/integration.test.js` (two app instances on one database and one Valkey) |--| the SQLite data moves whole: every table and column, verified by counts and checksums | `test/sqlite-import.test.js`, `test/migrations.test.js` (no column left behind) || a supporter's privacy holds everywhere; the creator chooses what public pages show; export and erasure keep the books reconciled | `test/privacy.test.js` (anonymous / hidden amount / private message across API, overlays, chat jobs, pages and events; goal and supporters page settings; export; erasure scrubs rows, overlay payloads, stored answers and unsent events, emits `tips.interaction.erased`, totals still equal Billing) |## What waits@@@ -418,23 +397,6 @@ Rollback: ovhost puts the previous sha back by itself when `/api/ready` does notrestart; afterwards `sudo ovhost rollback tips --to <sha>`. Migrations are expand-only until a`contract` migration, which waits out the 7-day N-1 window.--**The switch from SQLite to PostgreSQL** (once; the procedure is the SDK's--[migrating-to-postgresql.md](https://github.com/OpenVibers/OpenVibe.SDK/blob/main/docs/migrating-to-postgresql.md), section 6):--1. `sudo /opt/openvibe.host/roles/data/add-service.sh tips` — database `ov_tips`, its roles and Valkey-- user; it writes `DATABASE_URL`, `DATABASE_DIRECT_URL`, `VALKEY_URL`, `VALKEY_PREFIX` into-- `/etc/openvibe/tips.env`.--2. Rehearse on a copy of `/var/lib/openvibe-tips/tips.db` (`sqlite3 … ".backup …"` as the service user):-- `npm run migrate-to-postgres -- --sqlite <copy> --pglite` must end OK.--3. Merge the PostgreSQL release; do not deploy it yet.--4. `sudo systemctl stop openvibe-tips` (the write freeze), then back up `tips.db`.--5. From the new release's directory, as the service user with the service's environment:-- `node scripts/migrate-to-postgres.js` — it migrates as the owner, imports every table with-- `truncate`, verifies counts and checksums, changes nothing in the SQLite file, and exits 1 unless OK.--6. `sudo ovhost deploy tips`; check `/api/ready` shows `db` `store: postgresql` and `valkey` ok, the-- dashboard, a receipt and an overlay, and a flat error rate.--7. Keep `tips.db` read-only for 7 days (the rollback: the previous release reads it, losing the writes-- made since the switch unless they are replayed), then `ovhost archive push` it and delete it.--## Launch ruleThis repository does not make the product real, and the domain keeps its placeholder page on@@@ -449,8 +411,7 @@ exist here (plan §12.12):principal is provisioned on the host;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 (Billing is in shadow and has sent no events; production holds the Live import and no creator-- profile);++ Events (Billing is in shadow and has sent no events; production has no creator profile yet);5. capability and event registration against OpenVibe.Contracts — ✔ v0.15.0, payload schemas in v0.30.2 and v0.32.0 (v0.76.0 pinned);6. a migration/seed strategy ✔, a written threat review ✔ ([docs/threat-review.md](docs/threat-review.md),internal; an independent review is still due), and sitemap/robots ✔ (no feed);@@@ -8,9 +8,9 @@"authoritative": false,"deployed": true,"deployment": "Deployed internally, not launched: deployed with `sudo ovhost deploy tips`, /opt/openvibe.tips, unit openvibe-tips, 127.0.0.1:4610 loopback only; https://openvibe.tips still serves the OpenVibe.Sites placeholder. Principal tips; Events subscriptions billing.transaction.* and billing.receipt.* -> /internal/events (secret rotated 2026-09-24); TIPS_CHAT_ADAPTER is empty (no chat side effects).",-- "database": "PostgreSQL 18 through PgBouncer (transaction mode) as the system of record, Valkey 9 for shared state (per-actor limits, overlay fan-out, overlay stream slots), ADR-035: database ov_tips from OpenVibe.Host's data role, schema in migrations/ (0001_initial, expand), async on openvibe-sdk/db v0.18.0, money paths serializable with row locks, PostgreSQL outbox and inbox. Switched in production on 2026-09-28: scripts/migrate-to-postgres.js imported /var/lib/openvibe-tips/tips.db verified by counts and checksums (rehearsed on a copy of the production file first: report.ok, 139 interactions, 143 migration maps, 4 goals); the SQLite file stays read-only for the 7-day N-1 window (until 2026-10-05), then is archived to B2.",++ "database": "PostgreSQL 18 through PgBouncer (transaction mode) as the system of record, Valkey 9 for shared state (per-actor limits, overlay fan-out, overlay stream slots), ADR-035: database ov_tips from OpenVibe.Host's data role, schema in migrations/ (0001_initial expand, 0002_drop_legacy_import contract), async on openvibe-sdk/db v0.18.0, money paths serializable with row locks, PostgreSQL outbox and inbox.","contracts": "openvibe-contracts v0.76.0",-- "notes": "Runtime, API, SSR pages, overlays, the Billing settlement consumer, events outbox, Live import and reconciliation are built, tested and deployed, together with /metrics (loopback only), truthful /api/ready (Billing and Events checks), supporter privacy (anonymous, hidden amount, private message), goal and supporter-page settings, export and erasure (tips.interaction.erased), moderation (word filter, hide/show, invited moderators, tips.interaction.moderated) and per-actor limits. Capabilities (18 tips.*) and payload schemas are in openvibe-contracts. On 2026-09-24 production held the Live import (3 import runs, the last 2026-09-23 18:31 UTC: 139 interactions, 4 goals, 143 migration maps); 0 creator profiles and 0 overlay tokens (nobody had set up the product). No billing.* event had arrived: Billing runs in shadow. A written internal threat review (docs/threat-review.md) with its fixes and test/security.test.js. Not done: an independent security review; the openvibe.tips launch. Live stays the place tips happen until the Billing cutover (blocked on the owner re-pointing the PowerChat webhook) and the launch rule in README.md holds.",++ "notes": "Runtime, API, SSR pages, overlays, the Billing settlement consumer and the events outbox are built, tested and deployed, together with /metrics (loopback only), truthful /api/ready (Billing and Events checks), supporter privacy (anonymous, hidden amount, private message), goal and supporter-page settings, export and erasure (tips.interaction.erased), moderation (word filter, hide/show, invited moderators, tips.interaction.moderated) and per-actor limits. Capabilities (18 tips.*) and payload schemas are in openvibe-contracts. No billing.* event had arrived: Billing runs in shadow. A written internal threat review (docs/threat-review.md) with its fixes and test/security.test.js. Not done: an independent security review; the openvibe.tips launch. Live stays the place tips happen until the Billing cutover (blocked on the owner re-pointing the PowerChat webhook) and the launch rule in README.md holds.","updated": "2026-09-28","features": ["tips, paid messages, TTS and media requests settled by OpenVibe.Billing (never Tips' own money)",@@@ -20,7 +20,6 @@"supporter privacy, export and erasure","moderation: word filter, hide and show, invited moderators","Live chat delivery adapter",-- "Live import and reconciliation against Billing","server-rendered creator pages useful without JavaScript","/api/v1 by capability","per-actor limits (openvibe-sdk/limits)",@@@ -23,11 +23,9 @@ ExecStart=/usr/bin/env node /opt/openvibe.tips/server/index.jsRestart=alwaysRestartSec=5# DATABASE_URL, DATABASE_DIRECT_URL, VALKEY_URL and VALKEY_PREFIX come from the env file--# (roles/data/add-service.sh tips). The state directory keeps the SQLite file of the release before--# PostgreSQL: read-only rollback for the 7-day N-1 window, read by scripts/migrate-to-postgres.js.++# (roles/data/add-service.sh tips).StateDirectory=openvibe-tipsStateDirectoryMode=0700--Environment=TIPS_DB_PATH=/var/lib/openvibe-tips/tips.dbUMask=0077NoNewPrivileges=truePrivateTmp=truennew file mode 100644@@@ -0,0 +1,10 @@++-- phase: contract++-- after: 0001++--++-- The Live import and the SQLite migration era are gone. `legacy_source` and the `migration_maps`++-- table (0001_initial) existed only to key imported Live rows to the Tips entities they became;++-- nothing reads or writes them now (server/importer/live.js and the import scripts are deleted), so++-- the one-time import's columns go and its map table is dropped.++ALTER TABLE tip_interactions DROP COLUMN legacy_source;++ALTER TABLE tip_goals DROP COLUMN legacy_source;++DROP TABLE migration_maps;@@@ -9,7 +9,6 @@"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",@@@ -110,7 +109,9 @@"url": "https://feross.org/support"}],-- "license": "MIT"++ "license": "MIT",++ "optional": true,++ "peer": true},"node_modules/better-sqlite3": {"version": "11.10.0",@@@ -118,6 +119,8 @@"integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==","hasInstallScript": true,"license": "MIT",++ "optional": true,++ "peer": true,"dependencies": {"bindings": "^1.5.0","prebuild-install": "^7.1.1"@@@ -128,6 +131,8 @@"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz","integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==","license": "MIT",++ "optional": true,++ "peer": true,"dependencies": {"file-uri-to-path": "1.0.0"}@@@ -137,6 +142,8 @@"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz","integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==","license": "MIT",++ "optional": true,++ "peer": true,"dependencies": {"buffer": "^5.5.0","inherits": "^2.0.4",@@@ -186,6 +193,8 @@}],"license": "MIT",++ "optional": true,++ "peer": true,"dependencies": {"base64-js": "^1.3.1","ieee754": "^1.1.13"@@@ -239,7 +248,9 @@"version": "1.1.4","resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz","integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",-- "license": "ISC"++ "license": "ISC",++ "optional": true,++ "peer": true},"node_modules/cluster-key-slot": {"version": "1.1.2",@@@ -313,6 +324,8 @@"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz","integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==","license": "MIT",++ "optional": true,++ "peer": true,"dependencies": {"mimic-response": "^3.1.0"},@@@ -328,6 +341,8 @@"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz","integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==","license": "MIT",++ "optional": true,++ "peer": true,"engines": {"node": ">=4.0.0"}@@@ -365,6 +380,8 @@"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz","integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==","license": "Apache-2.0",++ "optional": true,++ "peer": true,"engines": {"node": ">=8"}@@@ -424,6 +441,8 @@"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz","integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==","license": "MIT",++ "optional": true,++ "peer": true,"dependencies": {"once": "^1.4.0"}@@@ -478,6 +497,8 @@"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)",++ "optional": true,++ "peer": true,"engines": {"node": ">=6"}@@@ -554,7 +575,9 @@"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"++ "license": "MIT",++ "optional": true,++ "peer": true},"node_modules/finalhandler": {"version": "1.3.2",@@@ -596,7 +619,9 @@"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"++ "license": "MIT",++ "optional": true,++ "peer": true},"node_modules/function-bind": {"version": "1.1.2",@@@ -648,7 +673,9 @@"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"++ "license": "MIT",++ "optional": true,++ "peer": true},"node_modules/gopd": {"version": "1.2.0",@@@ -736,7 +763,9 @@"url": "https://feross.org/support"}],-- "license": "BSD-3-Clause"++ "license": "BSD-3-Clause",++ "optional": true,++ "peer": true},"node_modules/inherits": {"version": "2.0.4",@@@ -748,7 +777,9 @@"version": "1.3.8","resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz","integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",-- "license": "ISC"++ "license": "ISC",++ "optional": true,++ "peer": true},"node_modules/iovalkey": {"version": "0.4.0",@@@ -985,6 +1016,8 @@"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz","integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==","license": "MIT",++ "optional": true,++ "peer": true,"engines": {"node": ">=10"},@@@ -997,6 +1030,8 @@"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz","integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==","license": "MIT",++ "optional": true,++ "peer": true,"funding": {"url": "https://github.com/sponsors/ljharb"}@@@ -1005,7 +1040,9 @@"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"++ "license": "MIT",++ "optional": true,++ "peer": true},"node_modules/ms": {"version": "2.0.0",@@@ -1017,7 +1054,9 @@"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"++ "license": "MIT",++ "optional": true,++ "peer": true},"node_modules/negotiator": {"version": "0.6.3",@@@ -1033,6 +1072,8 @@"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz","integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==","license": "MIT",++ "optional": true,++ "peer": true,"dependencies": {"semver": "^7.3.5"},@@@ -1069,6 +1110,8 @@"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz","integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==","license": "ISC",++ "optional": true,++ "peer": true,"dependencies": {"wrappy": "1"}@@@ -1276,6 +1319,8 @@"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==","deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.","license": "MIT",++ "optional": true,++ "peer": true,"dependencies": {"detect-libc": "^2.0.0","expand-template": "^2.0.3",@@@ -1319,6 +1364,8 @@"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz","integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==","license": "MIT",++ "optional": true,++ "peer": true,"dependencies": {"end-of-stream": "^1.1.0","once": "^1.3.1"@@@ -1369,6 +1416,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)",++ "optional": true,++ "peer": true,"dependencies": {"deep-extend": "^0.6.0","ini": "~1.3.0",@@@ -1384,6 +1433,8 @@"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz","integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==","license": "MIT",++ "optional": true,++ "peer": true,"dependencies": {"inherits": "^2.0.3","string_decoder": "^1.1.1",@@@ -1602,7 +1653,9 @@"url": "https://feross.org/support"}],-- "license": "MIT"++ "license": "MIT",++ "optional": true,++ "peer": true},"node_modules/simple-get": {"version": "4.0.1",@@@ -1623,6 +1676,8 @@}],"license": "MIT",++ "optional": true,++ "peer": true,"dependencies": {"decompress-response": "^6.0.0","once": "^1.3.1",@@@ -1658,6 +1713,8 @@"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz","integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==","license": "MIT",++ "optional": true,++ "peer": true,"dependencies": {"safe-buffer": "~5.2.0"}@@@ -1667,6 +1724,8 @@"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz","integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==","license": "MIT",++ "optional": true,++ "peer": true,"engines": {"node": ">=0.10.0"}@@@ -1676,6 +1735,8 @@"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz","integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==","license": "MIT",++ "optional": true,++ "peer": true,"dependencies": {"chownr": "^1.1.1","mkdirp-classic": "^0.5.2",@@@ -1688,6 +1749,8 @@"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz","integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==","license": "MIT",++ "optional": true,++ "peer": true,"dependencies": {"bl": "^4.0.3","end-of-stream": "^1.4.1",@@@ -1713,6 +1776,8 @@"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz","integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==","license": "Apache-2.0",++ "optional": true,++ "peer": true,"dependencies": {"safe-buffer": "^5.0.1"},@@@ -1746,7 +1811,9 @@"version": "1.0.2","resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz","integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",-- "license": "MIT"++ "license": "MIT",++ "optional": true,++ "peer": true},"node_modules/utils-merge": {"version": "1.0.1",@@@ -1770,7 +1837,9 @@"version": "1.0.2","resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz","integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",-- "license": "ISC"++ "license": "ISC",++ "optional": true,++ "peer": true},"node_modules/xtend": {"version": "4.0.2",@@@ -9,9 +9,7 @@"dev": "NODE_ENV=development node server/index.js","test": "node test/run.js","test:pg": "TIPS_TEST_STORE=pg node test/run.js",-- "subscribe": "node scripts/subscribe.js",-- "import-live": "node scripts/import-live.js",-- "migrate-to-postgres": "node scripts/migrate-to-postgres.js"++ "subscribe": "node scripts/subscribe.js"},"keywords": ["openvibe",@@@ -23,7 +21,6 @@"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",ddeleted file mode 100644@@@ -1,67 +0,0 @@--#!/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.-- *-- * Tips' side is its PostgreSQL database (DATABASE_URL; migrations applied with DATABASE_DIRECT_URL-- * first). The two snapshots are other services' SQLite files, which is why this operator tool, and-- * scripts/migrate-to-postgres.js, are the only code here that loads better-sqlite3.-- */--const path = require('path');--const Database = require('better-sqlite3');--const { loadConfig } = require('../server/config');--const { openDb, migrate } = 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.sqlitePath)) throw new Error(`${p} is Tips' own (pre-PostgreSQL) 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);-- await migrate(config, { serving: db });-- 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();-- await db.close();-- if (report.reconciliation && !report.reconciliation.ok) process.exit(1);--}----main().catch((e) => { console.error(e.message); process.exit(1); });ddeleted file mode 100644@@@ -1,142 +0,0 @@--#!/usr/bin/env node--'use strict';--/**-- * The one-time move of Tips' SQLite database into its PostgreSQL schema (ADR-035; the procedure is-- * openvibe-sdk docs/migrating-to-postgresql.md, section 6).-- *-- * node scripts/migrate-to-postgres.js [--sqlite <file>] [--pglite] [--json]-- *-- * 1. applies migrations/ as the owner (DATABASE_DIRECT_URL, a direct connection);-- * 2. copies every table with openvibe-sdk's importSqlite (batched, parents first, identities kept and-- * their sequences advanced) into emptied tables (truncate: a rehearsal can be repeated);-- * 3. verifies each table's row count and a checksum of every row on both sides, prints the report,-- * and exits 1 unless report.ok.-- *-- * The SQLite file (TIPS_DB_PATH, config.sqlitePath; --sqlite for a copy) is opened read-only: nothing in-- * it changes. --pglite imports into an in-memory PostgreSQL instead (a rehearsal with nothing to set up;-- * the report is all it leaves). --json prints the whole report.-- *-- * In production it runs once, from the new release's directory, as the service user with the service's-- * environment, while the service is stopped (the write freeze), before the PostgreSQL release starts.-- *-- * What does not map one to one:-- * settings not carried: the SQLite bookkeeping row (schema_version 3); ov_migrations-- * records the schema now (migrations/0001_initial.sql)-- * event_outbox → tips_event_outbox (the SDK outbox, outboxSchema('tips_event_outbox'))-- * idempotency_receipts → tips_event_inbox (the SDK inbox, inboxSchema('tips_event_inbox'))-- * JSON text 'null' → SQL NULL in the reference columns (interaction_effects.result,-- * paid_messages.chat_ref, paid_media_requests.queue_ref): the SQLite code-- * wrote JSON.stringify(null) when an adapter returned no reference, the-- * PostgreSQL code writes NULL, and both mean "no reference"-- * NUL, unpaired surrogate PostgreSQL stores no NUL character in text or jsonb and no unpaired surrogate-- * in jsonb (SQLite took both; the code strips them now): a NUL is dropped and an-- * unpaired surrogate becomes U+FFFD, in text and inside JSON values alike. The-- * report lists every column where that happened and how many values changed-- * No column is dropped.-- */--const path = require('path');--const { createDb, importSqlite } = require('openvibe-sdk/db');--const { loadConfig } = require('../server/config');--const { migrate, MIGRATIONS } = require('../server/db');----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}`);----/** A row map turning the JSON text 'null' into SQL NULL for these columns. */--const jsonNullToSql = (...cols) => (row) => {-- for (const c of cols) if (row[c] === 'null') row[c] = null;-- return row;--};----const TABLES = {-- tips_event_outbox: { from: 'event_outbox' },-- tips_event_inbox: { from: 'idempotency_receipts' },-- interaction_effects: { map: jsonNullToSql('result') },-- paid_messages: { map: jsonNullToSql('chat_ref') },-- paid_media_requests: { map: jsonNullToSql('queue_ref') },--};----const cleanText = (v) => v.toWellFormed().replace(/\u0000/g, '');--function cleanJson(v) {-- if (typeof v === 'string') return cleanText(v);-- if (Array.isArray(v)) return v.map(cleanJson);-- if (v && typeof v === 'object') return Object.fromEntries(Object.entries(v).map(([k, x]) => [cleanText(k), cleanJson(x)]));-- return v;--}----/**-- * importSqlite's `tables` for this schema: TABLES, and for every table a map that makes its text and-- * JSON values storable (see above). `cleaned` counts the distinct values changed per column (the-- * verification sees the same mapped values on both sides, so this is the record of what changed).-- */--async function importOptions(db) {-- const cols = await db.many(`SELECT table_name, column_name, udt_name FROM information_schema.columns-- WHERE table_schema = current_schema() AND table_name <> 'ov_migrations' AND udt_name IN ('text', 'varchar', 'json', 'jsonb')`);-- const byTable = new Map();-- for (const c of cols) { if (!byTable.has(c.table_name)) byTable.set(c.table_name, []); byTable.get(c.table_name).push(c); }-- const cleaned = new Map(); // "table.column" → Set of the original values changed-- const note = (t, c, v) => { const k = `${t}.${c}`; if (!cleaned.has(k)) cleaned.set(k, new Set()); cleaned.get(k).add(v); };-- const tables = {};-- for (const [table, list] of byTable) {-- const base = TABLES[table] || {};-- tables[table] = {-- ...base,-- map(row) {-- const r = base.map ? base.map(row) : row;-- if (!r) return r;-- for (const { column_name: c, udt_name: udt } of list) {-- const v = r[c];-- if (typeof v !== 'string') continue;-- if (udt === 'json' || udt === 'jsonb') {-- let parsed;-- try { parsed = JSON.parse(v); } catch { continue; } // left for the importer to report-- const fixed = JSON.stringify(cleanJson(parsed));-- if (fixed !== JSON.stringify(parsed)) { note(table, c, v); r[c] = fixed; }-- } else {-- const fixed = cleanText(v);-- if (fixed !== v) { note(table, c, v); r[c] = fixed; }-- }-- }-- return r;-- },-- };-- }-- return { tables, cleaned: () => [...cleaned].map(([column, values]) => ({ column, values: values.size })) };--}----async function main() {-- const config = loadConfig();-- const sqlite = path.resolve(opt('sqlite') || config.sqlitePath);-- const quiet = { log() {}, warn: console.warn, error: console.error };-- let owner;-- if (flag('pglite')) {-- owner = createDb({ pglite: true, service: 'tips-import' });-- await owner.migrate({ dir: MIGRATIONS, log: quiet });-- } else {-- await migrate(config, { log: quiet });-- owner = createDb({ url: config.db.directUrl, service: 'tips-import', max: 2 });-- }-- try {-- const t0 = Date.now();-- const options = await importOptions(owner);-- const report = await importSqlite({ sqlite, db: owner, truncate: true, tables: options.tables, log: quiet });-- const cleaned = options.cleaned();-- if (flag('json')) console.log(JSON.stringify({ sqlite, into: owner.store, ...report, cleaned }, null, 2));-- else {-- console.log(`import ${sqlite} → ${owner.store} (${Date.now() - t0} ms): ${report.ok ? 'OK' : 'PROBLEMS'}`);-- for (const t of report.tables) console.log(` ${t.table.padEnd(24)} ${String(t.rows).padStart(8)} rows ${t.checksum || '-'}${t.source !== t.table ? ` (from ${t.source})` : ''}`);-- for (const c of cleaned) console.log(` cleaned: ${c.column}: ${c.values} value(s) with a NUL or an unpaired surrogate`);-- for (const p of report.problems) console.log(` problem: ${p.table}: ${p.problem}`);-- }-- return report.ok ? 0 : 1;-- } finally {-- await owner.close();-- }--}----if (require.main === module) {-- main().then((code) => process.exit(code), (e) => { console.error(`migrate-to-postgres failed: ${e.message}`); process.exit(1); });--}----module.exports = { TABLES, importOptions };@@@ -403,9 +403,9 @@ function v1Router({ domain, apiAuth, limits }) {/** Async-safe handler: TipsError → problem+json; anything else → 500. */function wrap(fn) {-- return (req, res, next) => {++ return async (req, res, next) => {try {-- const p = fn(req, res, next);++ const p = await fn(req, res, next);if (p && typeof p.catch === 'function') p.catch((e) => sendError(req, res, e));} catch (e) { sendError(req, res, e); }};@@@ -58,7 +58,7 @@ function createBillingClient(config, { fetchImpl = globalThis.fetch, tokenClient} 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 }); }++ if (res.status === 401 && !retried) { tokenClientFor().invalidate(); return await 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}`;@@@ -69,17 +69,17 @@ function createBillingClient(config, { fetchImpl = globalThis.fetch, tokenClientreturn {/** { intent, checkout_url } — kind purchase: the supporter buys `bits` of credit. */-- createIntent: ({ provider, subject, bits, successUrl, cancelUrl, key, traceparent }) => call('POST', '/api/v1/intents', {++ createIntent: async ({ provider, subject, bits, successUrl, cancelUrl, key, traceparent }) => await 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', {++ createTransfer: async ({ from, to, amount, kind, target, message, key, traceparent }) => await 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'),++ refundTransfer: async ({ txnId, amount, reason, key }) => await call('POST', `/api/v1/transfers/${encodeURIComponent(txnId)}/refund`, { body: { amount, reason }, key }),++ rates: async () => await call('GET', '/api/v1/rates'),};}@@@ -32,7 +32,7 @@ function loadConfig(env = process.env) {// PostgreSQL (ADR-035; OpenVibe.Host's data role writes all four into /etc/openvibe/tips.env):// DATABASE_URL is the pooled runtime role through PgBouncer (transaction mode), DATABASE_DIRECT_URL-- // the owner role on a direct connection, for migrations at boot and the one-time import.++ // the owner role on a direct connection, for migrations at boot.db: {url: env.DATABASE_URL || '',directUrl: env.DATABASE_DIRECT_URL || '',@@@ -43,10 +43,6 @@ function loadConfig(env = process.env) {url: env.VALKEY_URL || '',prefix: env.VALKEY_PREFIX || 'ov:tips:',},-- // The SQLite file of the release before PostgreSQL: read (never written) only by-- // scripts/migrate-to-postgres.js. The systemd unit sets /var/lib/openvibe-tips/tips.db.-- sqlitePath: env.TIPS_DB_PATH || './data/tips.db',--// Identity: service tokens and user tokens are RS256 JWTs signed by OpenVibe.Network.network: {url: networkUrl,@@@ -56,8 +52,7 @@ function loadConfig(env = process.env) {},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).++ // Events and Live (chat delivery); and the OAuth code flow for browser sign-in.oauth: {clientId: env.OV_OAUTH_CLIENT_ID || 'tips',clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',@@@ -22,7 +22,6 @@* 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), tip_moderators / _invites / _moderation_log,* api_idempotency, import_runs, tips_event_outbox + tips_event_inbox (openvibe-sdk outbox and inbox).@@@ -43,7 +43,7 @@ function createLiveChatAdapter(config, { fetchImpl = globalThis.fetch, tokenClie} catch (e) {throw new Error(`Live unreachable: ${e.message}`);}-- if (res.status === 401 && !retried) { client().invalidate(); return deliver(job, true); }++ if (res.status === 401 && !retried) { client().invalidate(); return await 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'}`);@@@ -141,7 +141,7 @@ function createGoals(ctx) {const id = prefixedId('tgoal', ctx.now());await t.exec(sql`INSERT INTO tip_goals (id, creator_subject, title, description, target_amount, image_url, status, sort_order, created_at, updated_at)VALUES (${id}, ${creator}, ${f.title}, ${f.description || null}, ${f.target_amount}, ${f.image_url || null}, 'active', ${f.sort_order || 0}, ${at}, ${at})`);-- return changed(t, id, 'created');++ return await changed(t, id, 'created');});}@@@ -150,23 +150,23 @@ function createGoals(ctx) {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);-- if (!Object.keys(f).length) return present(db, goal);++ if (!Object.keys(f).length) return await present(db, goal);return ctx.tx(async (t) => {const cur = await t.one(sql`SELECT * FROM tip_goals WHERE id = ${goal.id} FOR UPDATE`);if (cur.status !== 'active') fail(409, 'tips.goal_closed', 'a closed goal cannot be edited');if (input.revision != null && Number(input.revision) !== cur.revision) fail(409, 'tips.revision_conflict', `the goal is at revision ${cur.revision}`);await t.exec(sql`UPDATE tip_goals SET ${sql.set(f)} WHERE id = ${goal.id}`);await markReached(t, goal.id);-- return changed(t, goal.id, 'updated');++ return await changed(t, goal.id, 'updated');});}async function close(goal) {-- if (goal.status === 'closed') return present(db, goal);++ if (goal.status === 'closed') return await present(db, goal);return ctx.tx(async (t) => {const n = await t.exec(sql`UPDATE tip_goals SET status = 'closed', closed_at = ${iso(ctx.now())} WHERE id = ${goal.id} AND status = 'active'`);-- if (!n) return present(t, await get(t, goal.id));-- return changed(t, goal.id, 'closed');++ if (!n) return await present(t, await get(t, goal.id));++ return await changed(t, goal.id, 'closed');});}@@@ -208,7 +208,7 @@ function createGoals(ctx) {// The overlay's goal update names the supporter as the alert does; not when the amount is hidden// (the bar's jump would otherwise pin the amount on them), nor while moderation holds it.const by = interaction.hide_amount || (interaction.moderation && interaction.moderation !== 'visible') ? null : publicView(interaction, { filter }).supporter_name;-- return changed(t, g.id, reachedNow ? 'reached' : 'contribution', { interactionId: interaction.id, by });++ return await changed(t, g.id, reachedNow ? 'reached' : 'contribution', { interactionId: interaction.id, by });}/** Inside the reversal transaction: take `bits` back off every goal the interaction counted toward. */@@@ -65,7 +65,7 @@ const TRANSFER_BATCH = 10;const TRANSFER_LEASE_MS = 5 * 60_000;const COLUMNS = ['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', 'anonymous', 'hide_amount', 'private_message', 'created_at', 'updated_at'];++ 'idempotency_key', 'origin', 'target', 'anonymous', 'hide_amount', 'private_message', 'created_at', 'updated_at'];function createInteractions(ctx) {const { db, config, MONEY } = ctx;@@@ -148,7 +148,7 @@ function createInteractions(ctx) {const at = iso(ctx.now());const r = {supporter_subject: null, supporter_name: null, amount_cents: null, message: null, request: {}, test: false, billing_txn_id: null,-- billing_intent_id: null, funding_txn_id: null, provider: null, provider_ref: null, legacy_source: null, idempotency_key: null,++ billing_intent_id: null, funding_txn_id: null, provider: null, provider_ref: null, idempotency_key: null,target: null, origin: 'tips', anonymous: false, hide_amount: false, private_message: false, created_at: at, ...row,};const values = {@@@ -208,11 +208,11 @@ function createInteractions(ctx) {key: `tips:transfer:${i.id}`, traceparent,});const txn = out.transaction;-- await ctx.tx(async (t) => settleByTransaction(t, await lock(t, i.id), { id: txn.id, test: !!txn.test }), MONEY);++ await ctx.tx(async (t) => await settleByTransaction(t, await lock(t, i.id), { id: txn.id, test: !!txn.test }), MONEY);return { settled: true };} catch (e) {if (e instanceof BillingCallError && !e.retryable) {-- await ctx.tx(async (t) => failPayment(t, await lock(t, i.id), e.code || 'billing.refused', e.message), MONEY);++ await ctx.tx(async (t) => await failPayment(t, await lock(t, i.id), e.code || 'billing.refused', e.message), MONEY);return { settled: false, refused: e.code, detail: e.body && e.body.detail };}// Due again after the attempt's backoff (TRANSFER_BACKOFF[attempts - 1], the last one repeating).@@@ -237,7 +237,7 @@ function createInteractions(ctx) {});} catch (e) {if (e instanceof BillingCallError && !e.retryable) {-- await ctx.tx(async (t) => failPayment(t, await lock(t, i.id), e.code || 'billing.refused', e.message), MONEY);++ await ctx.tx(async (t) => await failPayment(t, await lock(t, i.id), e.code || 'billing.refused', e.message), MONEY);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');@@@ -280,7 +280,7 @@ function createInteractions(ctx) {if (other && other.id !== i.id) return other;const row = await t.one(sql`UPDATE tip_interactions SET billing_txn_id = ${txn.id}, test = test OR ${!!txn.test}, transfer_due = false, failure = NULLWHERE id = ${i.id} RETURNING *`);-- return settle(t, row);++ return await settle(t, row);}/** Inside a MONEY transaction, `i` locked (or created by it): pending/failed → settled, and the whole effect path. */@@@ -465,19 +465,15 @@ function createInteractions(ctx) {}/**-- * The goal a PowerChat tip asked for: its app_purpose/app_ref carries `goal:<id>` — a Tips goal id,-- * or a Live donation_goals id (links minted by Live), which the Live import mapped to a Tips goal.-- * goals.contribute() still checks it is the creator's and active, else falls back to Live's rule.++ * The goal a PowerChat tip asked for: its app_purpose/app_ref carries `goal:<id>` — a Tips goal id.++ * goals.contribute() still checks it is the creator's and active, else falls back to the++ * sole-active-goal rule.*/-- async function goalFromPurpose(q, ...refs) {++ function goalFromPurpose(...refs) {for (const v of refs) {const m = storable(v || '').match(/(?:^|[:_-])goal[:_-]?([A-Za-z0-9_]+)/i);if (!m) continue;-- if (/^\d+$/.test(m[1])) {-- const map = await q.maybe(sql`SELECT target_id FROM migration_maps WHERE source = 'live' AND source_table = 'donation_goals' AND source_id = ${m[1]} AND status = 'imported'`);-- if (map && map.target_id) return map.target_id;-- continue;-- }++ if (/^\d+$/.test(m[1])) continue; // a numeric id names no Tips goal (goal ids carry a prefix)return m[1];}return null;@@@ -502,7 +498,7 @@ function createInteractions(ctx) {const bits = Number(p.value_bits);const amount = Math.min(Number.isSafeInteger(bits) && bits > 0 ? bits : cents, config.limits.maxBits);const message = p.message ? storable(String(p.message).replace(/\r\n?/g, '\n').trim().slice(0, config.limits.messageChars)) || null : null;-- const goalId = await goalFromPurpose(t, p.app_purpose, p.app_ref);++ const goalId = goalFromPurpose(p.app_purpose, p.app_ref);const i = await insert(t, {id: prefixedId('tint', ctx.now()), creator_subject: creator, supporter_subject: null,supporter_name: p.anonymous ? 'Anonymous' : (displayName(p.donor_name) || 'Someone'), anonymous: !!p.anonymous, kind: 'tip', amount, amount_cents: cents,@@@ -596,7 +592,6 @@ function createInteractions(ctx) {out.effects = effects;out.provider = i.provider || null;out.provider_ref = i.provider_ref || null;-- out.legacy_source = i.legacy_source || null;}return out;}@@@ -149,8 +149,8 @@ function createModeration(ctx) {? t.one(sql`UPDATE tip_interactions SET filtered = true, moderation = 'held', moderated_at = ${at}, moderated_by = 'filter', updated_at = ${at} WHERE id = ${i.id} RETURNING *`): t.one(sql`UPDATE tip_interactions SET filtered = true, updated_at = ${at} WHERE id = ${i.id} RETURNING *`);}-- function recordScreened(t, i, verdict) {-- return record(t, i, verdict.hold ? 'held' : 'filtered', { role: 'filter', reason: 'blocked_words' });++ async function recordScreened(t, i, verdict) {++ return await record(t, i, verdict.hold ? 'held' : 'filtered', { role: 'filter', reason: 'blocked_words' });}/** Who is acting: { role, actor } for the creator, a moderator or a granted service; else null. */@@@ -109,7 +109,7 @@ function createOverlayHub({ valkey = null, pushNew, heartbeatMs, now = () => Dat/** Run fn for a client after whatever it is already doing (its pushes never interleave). */function queue(client, fn) {-- client.chain = client.chain.then(() => (client.closed ? null : fn())).catch((e) => log.warn('[Tips] overlay push:', e.message));++ client.chain = client.chain.then(async () => (client.closed ? null : await fn())).catch((e) => log.warn('[Tips] overlay push:', e.message));return client.chain;}/** New deliveries for this client: coalesced, so a burst of notifications reads once. */@@@ -214,7 +214,7 @@ function createOverlayHub({ valkey = null, pushNew, heartbeatMs, now = () => Datconfig: (creator, config) => publish(creator, { t: 'config', config }),revoke: (creator, tokenId) => publish(creator, { t: 'revoke', token_id: tokenId }),/** Open streams of this creator's overlays, in every process. */-- connected: (creator) => slots('count', creator),++ connected: async (creator) => await slots('count', creator),closeAll() { for (const entry of [...creators.values()]) for (const c of [...entry.clients]) detach(c); },async close() { this.closeAll(); await ps.close(); },};@@@ -142,7 +142,7 @@ function createOverlays(ctx) {return ctx.tx(async (t) => {const c = await t.one(sql`SELECT * FROM overlay_configs WHERE id = ${config.id} FOR UPDATE`);if (input.revision != null && Number(input.revision) !== c.revision) fail(409, 'tips.revision_conflict', `the config is at revision ${c.revision}`);-- return changeConfig(t, c, input);++ return await changeConfig(t, c, input);});}async function changeConfig(t, c, input) {@@@ -189,9 +189,9 @@ function createOverlays(ctx) {}/** Inside the settlement transaction (or a simulation). `filter`: the creator's word filter. */-- function addAlert(t, interaction, filter) {++ async function addAlert(t, interaction, filter) {const payload = publicView(interaction, { at: iso(ctx.now()), filter });-- return insertDelivery(t, interaction.creator_subject, 'alert', { interactionId: interaction.id, payload, test: interaction.test, dedupe: `alert:${interaction.id}` });++ return await insertDelivery(t, interaction.creator_subject, 'alert', { interactionId: interaction.id, payload, test: interaction.test, dedupe: `alert:${interaction.id}` });}/**@@@ -215,7 +215,7 @@ function createOverlays(ctx) {await t.exec(sql`UPDATE overlay_deliveries d SET payload = v.payloadFROM jsonb_to_recordset(${sql.json(updates)}) AS v(seq bigint, payload jsonb) WHERE d.seq = v.seq`);}-- const refreshInteraction = (t, interaction, filter) => refreshInteractions(t, [interaction], new Map([[interaction.creator_subject, filter]]));++ const refreshInteraction = async (t, interaction, filter) => await refreshInteractions(t, [interaction], new Map([[interaction.creator_subject, filter]]));/** Inside a transaction: a moderator hid the interaction. Its alert is never sent or replayed again. */async function retract(t, interaction, filter) {@@@ -230,8 +230,8 @@ function createOverlays(ctx) {await refreshInteraction(t, interaction, filter);}-- function addGoalDelivery(t, creator, goalView, { reason, interactionId, by, dedupe, test = false }) {-- return insertDelivery(t, creator, 'goal', { interactionId, goalId: goalView.id, payload: { goal: goalView, reason, by: by || null, test }, test, dedupe });++ async function addGoalDelivery(t, creator, goalView, { reason, interactionId, by, dedupe, test = false }) {++ return await insertDelivery(t, creator, 'goal', { interactionId, goalId: goalView.id, payload: { goal: goalView, reason, by: by || null, test }, test, dedupe });}/**@@@ -65,7 +65,7 @@ function createProfiles(ctx) {const bySubject = async (q, s) => parse(await q.maybe(sql`SELECT * FROM creator_tip_profiles WHERE creator_subject = ${String(s || '')}`));const byHandle = async (q, h) => { const n = normalizeHandle(h); return n ? parse(await q.maybe(sql`SELECT * FROM creator_tip_profiles WHERE handle = ${n}`)) : null; };/** usr_… or a handle (with or without @). */-- const resolve = (q, ref) => (/^usr_/.test(String(ref || '')) ? bySubject(q, String(ref)) : byHandle(q, ref));++ const resolve = async (q, ref) => (/^usr_/.test(String(ref || '')) ? await bySubject(q, String(ref)) : await byHandle(q, ref));/** A handle not held by another creator: the username, else username-<suffix of the subject>. */async function freeHandle(q, subject, username) {@@@ -81,7 +81,7 @@ function createProfiles(ctx) {*/async function ensure(subject, claims = {}, attempt = 0) {try { return await ensureOnce(subject, claims); } catch (e) {-- if (e.code === '23505' && e.constraint === HANDLE_TAKEN && attempt < 2) return ensure(subject, claims, attempt + 1);++ if (e.code === '23505' && e.constraint === HANDLE_TAKEN && attempt < 2) return await ensure(subject, claims, attempt + 1);throw e;}}@@@ -93,7 +93,7 @@ function createProfiles(ctx) {const row = await db.maybe(sql`INSERT INTO creator_tip_profiles (creator_subject, handle, display_name, avatar_url, created_at, updated_at)VALUES (${subject}, ${await freeHandle(db, subject, username)}, ${name}, ${safeUrl(avatarUrl)}, ${at}, ${at})ON CONFLICT (creator_subject) DO NOTHING RETURNING *`);-- return row ? parse(row) : bySubject(db, subject);++ return row ? parse(row) : await bySubject(db, subject);}const handle = normalizeHandle(username) && normalizeHandle(username) !== existing.handle ? await freeHandle(db, subject, username) : existing.handle;if (handle !== existing.handle || name !== existing.display_name || (safeUrl(avatarUrl) || null) !== (existing.avatar_url || null)) {@@@ -119,7 +119,7 @@ function createProfiles(ctx) {const p = parse(await t.maybe(sql`SELECT * FROM creator_tip_profiles WHERE creator_subject = ${String(subject || '')} FOR UPDATE`));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}`);-- return change(t, p, patch);++ return await change(t, p, patch);});}ddeleted file mode 100644@@@ -1,313 +0,0 @@--'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.-- *-- * The snapshots are other services' SQLite files, read synchronously (better-sqlite3 handles passed in-- * by scripts/import-live.js); everything written goes to Tips' PostgreSQL in one SERIALIZABLE-- * transaction (it records settled money and applies refunds), whose report starts over on a retry.-- */--const { sql } = require('openvibe-sdk/db');--const { iso, prefixedId, json, storable } = 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;--};--// The snapshot (SQLite, read-only) side.--const tableExists = (snap, name) => !!snap.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name);--const columns = (snap, t) => (tableExists(snap, t) ? snap.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 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: {}, holds: [], excluded: [], unlinked: [], reconciliation: null,-- };-- const fresh = () => ({ imported: 0, held: 0, excluded: 0, refunds_applied: 0, external: 0, goals: 0, unchanged: 0, released: 0, linked_to_billing: 0, unlinked: 0 });-- Object.assign(report.counts, fresh());---- // ── 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');-- // Live's chat_messages carries its time in `timestamp` (older snapshots: created_at).-- const chatTs = chatCols.includes('created_at') ? 'created_at' : (chatCols.includes('timestamp') ? 'timestamp' : 'NULL');-- const chatDonations = chatCols.includes('metadata')-- ? live.prepare(`SELECT id, channel_user_id, username, message, metadata, ${chatTs} AS 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) ? storable(u.display_name || u.username) : null; };-- report.counts.identities = { resolved: [...ids].filter((i) => subjectOf(i)).length, unmapped: [...ids].filter((i) => !subjectOf(i)).length };---- let q = null; // the import's transaction-- const mapRow = (_source, table, id) => q.maybe(sql`SELECT * FROM migration_maps WHERE source = 'live' AND source_table = ${table} AND source_id = ${id}`);-- const record = (table, id, fields) => {-- const f = { target_type: null, target_id: null, reason: null, billing: null, ...fields };-- return q.exec(sql`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}, ${String(id)}, ${f.target_type}, ${f.target_id}, ${f.status}, ${f.reason}, ${f.billing}, ${runId}, ${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 run = async (t0) => {-- q = t0;-- // A retried attempt (serialization failure) reports from scratch.-- Object.assign(report.counts, fresh(), { live: report.counts.live, identities: report.counts.identities });-- report.holds = []; report.excluded = []; report.unlinked = [];-- // ── 2. donations ─────────────────────────────────────-- for (const t of txns.filter((x) => x.type === 'donation')) {-- const prev = await mapRow('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) {-- await q.exec(sql`UPDATE tip_interactions SET billing_txn_id = ${b} WHERE id = ${prev.target_id} AND billing_txn_id IS NULL`);-- await 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`;-- await 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`;-- await 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 ? storable(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 = await domain.interactions.insert(q, {-- 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, 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,-- });-- await q.exec(sql`UPDATE tip_interactions SET settled_at = ${created}, delivered_at = ${created} WHERE id = ${i.id}`);-- await 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 = await mapRow('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) { await 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;-- // The donations a refund may apply to, locked in id order (the domain's lock order).-- const candidates = creator ? (await q.many(sql`SELECT * FROM tip_interactions WHERE creator_subject = ${creator} AND settlement = 'imported'-- AND starts_with(legacy_source, 'live:transactions:') AND supporter_subject IS NOT DISTINCT FROM ${donor}::text AND created_at <= ${created}-- AND amount - reversed_bits > 0 ORDER BY id FOR UPDATE`)).sort((a, b) => (a.created_at < b.created_at ? 1 : a.created_at > b.created_at ? -1 : 0)) : [];-- // 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) {-- await 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 = [...(c.reversal_txn_ids || []), `live:transactions:${t.id}`];-- await q.exec(sql`UPDATE tip_interactions SET reversed_bits = reversed_bits + ${take}, payment_state = 'reversed', reversed_at = ${created},-- reversal_txn_ids = ${sql.json(ids)} WHERE id = ${c.id}`);-- applied.push({ interaction: c.id, bits: take });-- }-- await 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 = await mapRow('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);-- await record('chat_messages', c.id, { status: 'excluded', reason: `celebration of site-routed tip (Live transaction ${twin.id})` });-- report.counts.excluded++;-- continue;-- }-- if (amount <= 0) { await record('chat_messages', c.id, { status: 'excluded', reason: 'no amount' }); report.counts.excluded++; continue; }-- const creator = subjectOf(c.channel_user_id);-- if (!creator) {-- await 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 = await domain.interactions.insert(q, {-- id: prefixedId('tint', Date.parse(created)), creator_subject: creator, supporter_name: m.username ? storable(String(m.username).slice(0, 80)) : 'PowerChat supporter',-- kind: 'tip', amount, amount_cents: amount, message: m.message ? storable(String(m.message).slice(0, 500)) : null, funding: 'external', settlement: 'external',-- payment_state: 'settled', delivery_state: 'delivered', test: !!(resetAt && created < resetAt), provider: 'powerchat',-- provider_ref: `live-chat:${c.id}`, legacy_source: `live:chat_messages:${c.id}`, origin: 'import', created_at: created,-- });-- await q.exec(sql`UPDATE tip_interactions SET settled_at = ${created}, delivered_at = ${created} WHERE id = ${i.id}`);-- await 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 = await mapRow('live', 'donation_goals', String(g.id));-- if (prev && prev.status !== 'held') { report.counts.unchanged++; continue; }-- const creator = subjectOf(g.user_id);-- if (!creator) { await 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) { await 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));-- await q.exec(sql`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 (${id}, ${creator}, ${storable(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})`);-- await 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 = await reconcile(domain, billingSource, q); throw new DryRun(); }-- };---- // Serializable while the service writes too: a conflict retries the whole run (idempotent), up to 10 times.-- try { await domain.tx(run, { ...domain.MONEY, retries: 10 }); } catch (e) { if (!(e instanceof DryRun)) throw e; }-- if (!dryRun) report.reconciliation = await reconcile(domain, billingSource);-- report.finished_at = iso(domain.now());-- await domain.db.exec(sql`INSERT INTO import_runs (id, source, dry_run, started_at, finished_at, report)-- VALUES (${runId}, 'live', ${!!dryRun}, ${report.started_at}, ${report.finished_at}, ${sql.json(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. */--async function reconcile(domain, billingSource, q = domain.db) {-- if (!billingSource) return null;-- const billing = billingSource.creatorTotals();-- const tips = new Map((await q.many(sql`SELECT creator_subject AS s, SUM(amount - reversed_bits)::bigint AS n FROM tip_interactions-- WHERE NOT test AND settlement IN ('billing', 'imported') AND payment_state IN ('settled', 'reversed') GROUP BY creator_subject`)).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 };@@@ -66,8 +66,8 @@ async function main() {if (valkey) await valkey.close().catch(() => {});process.exit(0);}-- process.on('SIGTERM', () => { shutdown('SIGTERM'); });-- process.on('SIGINT', () => { shutdown('SIGINT'); });++ process.on('SIGTERM', async () => { await shutdown('SIGTERM'); });++ process.on('SIGINT', async () => { await shutdown('SIGINT'); });}main().catch((e) => {@@@ -6,16 +6,11 @@* 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;@@@ -39,7 +34,7 @@ function createKeyProvider(config, { fetchImpl = globalThis.fetch, log = consoleif (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 = setInterval(async () => { await load(); }, 6 * 60 * 60 * 1000);timer.unref();}function stop() { if (timer) clearInterval(timer); timer = null; }@@@ -74,47 +69,4 @@ function createUserAuth(config, keys) {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 };++module.exports = { createKeyProvider, createUserAuth };@@@ -19,7 +19,7 @@ function fail(status, code, detail, extra) { throw new TipsError(status, code, dconst 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');--/** JSON text (a SQLite snapshot's column) → value, or `d`. PostgreSQL jsonb columns come back parsed. */++/** JSON text → value, or `d`. PostgreSQL jsonb columns come back parsed. */const json = (v, d) => { if (v == null || v === '') return d; try { return JSON.parse(v); } catch { return d; } };function positiveInt(v, field, max) {@@@ -33,8 +33,8 @@ function positiveInt(v, field, max) {// from the creator's filter and turn text around on stream (docs/threat-review.md).const INVISIBLE = /[\u00AD\u200B-\u200F\u202A-\u202E\u2060-\u2064\u2066-\u2069\uFEFF]/g;--// PostgreSQL stores no NUL character in text or jsonb, and jsonb refuses an unpaired surrogate (SQLite--// took both): every text from outside goes through storable() before it reaches the database.++// PostgreSQL stores no NUL character in text or jsonb, and jsonb refuses an unpaired surrogate:++// every text from outside goes through storable() before it reaches the database.const NUL = /\u0000/g;/** A string PostgreSQL can store: NUL characters dropped, unpaired surrogates replaced by U+FFFD. */const storable = (v) => String(v).toWellFormed().replace(NUL, '');@@@ -112,11 +112,11 @@ function createWebRoutes({ domain, config, layout, userAuth }) {}r.get('/receipts/erase', withViewer, async (req, res) => {if (!req.viewer) return res.redirect(`/auth/login?next=${encodeURIComponent('/receipts/erase')}`);-- return renderErase(req, res);++ return await renderErase(req, res);});r.post('/receipts/erase', withViewer, form, async (req, res) => {if (!req.viewer) return res.redirect(303, `/auth/login?next=${encodeURIComponent('/receipts/erase')}`);-- if (!csrfOk(req) || !bool(req.body.confirm)) return renderErase(req, res, 403);++ if (!csrfOk(req) || !bool(req.body.confirm)) return await renderErase(req, res, 403);await interactions.erase(req.viewer.subject);return res.redirect(303, `/receipts?done=${encodeURIComponent('Your data was erased from your tips')}`);});@@@ -163,21 +163,21 @@ function createWebRoutes({ domain, config, layout, userAuth }) {r.get('/dashboard', withViewer, async (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.-- return renderDashboard(req, res, { flash: FLASH.has(String(req.query.done || '')) ? String(req.query.done) : null });++ return await 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, async (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 });++ if (!csrfOk(req)) return await renderDashboard(req, res, { error: 'That form expired. Please try again.', status: 403 });try {const p = await ensureProfile(req);const out = await 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 });++ if (e instanceof TipsError) return await renderDashboard(req, res, { error: e.detail || e.message, status: e.status >= 500 ? 500 : 400 });throw e;}});@@@ -335,19 +335,19 @@ function createWebRoutes({ domain, config, layout, userAuth }) {if (!req.viewer) return res.redirect(`/auth/login?next=${encodeURIComponent(req.originalUrl)}`);const { p, actor } = await modAccess(req);if (!p || !actor) return notFound(req, res);-- return renderModerate(req, res, p);++ return await renderModerate(req, res, p);});r.post('/moderate/:handle/:id/:act(hide|restore)', withViewer, form, async (req, res) => {if (!req.viewer) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/moderate/${req.params.handle}`)}`);const { p, actor } = await modAccess(req);if (!p || !actor) return notFound(req, res);-- if (!csrfOk(req)) return renderModerate(req, res, p, { error: 'That form expired. Please try again.', status: 403 });++ if (!csrfOk(req)) return await renderModerate(req, res, p, { error: 'That form expired. Please try again.', status: 403 });const i = await interactions.get(db, req.params.id);if (!i || i.creator_subject !== p.creator_subject) return notFound(req, res);try {await moderation[req.params.act](i, { ...actor, reason: req.body.reason });} catch (e) {-- if (e instanceof TipsError) return renderModerate(req, res, p, { error: e.detail || e.message, status: 400 });++ if (e instanceof TipsError) return await renderModerate(req, res, p, { error: e.detail || e.message, status: 400 });throw e;}return res.redirect(303, `/moderate/${encodeURIComponent(p.handle)}?done=${req.params.act === 'hide' ? 'Hidden' : 'Shown'}`);@@@ -371,7 +371,7 @@ function createWebRoutes({ domain, config, layout, userAuth }) {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', withViewer, async (req, res, next) => (profiles.normalizeHandle(req.params.handle) ? await creatorView(req, res) : next()));r.get('/:handle/goals', withViewer, async (req, res, next) => {const p = await profiles.byHandle(db, req.params.handle);if (!p) return next();@@@ -407,7 +407,7 @@ function createWebRoutes({ domain, config, layout, userAuth }) {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, anonymous: bool(b.anonymous), hide_amount: bool(b.hide_amount), private_message: bool(b.private_message),};-- if (!csrfOk(req)) return creatorView(req, res, { values, error: 'That form expired. Please send it again.', status: 403 });++ if (!csrfOk(req)) return await 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;@@@ -426,7 +426,7 @@ function createWebRoutes({ domain, config, layout, userAuth }) {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 });++ return await creatorView(req, res, { values, error: msg, status: 409 });}} else {i = (await interactions.startCheckout(i, { provider: b.provider })).interaction;@@@ -435,7 +435,7 @@ function createWebRoutes({ domain, config, layout, userAuth }) {}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 });++ if (e instanceof TipsError) return await creatorView(req, res, { values, error: e.detail || e.message, status: e.status >= 500 ? 502 : 400 });return next(e);}});@@@ -72,17 +72,15 @@ const { boot, check, done } = require('./helpers/app');assert.strictEqual(totals.settled_via_billing, 0);});-- await check('the goal the tip asked for: a Tips goal id, or a Live goal id through the import map', async () => {++ await check('the goal the tip asked for: a Tips goal id', async () => {const a = (await t.call('POST', '/api/v1/goals', { user: alex, body: { title: 'Desk', target_amount: 900 } })).json.goal;const b = (await t.call('POST', '/api/v1/goals', { user: alex, body: { title: 'Chair', target_amount: 900 } })).json.goal;await t.deliver(receipt({ provider_event_id: 'ext-goal-tips', amount_cents: 100, value_bits: 100, app_purpose: `goal:${a.id}` }));-- await t.db.exec(`INSERT INTO migration_maps (source, source_table, source_id, target_type, target_id, status, run_id, created_at, updated_at)-- VALUES ('live', 'donation_goals', '12', 'goal', $1, 'imported', 'imp_test', $2, $2)`, [b.id, new Date().toISOString()]);-- await t.deliver(receipt({ provider_event_id: 'ext-goal-live', amount_cents: 200, value_bits: 200, app_purpose: 'goal:12' }));++ await t.deliver(receipt({ provider_event_id: 'ext-goal-other', amount_cents: 200, value_bits: 200, app_purpose: `goal:${b.id}` }));await t.deliver(receipt({ provider_event_id: 'ext-goal-none', amount_cents: 300, value_bits: 300 }));assert.strictEqual((await domain.goals.present(t.db, await domain.goals.get(t.db, a.id))).current_amount, 100);assert.strictEqual((await domain.goals.present(t.db, await domain.goals.get(t.db, b.id))).current_amount, 200);-- assert.strictEqual(await t.db.value('SELECT count(*) FROM tip_goal_contributions WHERE interaction_id = $1', [(await byRef('ext-goal-none')).id]), 0, 'several active goals and no pick: none (Live\'s rule)');++ assert.strictEqual(await t.db.value('SELECT count(*) FROM tip_goal_contributions WHERE interaction_id = $1', [(await byRef('ext-goal-none')).id]), 0, 'several active goals and no pick: none');});await check('anonymous: no donor name; a creator with no tip page is still announced', async () => {ddeleted file mode 100644@@@ -1,301 +0,0 @@----CREATE TABLE settings (-- id INTEGER PRIMARY KEY CHECK (id = 1),-- schema_version INTEGER NOT NULL,-- created_at TEXT NOT NULL--);----CREATE TABLE 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--, page_settings TEXT NOT NULL DEFAULT '{}', filter_words TEXT NOT NULL DEFAULT '[]', filter_action TEXT NOT NULL DEFAULT 'mask', filter_links INTEGER NOT NULL DEFAULT 1);----CREATE TABLE 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 | billing-external (announced by Billing, delivered here) | 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, anonymous INTEGER NOT NULL DEFAULT 0, hide_amount INTEGER NOT NULL DEFAULT 0, private_message INTEGER NOT NULL DEFAULT 0, erased_at TEXT, moderation TEXT NOT NULL DEFAULT 'visible', moderated_at TEXT, moderated_by TEXT, filtered INTEGER NOT NULL DEFAULT 0,-- UNIQUE (provider, provider_ref),-- UNIQUE (legacy_source)--);----CREATE TABLE 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 TABLE 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 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 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 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 TABLE 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 TABLE 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--, hidden INTEGER NOT NULL DEFAULT 0);----CREATE TABLE 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 TABLE 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 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 tip_moderators (-- creator_subject TEXT NOT NULL,-- moderator_subject TEXT NOT NULL,-- name TEXT, -- display name when they joined (for the creator's list)-- added_by TEXT NOT NULL, -- the creator, a service, or 'invite:<id>'-- created_at TEXT NOT NULL,-- removed_at TEXT,-- PRIMARY KEY (creator_subject, moderator_subject)--);----CREATE TABLE tip_moderator_invites (-- id TEXT PRIMARY KEY, -- tmin_<ULID>-- creator_subject TEXT NOT NULL,-- token_hash TEXT NOT NULL UNIQUE, -- sha256 of the link's secret (shown once)-- created_by TEXT NOT NULL,-- created_at TEXT NOT NULL,-- expires_at TEXT NOT NULL,-- used_at TEXT,-- used_by TEXT,-- revoked_at TEXT--);----CREATE TABLE tip_moderation_log (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- creator_subject TEXT NOT NULL,-- interaction_id TEXT NOT NULL,-- action TEXT NOT NULL CHECK (action IN ('held', 'filtered', 'hidden', 'restored')),-- by_role TEXT NOT NULL CHECK (by_role IN ('filter', 'creator', 'moderator', 'service')),-- actor TEXT, -- usr_… / svc:… ; null for the filter-- reason TEXT,-- created_at TEXT NOT NULL--);----CREATE TABLE 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--);----CREATE TABLE event_outbox (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- event_id TEXT NOT NULL UNIQUE,-- envelope TEXT NOT NULL,-- traceparent TEXT,-- created_at INTEGER NOT NULL,-- attempts INTEGER NOT NULL DEFAULT 0,-- next_attempt_at INTEGER NOT NULL DEFAULT 0,-- sent_at INTEGER,-- seq INTEGER,-- rejected_at INTEGER,-- last_error TEXT-- );----CREATE TABLE idempotency_receipts (-- consumer TEXT NOT NULL,-- event_id TEXT NOT NULL,-- processed_at INTEGER NOT NULL,-- PRIMARY KEY (consumer, event_id)-- );----CREATE INDEX idx_tint_creator ON tip_interactions (creator_subject, created_at);----CREATE INDEX idx_tint_supporter ON tip_interactions (supporter_subject, created_at);----CREATE INDEX idx_tint_pending ON tip_interactions (payment_state, funding);----CREATE INDEX idx_goal_creator ON tip_goals (creator_subject, status);----CREATE INDEX idx_ovc_creator ON overlay_configs (creator_subject);----CREATE INDEX idx_ovt_creator ON overlay_tokens (creator_subject);----CREATE INDEX idx_ovd_creator ON overlay_deliveries (creator_subject, seq);----CREATE INDEX idx_ovd_pending ON overlay_deliveries (seq) WHERE status = 'pending';----CREATE INDEX idx_effects_due ON interaction_effects (state, next_attempt_at);----CREATE INDEX idx_tmod_moderator ON tip_moderators (moderator_subject);----CREATE INDEX idx_tmodlog_creator ON tip_moderation_log (creator_subject, id);----CREATE INDEX idx_event_outbox_due ON event_outbox(sent_at, rejected_at, next_attempt_at);@@@ -27,7 +27,8 @@ const valkeyAvailable = () => !!process.env.OV_TEST_VALKEY_URL;async function testDb({ store = process.env.TIPS_TEST_STORE || 'pglite', max = 4 } = {}) {if (store !== 'pg') {const db = createDb({ pglite: true, service: 'tips-test', log: quiet });-- await db.migrate({ dir: MIGRATIONS, log: quiet });++ // windowDays 0: a test wants the final schema, not a contract held for its N-1 window.++ await db.migrate({ dir: MIGRATIONS, log: quiet, windowDays: 0 });return { db, store: 'pglite', close: () => db.close().catch(() => {}) };}if (!pgAvailable()) throw new Error('store pg needs OV_TEST_PG_URL and OV_TEST_PG_DIRECT_URL (openvibe-sdk scripts/test-services.sh up)');@@@ -62,7 +63,7 @@ async function testDb({ store = process.env.TIPS_TEST_STORE || 'pglite', max = 4let db;try {const ownerDb = createDb({ url: as(process.env.OV_TEST_PG_DIRECT_URL, owner), service: 'tips-test-migrate', max: 1, log: quiet });-- try { await ownerDb.migrate({ dir: MIGRATIONS, log: quiet }); } finally { await ownerDb.close(); }++ try { await ownerDb.migrate({ dir: MIGRATIONS, log: quiet, windowDays: 0 }); } finally { await ownerDb.close(); }db = createDb({ url: as(process.env.OV_TEST_PG_URL, name), service: 'tips-test', max, log: quiet });} catch (e) { await drop().catch(() => {}); throw e; } // a failed setup leaves nothing behindreturn {ddeleted file mode 100644@@@ -1,191 +0,0 @@--'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, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP); -- Live's column name-- 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, timestamp) 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(await t.db.value('SELECT count(*) FROM tip_interactions'), 0);-- assert.strictEqual(await t.db.value('SELECT count(*) FROM migration_maps'), 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.maybe('SELECT * FROM tip_interactions WHERE legacy_source = $1', [`live:transactions:${id}`]);-- const two = await 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((await byLegacy(1)).test, true, 'before stats_vibes_reset_at');-- const ghost = await byLegacy(3);-- assert.strictEqual(ghost.supporter_subject, null);-- assert.strictEqual(ghost.supporter_name, 'Ghost');-- assert.strictEqual((await byLegacy(6)).kind, 'media_request');-- assert.strictEqual((await byLegacy(8)).funding, 'provider');-- assert.strictEqual(await byLegacy(4), null, 'held, not dropped');-- const held = await t.db.maybe("SELECT * FROM migration_maps WHERE source_table = 'transactions' AND source_id = '4'");-- assert.strictEqual(held.status, 'held');-- const excluded = await t.db.maybe("SELECT * FROM migration_maps WHERE source_table = 'transactions' AND source_id = '5'");-- 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(await t.db.value('SELECT count(*) FROM interaction_effects'), 0);-- assert.strictEqual(await t.db.value('SELECT count(*) FROM overlay_deliveries'), 0);-- assert.strictEqual((await t.outboxRows('tips.interaction.ready')).length, 0);-- });---- await check('the refund nets out against the donation it undoes', async () => {-- const six = await t.db.maybe("SELECT * FROM tip_interactions WHERE legacy_source = 'live:transactions:6'");-- 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 = await t.db.maybe("SELECT * FROM tip_interactions WHERE legacy_source = 'live:chat_messages:2'");-- assert.strictEqual(ext.settlement, 'external');-- assert.strictEqual(ext.amount, 700);-- assert.strictEqual(ext.supporter_name, 'Direct');-- const twin = await t.db.maybe("SELECT * FROM migration_maps WHERE source_table = 'chat_messages' AND source_id = '1'");-- assert.strictEqual(twin.status, 'excluded');-- assert.match(twin.reason, /site-routed tip \(Live transaction 8\)/);-- assert.strictEqual(await t.db.value("SELECT count(*) FROM migration_maps WHERE source_table = 'chat_messages' AND source_id = '3'"), 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 = await t.db.maybe("SELECT * FROM tip_goals WHERE legacy_source = 'live:donation_goals:1'");-- const view = await domain.goals.present(t.db, 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 = await reconcile(domain, billingSnapshotSource(bdb));-- assert.strictEqual(r.ok, true, JSON.stringify(r.mismatches));-- assert.strictEqual((await domain.interactions.totals(t.db, subjects[1])).settled_via_billing, 740);-- assert.strictEqual((await domain.interactions.totals(t.db, subjects[1])).external, 700);-- });---- await check('a re-run changes nothing; a newly mapped creator is released from hold', async () => {-- const before = await t.db.value('SELECT count(*) FROM tip_interactions');-- const again = await run();-- assert.strictEqual(again.counts.imported, 0);-- assert.strictEqual(again.counts.goals, 0);-- assert.ok(again.counts.unchanged > 0);-- assert.strictEqual(await t.db.value('SELECT count(*) FROM tip_interactions'), 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 = await t.db.maybe("SELECT * FROM tip_interactions WHERE legacy_source = 'live:transactions:4'");-- assert.strictEqual(four.creator_subject, heldUser.subject);-- assert.strictEqual(four.billing_txn_id, 'txn_import_4');-- assert.strictEqual(await t.db.value("SELECT status FROM migration_maps WHERE source_table = 'transactions' AND source_id = '4'"), '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((await t.db.many("SELECT source_id FROM migration_maps WHERE source_table = 'transactions'")).map((x) => x.source_id));-- assert.deepStrictEqual(tipRows.filter((id) => !mapped.has(id)), []);-- const excluded = await t.db.many("SELECT * FROM migration_maps WHERE status = 'excluded'");-- assert.ok(excluded.every((x) => x.reason));-- });---- live.close(); bdb.close();-- await t.close();-- done();--})().catch((e) => { console.error(e); process.exit(1); });@@@ -1,18 +1,15 @@'use strict';// migrations/ (ADR-035, ADR-028): the files parse with their phase, apply in order, and apply again--// without change; the schema carries every column the SQLite release had (nothing lost in the import);--// every query shape the code runs can be served by an index; and no runtime file loads better-sqlite3.++// without change; a contract migration drops what an earlier expand left behind; and every query shape++// the code runs can be served by an index.const assert = require('assert');const fs = require('fs');const path = require('path');--const Database = require('better-sqlite3');const { createDb, sql } = require('openvibe-sdk/db');const { MIGRATIONS } = require('../server/db');--const { TABLES } = require('../scripts/migrate-to-postgres');const { check, done } = require('./helpers/app');const quiet = { log() {} };--const ROOT = path.join(__dirname, '..');/** The migration files as the SDK reads them: NNNN_name.sql with a phase header (and after: for a contract). */function parse(dir) {@@@ -38,17 +35,24 @@ function parse(dir) {});await check('they apply once, in order, and a second run changes nothing', async () => {-- const first = await db.migrate({ dir: MIGRATIONS, log: quiet });++ const first = await db.migrate({ dir: MIGRATIONS, log: quiet, windowDays: 0 });assert.deepStrictEqual(first.applied.map((m) => m.id), parse(MIGRATIONS).map((m) => m.id));const schema = async () => db.many(sql`SELECT table_name, column_name, data_type, is_nullable, column_default FROM information_schema.columnsWHERE table_schema = 'public' ORDER BY table_name, ordinal_position`);const before = await schema();-- const second = await db.migrate({ dir: MIGRATIONS, log: quiet });++ const second = await db.migrate({ dir: MIGRATIONS, log: quiet, windowDays: 0 });assert.deepStrictEqual([second.applied, second.held], [[], []]);assert.deepStrictEqual(await schema(), before, 'the schema is the same after a second run');assert.strictEqual(await db.value(sql`SELECT count(*) FROM ov_migrations`), parse(MIGRATIONS).length);});++ await check('the contract migration drops the legacy import columns and the map table', async () => {++ assert.deepStrictEqual(await db.many(sql`SELECT table_name FROM information_schema.columns++ WHERE table_schema = 'public' AND column_name = 'legacy_source'`), []);++ assert.deepStrictEqual(await db.many(sql`SELECT table_name FROM information_schema.tables++ WHERE table_schema = 'public' AND table_name = 'migration_maps'`), []);++ });++await check('every table has a primary key; money and time columns have their types', async () => {const noKey = await db.many(sql`SELECT c.relname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespaceWHERE n.nspname = 'public' AND c.relkind = 'r' AND NOT EXISTS (SELECT 1 FROM pg_index i WHERE i.indrelid = c.oid AND i.indisprimary)`);@@@ -69,27 +73,6 @@ function parse(dir) {assert.strictEqual(await type('api_idempotency', 'response'), 'text', 'replayed byte for byte');});-- await check('nothing the SQLite release stored is left behind: every source table and column has a target', async () => {-- const s = new Database(':memory:');-- s.exec(fs.readFileSync(path.join(__dirname, 'fixtures', 'sqlite-v3-schema.sql'), 'utf8'));-- const from = (target) => (TABLES[target] && TABLES[target].from) || target;-- const targets = new Map();-- for (const r of await db.many(sql`SELECT table_name, column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name <> 'ov_migrations'`)) {-- const src = from(r.table_name);-- if (!targets.has(src)) targets.set(src, new Set());-- targets.get(src).add(r.column_name);-- }-- const missing = [];-- for (const { name } of s.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all()) {-- if (name === 'settings') continue; // not carried on purpose: ov_migrations records the schema (0001_initial.sql)-- if (!targets.has(name)) { missing.push(name); continue; }-- for (const c of s.prepare(`PRAGMA table_info(${name})`).all()) if (!targets.get(name).has(c.name)) missing.push(`${name}.${c.name}`);-- }-- s.close();-- assert.deepStrictEqual(missing, [], 'no table or column dropped (dropColumns stays empty)');-- assert.ok(Object.values(TABLES).every((o) => !o.dropColumns), 'no dropColumns');-- });--await check('every query shape the code runs can be served by an index (no sequential scan of its table)', async () => {// With sequential scans switched off for the plan, a shape that has no usable index still plans a// Seq Scan (at a prohibitive cost): that is what this looks for.@@@ -125,7 +108,6 @@ function parse(dir) {'the weekly prune of stored answers': ['api_idempotency', sql`DELETE FROM api_idempotency WHERE created_at < now()`],'an erasure scrubbing outbox copies': ['tips_event_outbox', sql`SELECT id FROM tips_event_outbox WHERE envelope->'subject'->>'type' = 'interaction' AND envelope->'subject'->>'id' = ANY('{a}'::text[])`],'the rejected-events count': ['tips_event_outbox', sql`SELECT count(*) FROM tips_event_outbox WHERE rejected_at IS NOT NULL`],-- 'a Live goal through the import map': ['migration_maps', sql`SELECT target_id FROM migration_maps WHERE source = 'live' AND source_table = 'donation_goals' AND source_id = '12'`],};const scans = [];for (const [name, [table, q]] of Object.entries(shapes)) {@@@ -141,21 +123,6 @@ function parse(dir) {assert.deepStrictEqual(scans, []);});-- await check('no runtime file loads better-sqlite3 (only the import tools may)', async () => {-- const offenders = [];-- const walk = (dir) => {-- for (const e of fs.readdirSync(dir, { withFileTypes: true })) {-- const p = path.join(dir, e.name);-- if (e.isDirectory()) walk(p);-- else if (e.name.endsWith('.js') && /require\(['"]better-sqlite3['"]\)|\.prepare\(|db\.transaction\(/.test(fs.readFileSync(p, 'utf8').replace(/(live|bdb|snap)\.prepare\(/g, ''))) offenders.push(path.relative(ROOT, p));-- }-- };-- walk(path.join(ROOT, 'server'));-- assert.deepStrictEqual(offenders, []);-- const loaders = fs.readdirSync(path.join(ROOT, 'scripts')).filter((f) => /require\(['"]better-sqlite3['"]\)/.test(fs.readFileSync(path.join(ROOT, 'scripts', f), 'utf8')));-- assert.deepStrictEqual(loaders, ['import-live.js'], "the Live import reads Live's and Billing's snapshot files; migrate-to-postgres.js reads Tips' through importSqlite");-- });--await db.close();done();})().catch((e) => { console.error(e); process.exit(1); });ddeleted file mode 100644@@@ -1,189 +0,0 @@--'use strict';--// The one-time import (ADR-035; scripts/migrate-to-postgres.js): a SQLite file with the last SQLite--// release's schema (test/fixtures/sqlite-v3-schema.sql) and rows of every table goes through--// openvibe-sdk's importSqlite into the migrated schema: report.ok, every row counted and checksummed,--// values converted by column type (0/1 → boolean, ISO text → timestamptz, JSON text → jsonb), identities--// kept and continued, the SQLite file untouched; and the service then serves the imported data.--const assert = require('assert');--const crypto = require('crypto');--const fs = require('fs');--const os = require('os');--const path = require('path');--const { execFileSync } = require('child_process');--const Database = require('better-sqlite3');--const { ids } = require('openvibe-contracts');--const { importSqlite } = require('openvibe-sdk/db');--const { testDb } = require('./helpers/db');--const { boot, check, done } = require('./helpers/app');--const { importOptions } = require('../scripts/migrate-to-postgres');----const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'tips-import-'));--const quiet = { log() {} };--const creator = ids.newId('user');--const supporter = ids.newId('user');--const T = (s) => `2026-09-${s}Z`; // an ISO time, as the SQLite release wrote them----/** A SQLite file as the SQLite release left it: its schema and a few rows in every table. */--function fixture(file) {-- const s = new Database(file);-- s.exec(fs.readFileSync(path.join(__dirname, 'fixtures', 'sqlite-v3-schema.sql'), 'utf8'));-- const ins = (table, row) => s.prepare(`INSERT INTO ${table} (${Object.keys(row).join(', ')}) VALUES (${Object.keys(row).map(() => '?').join(', ')})`).run(...Object.values(row));-- ins('settings', { id: 1, schema_version: 3, created_at: T('01T00:00:00.000') });-- ins('creator_tip_profiles', {-- creator_subject: creator, handle: 'alex', display_name: 'Alex', page_enabled: 1, accepting: 1, tts_enabled: 0, media_requests_enabled: 1,-- revision: 4, created_at: T('01T10:00:00.000'), updated_at: T('02T11:30:00.250'),-- page_settings: '{"goal_amounts":false,"supporters_page":true}', filter_words: '["badword","two words"]', filter_action: 'hold', filter_links: 0,-- });-- const tip = (id, fields) => ins('tip_interactions', {-- id, creator_subject: creator, supporter_subject: supporter, supporter_name: 'Viewer', kind: 'tip', amount: 250, funding: 'credit', settlement: 'billing',-- payment_state: 'settled', delivery_state: 'delivered', created_at: T('03T12:00:00.000'), updated_at: T('03T12:00:01.000'), settled_at: T('03T12:00:00.500'), ...fields,-- });-- tip('tint_01', { billing_txn_id: 'txn_1', request: '{"goal_id":"tgoal_01"}', target: '{"service":"live","type":"stream","id":"42"}', message: 'great stream' });-- // Two things SQLite took and PostgreSQL does not: a NUL in text, an unpaired surrogate inside JSON.-- tip('tint_02', { message: 'nul\u0000here', request: '{"legacy_message":"cut \\ud83d","n":1}', billing_txn_id: 'txn_2', payment_state: 'reversed', reversal_txn_ids: '["txn_9"]', reversed_bits: 100, reversed_at: T('04T09:00:00.000'), anonymous: 1, hide_amount: 1, test: 0 });-- tip('tint_03', { kind: 'paid_message', amount: 500, message: 'hello chat', funding: 'checkout', payment_state: 'pending', delivery_state: 'awaiting_payment',-- settled_at: null, billing_intent_id: 'pi_1', checkout_url: 'https://checkout.example/pi_1', idempotency_key: 'api:usr:k1', transfer_due: 1, transfer_attempts: 2, next_transfer_at: 1790600000123 });-- tip('tint_04', { supporter_subject: null, supporter_name: null, erased_at: T('05T08:00:00.000'), moderation: 'hidden', moderated_by: 'filter', filtered: 1, test: 1, origin: 'import',-- settlement: 'imported', legacy_source: 'live:transactions:7', provider: 'powerchat', provider_ref: 'live-chat:3', request: '{}' });-- ins('tip_goals', { id: 'tgoal_01', creator_subject: creator, title: 'Desk', target_amount: 1000, status: 'active', opening_amount: 50, created_at: T('02T00:00:00.000'), updated_at: T('02T00:00:00.000'), reached_at: null });-- ins('tip_goal_contributions', { id: 7, goal_id: 'tgoal_01', interaction_id: 'tint_01', amount: 250, reversed_amount: 0, created_at: T('03T12:00:00.600'), updated_at: T('03T12:00:00.600') });-- ins('tip_goal_contributions', { id: 12, goal_id: 'tgoal_01', interaction_id: 'tint_02', amount: 250, reversed_amount: 100, created_at: T('03T12:00:00.700'), updated_at: T('04T09:00:00.000') });-- ins('paid_messages', { id: 'tpm_01', interaction_id: 'tint_01', creator_subject: creator, kind: 'paid_message', text: 'hello', highlight_seconds: 30, status: 'delivered', chat_ref: 'null', test: 0, created_at: T('03T12:00:01.000'), updated_at: T('03T12:00:02.000') });-- ins('paid_media_requests', { id: 'tmr_01', interaction_id: 'tint_02', creator_subject: creator, url: 'https://youtu.be/x', provider: 'youtube', status: 'accepted', queue_ref: '{"queue_id":5}', created_at: T('03T12:00:01.000'), updated_at: T('03T12:00:02.000') });-- ins('overlay_configs', { id: 'tovc_01', creator_subject: creator, kind: 'alerts', name: 'Alerts', settings: '{"min_amount":1,"sound_url":null}', revision: 2, created_at: T('02T00:00:00.000'), updated_at: T('02T00:00:00.000') });-- ins('overlay_tokens', { id: 'tovt_01', creator_subject: creator, token_hash: 'a'.repeat(64), scopes: '["alerts","goals"]', config_id: 'tovc_01', created_by: creator, created_at: T('02T00:00:00.000'), revoked_at: T('06T00:00:00.000') });-- ins('overlay_deliveries', { seq: 3, id: 'tovd_03', creator_subject: creator, kind: 'alert', interaction_id: 'tint_01', payload: '{"interaction_id":"tint_01","amount":250}', status: 'delivered', sends: 4, dedupe_key: 'alert:tint_01', created_at: T('03T12:00:01.000'), delivered_at: T('03T12:00:01.500'), hidden: 0 });-- ins('overlay_deliveries', { seq: 9, id: 'tovd_09', creator_subject: creator, kind: 'goal', goal_id: 'tgoal_01', payload: '{"goal":{"id":"tgoal_01"},"by":null}', status: 'pending', dedupe_key: 'goal:tgoal_01:r2', created_at: T('03T12:00:01.000'), hidden: 1 });-- ins('interaction_effects', { id: 4, interaction_id: 'tint_01', effect: 'chat_line', adapter: 'live-chat', state: 'delivered', attempts: 1, result: '{"ref":{"chat_message_id":1}}', created_at: T('03T12:00:01.000'), updated_at: T('03T12:00:02.000') });-- ins('interaction_effects', { id: 5, interaction_id: 'tint_01', effect: 'overlay_alert', adapter: 'overlay', state: 'delivered', result: 'null', created_at: T('03T12:00:01.000'), updated_at: T('03T12:00:01.000') });-- ins('interaction_effects', { id: 6, interaction_id: 'tint_03', effect: 'paid_message', adapter: 'live-chat', state: 'queued', attempts: 2, next_attempt_at: 1790600000999, last_error: 'Live 503', created_at: T('03T12:00:01.000'), updated_at: T('03T12:00:01.000') });-- ins('migration_maps', { id: 2, source: 'live', source_table: 'transactions', source_id: '7', target_type: 'interaction', target_id: 'tint_04', status: 'imported', run_id: 'timp_1', created_at: T('01T00:00:00.000'), updated_at: T('01T00:00:00.000') });-- ins('import_runs', { id: 'timp_1', source: 'live', dry_run: 0, started_at: T('01T00:00:00.000'), finished_at: T('01T00:00:05.000'), report: '{"counts":{"imported":1},"holds":[]}' });-- ins('tip_moderators', { creator_subject: creator, moderator_subject: supporter, name: 'Mod', added_by: 'invite:tmin_1', created_at: T('02T00:00:00.000') });-- ins('tip_moderator_invites', { id: 'tmin_1', creator_subject: creator, token_hash: 'b'.repeat(64), created_by: creator, created_at: T('02T00:00:00.000'), expires_at: T('09T00:00:00.000'), used_at: T('02T01:00:00.000'), used_by: supporter });-- ins('tip_moderation_log', { id: 3, creator_subject: creator, interaction_id: 'tint_04', action: 'hidden', by_role: 'moderator', actor: supporter, reason: 'abuse', created_at: T('05T08:00:00.000') });-- // A stored answer is replayed byte for byte: its text keeps its key order and spacing.-- ins('api_idempotency', { key: `${supporter}:tip-key-0001`, request_hash: 'h1', method: 'POST', path: '/api/v1/checkout', status: 201, response: '{"z":1,"a":{"b":2}}', created_at: T('03T12:00:00.000') });-- ins('event_outbox', { id: 1, event_id: 'evt_01', envelope: '{"event_type":"tips.interaction.ready","subject":{"type":"interaction","id":"tint_01"},"payload":{"supporter":null}}', created_at: 1790500000000, attempts: 1, sent_at: 1790500001000, seq: 17 });-- ins('event_outbox', { id: 2, event_id: 'evt_02', envelope: '{"event_type":"tips.goal.updated","subject":{"type":"goal","id":"tgoal_01"},"payload":{}}', created_at: 1790500002000, attempts: 3, next_attempt_at: 1790500999000, last_error: 'timeout' });-- ins('idempotency_receipts', { consumer: 'tips-billing', event_id: 'evt_billing_1', processed_at: 1790500000500 });-- s.close();-- return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');--}----(async () => {-- const file = path.join(tmp, 'tips.db');-- const hash = fixture(file);---- const imported = await testDb({ store: 'pglite' });-- const db = imported.db;-- let report;-- const options = await importOptions(db);-- const TABLES = options.tables;-- await check('the importer copies every table, verified by counts and checksums (report.ok)', async () => {-- report = await importSqlite({ sqlite: file, db, truncate: true, tables: TABLES, log: quiet });-- assert.strictEqual(report.ok, true, JSON.stringify(report.problems));-- const rows = Object.fromEntries(report.tables.map((t) => [t.table, t.rows]));-- assert.deepStrictEqual(rows, {-- creator_tip_profiles: 1, tip_interactions: 4, tip_goals: 1, tip_goal_contributions: 2, paid_messages: 1, paid_media_requests: 1,-- overlay_configs: 1, overlay_tokens: 1, overlay_deliveries: 2, interaction_effects: 3, migration_maps: 1, import_runs: 1,-- tip_moderators: 1, tip_moderator_invites: 1, tip_moderation_log: 1, api_idempotency: 1, tips_event_outbox: 2, tips_event_inbox: 1,-- });-- assert.ok(report.tables.every((t) => /^[0-9a-f]{16}$/.test(t.checksum)), 'a checksum per table');-- const renamed = report.tables.filter((t) => t.source !== t.table).map((t) => `${t.source} → ${t.table}`).sort();-- assert.deepStrictEqual(renamed, ['event_outbox → tips_event_outbox', 'idempotency_receipts → tips_event_inbox']);-- assert.strictEqual(crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'), hash, 'the SQLite file is unchanged');-- });---- await check('values are converted by column type', async () => {-- const p = await db.one('SELECT * FROM creator_tip_profiles');-- assert.deepStrictEqual([p.page_enabled, p.tts_enabled, p.filter_links, p.revision], [true, false, false, 4]);-- assert.deepStrictEqual(p.page_settings, { goal_amounts: false, supporters_page: true });-- assert.deepStrictEqual(p.filter_words, ['badword', 'two words']);-- assert.strictEqual(p.updated_at, T('02T11:30:00.250'), 'the same ISO time the API sent before');-- const i = await db.one("SELECT * FROM tip_interactions WHERE id = 'tint_01'");-- assert.deepStrictEqual(i.request, { goal_id: 'tgoal_01' });-- assert.deepStrictEqual(i.target, { service: 'live', type: 'stream', id: '42' });-- assert.deepStrictEqual([i.test, i.anonymous, i.transfer_due, i.amount], [false, false, false, 250]);-- assert.deepStrictEqual(i.reversal_txn_ids, []);-- const r = await db.one("SELECT * FROM tip_interactions WHERE id = 'tint_02'");-- assert.deepStrictEqual([r.anonymous, r.hide_amount, r.reversed_bits, r.reversal_txn_ids], [true, true, 100, ['txn_9']]);-- assert.strictEqual(r.message, 'nulhere', 'a NUL is dropped (PostgreSQL text cannot hold it)');-- assert.deepStrictEqual(r.request, { legacy_message: 'cut \ufffd', n: 1 }, 'an unpaired surrogate becomes U+FFFD (jsonb refuses it)');-- assert.deepStrictEqual(options.cleaned().sort((a, b) => a.column.localeCompare(b.column)),-- [{ column: 'tip_interactions.message', values: 1 }, { column: 'tip_interactions.request', values: 1 }], 'and the report says where');-- const pending = await db.one("SELECT * FROM tip_interactions WHERE id = 'tint_03'");-- assert.deepStrictEqual([pending.transfer_due, pending.transfer_attempts, pending.next_transfer_at, pending.settled_at], [true, 2, 1790600000123, null]);-- const erased = await db.one("SELECT * FROM tip_interactions WHERE id = 'tint_04'");-- assert.deepStrictEqual([erased.test, erased.filtered, erased.erased_at, erased.target], [true, true, T('05T08:00:00.000'), null]);-- assert.strictEqual(await db.value("SELECT chat_ref FROM paid_messages WHERE id = 'tpm_01'"), null, "JSON text 'null' → NULL (no reference)");-- assert.deepStrictEqual(await db.value("SELECT queue_ref FROM paid_media_requests WHERE id = 'tmr_01'"), { queue_id: 5 });-- assert.deepStrictEqual((await db.many('SELECT id, result FROM interaction_effects ORDER BY id')).map((e) => [e.id, e.result]),-- [[4, { ref: { chat_message_id: 1 } }], [5, null], [6, null]]);-- assert.deepStrictEqual(await db.value("SELECT scopes FROM overlay_tokens"), ['alerts', 'goals']);-- assert.deepStrictEqual((await db.many('SELECT seq, hidden, test FROM overlay_deliveries ORDER BY seq')).map((d) => [d.seq, d.hidden, d.test]), [[3, false, false], [9, true, false]]);-- assert.strictEqual(await db.value('SELECT response FROM api_idempotency'), '{"z":1,"a":{"b":2}}', 'a stored answer stays byte for byte');-- assert.strictEqual(await db.value('SELECT dry_run FROM import_runs'), false);-- const out = await db.many('SELECT id, envelope, sent_at, next_attempt_at FROM tips_event_outbox ORDER BY id');-- assert.deepStrictEqual(out.map((o) => [o.id, o.envelope.event_type, o.sent_at, o.next_attempt_at]), [[1, 'tips.interaction.ready', 1790500001000, 0], [2, 'tips.goal.updated', null, 1790500999000]]);-- assert.deepStrictEqual(await db.one('SELECT * FROM tips_event_inbox'), { consumer: 'tips-billing', event_id: 'evt_billing_1', processed_at: 1790500000500 });-- });---- await check('identities keep their values and continue after the imported maximum', async () => {-- const at = new Date().toISOString();-- assert.strictEqual(await db.value(`INSERT INTO overlay_deliveries (id, creator_subject, kind, payload, status, dedupe_key, created_at)-- VALUES ('tovd_new', $1, 'alert', '{}', 'pending', 'alert:new', $2) RETURNING seq`, [creator, at]), 10, 'the SSE ids go on from 9');-- assert.strictEqual(await db.value(`INSERT INTO interaction_effects (interaction_id, effect, adapter, state, created_at, updated_at)-- VALUES ('tint_02', 'chat_line', 'test', 'queued', $1, $1) RETURNING id`, [at]), 7);-- assert.strictEqual(await db.value(`INSERT INTO tip_goal_contributions (goal_id, interaction_id, amount, created_at, updated_at)-- VALUES ('tgoal_01', 'tint_03', 5, $1, $1) RETURNING id`, [at]), 13);-- assert.strictEqual(await db.value("INSERT INTO tips_event_outbox (event_id, envelope, created_at) VALUES ('evt_03', '{}', 1) RETURNING id"), 3);-- });---- await check('a rehearsal can be repeated (truncate), and JSON null verifies without a mapping', async () => {-- const again = await importSqlite({ sqlite: file, db, truncate: true, tables: TABLES, log: quiet });-- assert.strictEqual(again.ok, true, JSON.stringify(again.problems));-- // openvibe-sdk ≥ 0.18 counts JSON null and SQL NULL as equal in verification, so the import is verified-- // even without Tips' own 'null' mapping (which stays: it stores SQL NULL, not a JSON null).-- const plain = await importSqlite({ sqlite: file, db, truncate: true, tables: { ...TABLES, paid_messages: {} }, only: ['paid_messages', 'tip_interactions', 'creator_tip_profiles'], log: quiet });-- assert.strictEqual(plain.ok, true, JSON.stringify(plain.problems));-- await importSqlite({ sqlite: file, db, truncate: true, tables: TABLES, log: quiet });-- });---- await check('the service serves the imported data as it did from SQLite', async () => {-- const t = await boot({ db });-- const who = { subject: supporter, username: 'viewer' };-- const r = await t.call('GET', '/api/v1/interactions', { user: who });-- assert.strictEqual(r.status, 200, r.text);-- const byId = Object.fromEntries(r.json.interactions.map((i) => [i.id, i]));-- assert.deepStrictEqual(Object.keys(byId).sort(), ['tint_01', 'tint_02', 'tint_03']);-- assert.strictEqual(byId.tint_01.created_at, T('03T12:00:00.000'));-- assert.strictEqual(byId.tint_01.payment.settled_at, T('03T12:00:00.500'));-- assert.deepStrictEqual(byId.tint_02.payment.reversal_txn_ids, ['txn_9']);-- assert.strictEqual(byId.tint_02.public.supporter_name, 'Anonymous');-- const goal = (await t.call('GET', '/api/v1/goals/tgoal_01', { user: { subject: creator, username: 'alex' } })).json.goal;-- assert.strictEqual(goal.current_amount, 50 + 250 + 150, 'opening amount plus what the contributions keep');-- const replay = await t.call('POST', '/api/v1/checkout', { user: who, key: 'tip-key-0001', body: {} });-- assert.strictEqual(replay.status, 422, 'a different request under a stored key is refused');-- assert.strictEqual(await t.db.value('SELECT count(*) FROM tips_event_inbox'), 1);-- await t.close();-- });---- await check('scripts/migrate-to-postgres.js: OK exits 0; a column the schema lacks is a problem and exits 1', async () => {-- const out = execFileSync(process.execPath, [path.join(__dirname, '..', 'scripts', 'migrate-to-postgres.js'), '--pglite', '--sqlite', file], { encoding: 'utf8' });-- assert.match(out, /→ pglite \(\d+ ms\): OK/);-- assert.match(out, /tips_event_outbox\s+2 rows\s+[0-9a-f]{16}\s+\(from event_outbox\)/);-- assert.match(out, /cleaned: tip_interactions\.message: 1 value\(s\) with a NUL or an unpaired surrogate/);-- const extra = path.join(tmp, 'extra.db');-- fs.copyFileSync(file, extra);-- const s = new Database(extra); s.exec('ALTER TABLE tip_goals ADD COLUMN mystery TEXT'); s.close();-- let code = 0; let text = '';-- try { execFileSync(process.execPath, [path.join(__dirname, '..', 'scripts', 'migrate-to-postgres.js'), '--pglite', '--sqlite', extra], { encoding: 'utf8' }); } catch (e) { code = e.status; text = e.stdout; }-- assert.strictEqual(code, 1);-- assert.match(text, /problem: tip_goals: source columns with no target column .*mystery/);-- });---- await imported.close();-- fs.rmSync(tmp, { recursive: true, force: true });-- done();--})().catch((e) => { console.error(e); process.exit(1); });
@@ -17,16 +17,12 @@ TRUST_PROXY=1
@@ -17,16 +17,12 @@ TRUST_PROXY=1
# an embedded PGlite database in data/pglite (one process). Tests need neither (PGlite, or the containers
# an embedded PGlite database in data/pglite (one process). Tests need neither (PGlite, or the containers
# of node_modules/openvibe-sdk/scripts/test-services.sh).
# of node_modules/openvibe-sdk/scripts/test-services.sh).
DATABASE_URL=
DATABASE_URL=
-# DATABASE_DIRECT_URL: the owner role on a direct connection, for the migrations at boot and
-# scripts/migrate-to-postgres.js.
+# DATABASE_DIRECT_URL: the owner role on a direct connection, for the migrations at boot.
DATABASE_DIRECT_URL=
DATABASE_DIRECT_URL=
# VALKEY_URL: shared, never authoritative state (per-actor limits, overlay fan-out and stream slots).
# VALKEY_URL: shared, never authoritative state (per-actor limits, overlay fan-out and stream slots).
# Unset: kept in each process, right for one process only.
# Unset: kept in each process, right for one process only.
VALKEY_URL=
VALKEY_URL=
VALKEY_PREFIX=ov:tips:
VALKEY_PREFIX=ov:tips:
-# The SQLite file of the release before PostgreSQL: read (never written) only by
-# scripts/migrate-to-postgres.js. The systemd unit sets /var/lib/openvibe-tips/tips.db.
-#TIPS_DB_PATH=./data/tips.db
# ── Identity: OpenVibe.Network ──────────────────────────────
# ── Identity: OpenVibe.Network ──────────────────────────────
OV_NETWORK_URL=https://openvibe.network
OV_NETWORK_URL=https://openvibe.network
@@ -3,10 +3,9 @@
@@ -3,10 +3,9 @@
> Creator support: tips, goals, paid messages, TTS and media requests, overlays.
> Creator support: tips, goals, paid messages, TTS and media requests, overlays.
**Status:** alpha — runtime built and tested against stubs (roadmap Wave 9). Deployed internally, not
**Status:** alpha — runtime built and tested against stubs (roadmap Wave 9). Deployed internally, not
-launched: running on the host since 2026-09-23 on `127.0.0.1:4610` only, holding the Live import and no
+launched: running on the host since 2026-09-23 on `127.0.0.1:4610` only, with no
creator profile yet, with no public route. OpenVibe.Live remains where tips happen until the Billing cutover (see *What waits*).
creator profile yet, with no public route. OpenVibe.Live remains where tips happen until the Billing cutover (see *What waits*).
-Data: PostgreSQL through PgBouncer, and Valkey for shared state ([ADR-035](https://github.com/OpenVibers/OpenVibe.Contracts/blob/main/docs/adr/ADR-035-postgresql-and-valkey.md));
-production runs the SQLite release until the switch in *Deploy*.
+Data: PostgreSQL through PgBouncer, and Valkey for shared state ([ADR-035](https://github.com/OpenVibers/OpenVibe.Contracts/blob/main/docs/adr/ADR-035-postgresql-and-valkey.md));
**Domain:** `openvibe.tips` (keeps its OpenVibe.Sites placeholder until the launch rule below holds)
**Domain:** `openvibe.tips` (keeps its OpenVibe.Sites placeholder until the launch rule below holds)
**Port / service id:** 4610 / `tips`
**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.
**Decision:** [ADR-012](https://github.com/OpenVibers/OpenVibe.Contracts/blob/main/docs/adr/ADR-012-economic-classification.md) — Tips never moves money; Billing does.
@@ -22,7 +21,7 @@ from *interaction delivered*.
@@ -22,7 +21,7 @@ from *interaction delivered*.
## Owns
## Owns
- `tip_interactions`, `creator_tip_profiles`, `tip_goals`, `tip_goal_contributions`, `paid_messages`,
- `tip_interactions`, `creator_tip_profiles`, `tip_goals`, `tip_goal_contributions`, `paid_messages`,
- `paid_media_requests`, `overlay_configs`, `overlay_deliveries`, `interaction_effects`, `migration_maps`
+ `paid_media_requests`, `overlay_configs`, `overlay_deliveries`, `interaction_effects`
(its own PostgreSQL database `ov_tips`, schema in [migrations/](migrations/), access in `server/db.js`) —
(its own PostgreSQL database `ov_tips`, schema in [migrations/](migrations/), access in `server/db.js`) —
interaction state and **references** to Billing transaction ids, never a mutable cash balance
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)
- revocable scoped overlay tokens (`overlay_tokens`, hashed at rest; never creator cookies)
@@ -45,7 +44,7 @@ from *interaction delivered*.
@@ -45,7 +44,7 @@ from *interaction delivered*.
PowerChat, once Billing receives the PowerChat webhook)
PowerChat, once Billing receives the PowerChat webhook)
- **OpenVibe.Events** — Billing's events in (signed webhook + openvibe-sdk inbox), Tips' events out
- **OpenVibe.Events** — Billing's events in (signed webhook + openvibe-sdk inbox), Tips' events out
(openvibe-sdk transactional outbox)
(openvibe-sdk transactional outbox)
-- **OpenVibe.Network** — SSO for people, service tokens, JWKS, identity resolve (importer)
+- **OpenVibe.Network** — SSO for people, service tokens, JWKS, identity resolve
- **OpenVibe.Live** — chat delivery through `/internal/tips/deliveries` (deployed in Live since `f11f809`; [docs/live-patch.diff](docs/live-patch.diff) is the original patch) until OpenVibe.Chat exposes paid messages; overlay consumer
- **OpenVibe.Live** — chat delivery through `/internal/tips/deliveries` (deployed in Live since `f11f809`; [docs/live-patch.diff](docs/live-patch.diff) is the original patch) until OpenVibe.Chat exposes paid messages; overlay consumer
- **OpenVibe.Shared** v1.25.0 (app icon, SSR footer, noscript nav, release manifest, legal pages) and
- **OpenVibe.Shared** v1.25.0 (app icon, SSR footer, noscript nav, release manifest, legal pages) and
the Network's `navbar.js`; **openvibe-contracts** v0.76.0 and **openvibe-sdk** v0.18.0 (service tokens,
the Network's `navbar.js`; **openvibe-contracts** v0.76.0 and **openvibe-sdk** v0.18.0 (service tokens,
@@ -65,7 +64,6 @@ Called elsewhere, as the service principal `tips`:
@@ -65,7 +64,6 @@ Called elsewhere, as the service principal `tips`:
|---|---|---|
|---|---|---|
| OpenVibe.Billing | `billing.intent.create`, `billing.transfer.create` | checkout, and a tip from credit |
| OpenVibe.Billing | `billing.intent.create`, `billing.transfer.create` | checkout, and a tip from credit |
| OpenVibe.Events | `events.event.publish`; `events.subscription.manage` once (`npm run subscribe`) | the outbox relay; the `billing.transaction.*` and `billing.receipt.*` subscriptions |
| OpenVibe.Events | `events.event.publish`; `events.subscription.manage` once (`npm run subscribe`) | the outbox relay; the `billing.transaction.*` and `billing.receipt.*` subscriptions |
-| OpenVibe.Network | `identity.subject.resolve` | the Live importer's creator mapping |
| OpenVibe.Live | `live.tips_delivery.write` (only with `TIPS_CHAT_ADAPTER=live-chat`) | chat lines and alerts through `/internal/tips/deliveries` |
| OpenVibe.Live | `live.tips_delivery.write` (only with `TIPS_CHAT_ADAPTER=live-chat`) | chat lines and alerts through `/internal/tips/deliveries` |
## Run it
## Run it
@@ -79,8 +77,6 @@ eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)" # PostgreSQL 1
@@ -79,8 +77,6 @@ eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)" # PostgreSQL 1
npm test # … now also test/integration.test.js on the containers
npm test # … now also test/integration.test.js on the containers
npm run test:pg # every test file through PgBouncer and Valkey
npm run test:pg # every test file through PgBouncer and Valkey
npm run subscribe # create the billing.transaction.* and billing.receipt.* subscriptions in OpenVibe.Events
npm run subscribe # create the billing.transaction.* and billing.receipt.* subscriptions in OpenVibe.Events
-node scripts/import-live.js --live-db <live snapshot> --billing-db <billing snapshot> [--dry-run] [--json]
-npm run migrate-to-postgres -- [--sqlite <copy>] [--pglite] [--json] # the one-time SQLite import (Deploy)
```
```
Production (deployed, loopback only): `/opt/openvibe.tips`, env `/etc/openvibe/tips.env`, unit
Production (deployed, loopback only): `/opt/openvibe.tips`, env `/etc/openvibe/tips.env`, unit
@@ -140,8 +136,8 @@ minus what Billing took back) — the number that must equal Billing's books —
@@ -140,8 +136,8 @@ minus what Billing took back) — the number that must equal Billing's books —
(settings changes and per-creator limits), the interaction row, its goals by id, the creator's overlay
(settings changes and per-creator limits), the interaction row, its goals by id, the creator's overlay
stream (an advisory lock, so a creator's deliveries commit in seq order), then the rest. Serialization
stream (an advisory lock, so a creator's deliveries commit in seq order), then the rest. Serialization
failures and deadlocks are retried by the data layer.
failures and deadlocks are retried by the data layer.
-- Text is made storable on the way in: PostgreSQL keeps no NUL character, and jsonb no unpaired surrogate
- (SQLite kept both), so they are dropped or replaced (U+FFFD) in what people and Billing's events send.
+- Text is made storable on the way in: PostgreSQL keeps no NUL character, and jsonb no unpaired surrogate,
+ so they are dropped or replaced (U+FFFD) in what people and Billing's events send.
- **Background work runs in every process** and claims its rows: due effects and due transfers with a
- **Background work runs in every process** and claims its rows: due effects and due transfers with a
lease (`FOR UPDATE SKIP LOCKED`, the lease in `next_attempt_at` / `next_transfer_at`), the overlay
lease (`FOR UPDATE SKIP LOCKED`, the lease in `next_attempt_at` / `next_transfer_at`), the overlay
window sweep with `SKIP LOCKED`, the outbox relay by the SDK's lease; prunes are idempotent deletes.
window sweep with `SKIP LOCKED`, the outbox relay by the SDK's lease; prunes are idempotent deletes.
@@ -269,8 +265,8 @@ Consumed:
@@ -269,8 +265,8 @@ Consumed:
`donor_name` — null when `anonymous` — `message`, `provider`, `provider_event_id`, `app_purpose`/`app_ref`,
`donor_name` — null when `anonymous` — `message`, `provider`, `provider_event_id`, `app_purpose`/`app_ref`,
`test`) is sent by Billing only once it is the money authority (`BILLING_AUTHORITY=billing` in
`test`) is sent by Billing only once it is the money authority (`BILLING_AUTHORITY=billing` in
billing.env); before that Live's own PowerChat webhook announces those tips, so nothing is announced
billing.env); before that Live's own PowerChat webhook announces those tips, so nothing is announced
-twice. A `goal:<id>` in `app_purpose`/`app_ref` picks the goal: a Tips goal id, or a Live `donation_goals`
-id mapped by the Live import; otherwise Live's rule (the only active goal).
+twice. A `goal:<id>` in `app_purpose`/`app_ref` picks the goal (a Tips goal id); otherwise the creator's
+only active goal.
## Overlays
## Overlays
@@ -358,22 +354,6 @@ Never limited: `/api/health`, `/api/ready`, `/release.json`, `/metrics`, the ove
@@ -358,22 +354,6 @@ Never limited: `/api/health`, `/api/ready`, `/release.json`, `/metrics`, the ove
`TIPS_OVERLAY_MAX_STREAMS`) and the signed Events deliveries at `/internal/events`. `test/actor-limits.test.js`.
`TIPS_OVERLAY_MAX_STREAMS`) and the signed Events deliveries at `/internal/events`. `test/actor-limits.test.js`.
With `VALKEY_URL` every process counts one caller together (`test/integration.test.js`).
With `VALKEY_URL` every process counts one caller together (`test/integration.test.js`).
-## Import and reconciliation
-
-`scripts/import-live.js` reads a **copy** of Live's database (read-only) after Billing's own import, and
-a copy of Billing's database for the links and totals:
-
-- `transactions` donations → interactions (`settlement imported`), linked to Billing by its importer key
- `import:live:txn:<live id>`; non-completed rows excluded with the reason; rows before
- `stats_vibes_reset_at` flagged test (as Billing flags them); unmapped creators **held**, released by a
- later run; Live refunds applied to the donation they undo
-- PowerChat donations recorded only in `chat_messages` → EXTERNAL interactions; the celebration line of a
- site-routed `pcdon` tip is matched to its transaction and not counted twice
-- `donation_goals` → goals, Live's `current_amount` carried over as `opening_amount`
-- reconciliation: per creator, Tips' `settled_via_billing` must equal Billing's (donations to them minus
- refunds from them, non-test); mismatches are listed and the exit code is 1. Every source row ends up
- imported, excluded with a reason, or held (`migration_maps`).
-
## Acceptance (must be true before "done")
## Acceptance (must be true before "done")
| Criterion | Evidence |
| Criterion | Evidence |
@@ -381,12 +361,11 @@ a copy of Billing's database for the links and totals:
@@ -381,12 +361,11 @@ a copy of Billing's database for the links and totals:
| 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) |
| 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) |
| 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** |
| 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) |
+| creator totals reconcile exactly to Billing | `test/api.test.js` (totals = the stub Billing payable) |
| simulation never counted; token revocation immediate; goals from settled only; reversal keeps the delivery record | `test/overlays.test.js`, `test/api.test.js`, `test/settlement.test.js` |
| simulation never counted; token revocation immediate; goals from settled only; reversal keeps the delivery record | `test/overlays.test.js`, `test/api.test.js`, `test/settlement.test.js` |
-| an EXTERNAL PowerChat tip Billing announced is celebrated once: Live chat line, overlay, goal; never Billing money | `test/external.test.js` (redelivery and republish, same key as `POST /interactions/external`, Tips and Live goal ids, anonymous, test receipts, malformed payloads) |
+| an EXTERNAL PowerChat tip Billing announced is celebrated once: Live chat line, overlay, goal; never Billing money | `test/external.test.js` (redelivery and republish, same key as `POST /interactions/external`, Tips goal ids, anonymous, test receipts, malformed payloads) |
| paid messages are filtered before they are shown or read; the creator and their moderators hide and show them without touching the money | `test/moderation.test.js` (mask, hold and release, invisible/full-width evasion, TTS text, invitation links, hide cancels queued chat/TTS and retracts the overlay live/replay/state, restore, pending payments, who may moderate, event payloads) |
| paid messages are filtered before they are shown or read; the creator and their moderators hide and show them without touching the money | `test/moderation.test.js` (mask, hold and release, invisible/full-width evasion, TTS text, invitation links, hide cancels queued chat/TTS and retracts the overlay live/replay/state, restore, pending payments, who may moderate, event payloads) |
| the suite passes on PostgreSQL behind PgBouncer in transaction mode; one settlement or reversal raced across two processes counts once; the unpaid-checkout limit holds under concurrency; leased work runs once; flushing Valkey changes no money answer (ADR-007 2026-09-24, ADR-035) | `npm run test:pg`; `test/integration.test.js` (two app instances on one database and one Valkey) |
| the suite passes on PostgreSQL behind PgBouncer in transaction mode; one settlement or reversal raced across two processes counts once; the unpaid-checkout limit holds under concurrency; leased work runs once; flushing Valkey changes no money answer (ADR-007 2026-09-24, ADR-035) | `npm run test:pg`; `test/integration.test.js` (two app instances on one database and one Valkey) |
-| the SQLite data moves whole: every table and column, verified by counts and checksums | `test/sqlite-import.test.js`, `test/migrations.test.js` (no column left behind) |
| a supporter's privacy holds everywhere; the creator chooses what public pages show; export and erasure keep the books reconciled | `test/privacy.test.js` (anonymous / hidden amount / private message across API, overlays, chat jobs, pages and events; goal and supporters page settings; export; erasure scrubs rows, overlay payloads, stored answers and unsent events, emits `tips.interaction.erased`, totals still equal Billing) |
| a supporter's privacy holds everywhere; the creator chooses what public pages show; export and erasure keep the books reconciled | `test/privacy.test.js` (anonymous / hidden amount / private message across API, overlays, chat jobs, pages and events; goal and supporters page settings; export; erasure scrubs rows, overlay payloads, stored answers and unsent events, emits `tips.interaction.erased`, totals still equal Billing) |
## What waits
## What waits
@@ -418,23 +397,6 @@ Rollback: ovhost puts the previous sha back by itself when `/api/ready` does not
@@ -418,23 +397,6 @@ Rollback: ovhost puts the previous sha back by itself when `/api/ready` does not
restart; afterwards `sudo ovhost rollback tips --to <sha>`. Migrations are expand-only until a
restart; afterwards `sudo ovhost rollback tips --to <sha>`. Migrations are expand-only until a
`contract` migration, which waits out the 7-day N-1 window.
`contract` migration, which waits out the 7-day N-1 window.
-**The switch from SQLite to PostgreSQL** (once; the procedure is the SDK's
-[migrating-to-postgresql.md](https://github.com/OpenVibers/OpenVibe.SDK/blob/main/docs/migrating-to-postgresql.md), section 6):
-1. `sudo /opt/openvibe.host/roles/data/add-service.sh tips` — database `ov_tips`, its roles and Valkey
- user; it writes `DATABASE_URL`, `DATABASE_DIRECT_URL`, `VALKEY_URL`, `VALKEY_PREFIX` into
- `/etc/openvibe/tips.env`.
-2. Rehearse on a copy of `/var/lib/openvibe-tips/tips.db` (`sqlite3 … ".backup …"` as the service user):
- `npm run migrate-to-postgres -- --sqlite <copy> --pglite` must end OK.
-3. Merge the PostgreSQL release; do not deploy it yet.
-4. `sudo systemctl stop openvibe-tips` (the write freeze), then back up `tips.db`.
-5. From the new release's directory, as the service user with the service's environment:
- `node scripts/migrate-to-postgres.js` — it migrates as the owner, imports every table with
- `truncate`, verifies counts and checksums, changes nothing in the SQLite file, and exits 1 unless OK.
-6. `sudo ovhost deploy tips`; check `/api/ready` shows `db` `store: postgresql` and `valkey` ok, the
- dashboard, a receipt and an overlay, and a flat error rate.
-7. Keep `tips.db` read-only for 7 days (the rollback: the previous release reads it, losing the writes
- made since the switch unless they are replayed), then `ovhost archive push` it and delete it.
-
## Launch rule
## Launch rule
This repository does not make the product real, and the domain keeps its placeholder page on
This repository does not make the product real, and the domain keeps its placeholder page on
@@ -449,8 +411,7 @@ exist here (plan §12.12):
@@ -449,8 +411,7 @@ exist here (plan §12.12):
principal is provisioned on the host;
principal is provisioned on the host;
3. server-rendered public routes useful without JavaScript — ✔;
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
4. real persistence and end-to-end workflows — ✔ against stubs; not yet against the real Billing and
- Events (Billing is in shadow and has sent no events; production holds the Live import and no creator
- profile);
+ Events (Billing is in shadow and has sent no events; production has no creator profile yet);
5. capability and event registration against OpenVibe.Contracts — ✔ v0.15.0, payload schemas in v0.30.2 and v0.32.0 (v0.76.0 pinned);
5. capability and event registration against OpenVibe.Contracts — ✔ v0.15.0, payload schemas in v0.30.2 and v0.32.0 (v0.76.0 pinned);
6. a migration/seed strategy ✔, a written threat review ✔ ([docs/threat-review.md](docs/threat-review.md),
6. a migration/seed strategy ✔, a written threat review ✔ ([docs/threat-review.md](docs/threat-review.md),
internal; an independent review is still due), and sitemap/robots ✔ (no feed);
internal; an independent review is still due), and sitemap/robots ✔ (no feed);
@@ -8,9 +8,9 @@
@@ -8,9 +8,9 @@
"authoritative": false,
"authoritative": false,
"deployed": true,
"deployed": true,
"deployment": "Deployed internally, not launched: deployed with `sudo ovhost deploy tips`, /opt/openvibe.tips, unit openvibe-tips, 127.0.0.1:4610 loopback only; https://openvibe.tips still serves the OpenVibe.Sites placeholder. Principal tips; Events subscriptions billing.transaction.* and billing.receipt.* -> /internal/events (secret rotated 2026-09-24); TIPS_CHAT_ADAPTER is empty (no chat side effects).",
"deployment": "Deployed internally, not launched: deployed with `sudo ovhost deploy tips`, /opt/openvibe.tips, unit openvibe-tips, 127.0.0.1:4610 loopback only; https://openvibe.tips still serves the OpenVibe.Sites placeholder. Principal tips; Events subscriptions billing.transaction.* and billing.receipt.* -> /internal/events (secret rotated 2026-09-24); TIPS_CHAT_ADAPTER is empty (no chat side effects).",
- "database": "PostgreSQL 18 through PgBouncer (transaction mode) as the system of record, Valkey 9 for shared state (per-actor limits, overlay fan-out, overlay stream slots), ADR-035: database ov_tips from OpenVibe.Host's data role, schema in migrations/ (0001_initial, expand), async on openvibe-sdk/db v0.18.0, money paths serializable with row locks, PostgreSQL outbox and inbox. Switched in production on 2026-09-28: scripts/migrate-to-postgres.js imported /var/lib/openvibe-tips/tips.db verified by counts and checksums (rehearsed on a copy of the production file first: report.ok, 139 interactions, 143 migration maps, 4 goals); the SQLite file stays read-only for the 7-day N-1 window (until 2026-10-05), then is archived to B2.",
+ "database": "PostgreSQL 18 through PgBouncer (transaction mode) as the system of record, Valkey 9 for shared state (per-actor limits, overlay fan-out, overlay stream slots), ADR-035: database ov_tips from OpenVibe.Host's data role, schema in migrations/ (0001_initial expand, 0002_drop_legacy_import contract), async on openvibe-sdk/db v0.18.0, money paths serializable with row locks, PostgreSQL outbox and inbox.",
"contracts": "openvibe-contracts v0.76.0",
"contracts": "openvibe-contracts v0.76.0",
- "notes": "Runtime, API, SSR pages, overlays, the Billing settlement consumer, events outbox, Live import and reconciliation are built, tested and deployed, together with /metrics (loopback only), truthful /api/ready (Billing and Events checks), supporter privacy (anonymous, hidden amount, private message), goal and supporter-page settings, export and erasure (tips.interaction.erased), moderation (word filter, hide/show, invited moderators, tips.interaction.moderated) and per-actor limits. Capabilities (18 tips.*) and payload schemas are in openvibe-contracts. On 2026-09-24 production held the Live import (3 import runs, the last 2026-09-23 18:31 UTC: 139 interactions, 4 goals, 143 migration maps); 0 creator profiles and 0 overlay tokens (nobody had set up the product). No billing.* event had arrived: Billing runs in shadow. A written internal threat review (docs/threat-review.md) with its fixes and test/security.test.js. Not done: an independent security review; the openvibe.tips launch. Live stays the place tips happen until the Billing cutover (blocked on the owner re-pointing the PowerChat webhook) and the launch rule in README.md holds.",
+ "notes": "Runtime, API, SSR pages, overlays, the Billing settlement consumer and the events outbox are built, tested and deployed, together with /metrics (loopback only), truthful /api/ready (Billing and Events checks), supporter privacy (anonymous, hidden amount, private message), goal and supporter-page settings, export and erasure (tips.interaction.erased), moderation (word filter, hide/show, invited moderators, tips.interaction.moderated) and per-actor limits. Capabilities (18 tips.*) and payload schemas are in openvibe-contracts. No billing.* event had arrived: Billing runs in shadow. A written internal threat review (docs/threat-review.md) with its fixes and test/security.test.js. Not done: an independent security review; the openvibe.tips launch. Live stays the place tips happen until the Billing cutover (blocked on the owner re-pointing the PowerChat webhook) and the launch rule in README.md holds.",
"updated": "2026-09-28",
"updated": "2026-09-28",
"features": [
"features": [
"tips, paid messages, TTS and media requests settled by OpenVibe.Billing (never Tips' own money)",
"tips, paid messages, TTS and media requests settled by OpenVibe.Billing (never Tips' own money)",
@@ -20,7 +20,6 @@
@@ -20,7 +20,6 @@
"supporter privacy, export and erasure",
"supporter privacy, export and erasure",
"moderation: word filter, hide and show, invited moderators",
"moderation: word filter, hide and show, invited moderators",
"Live chat delivery adapter",
"Live chat delivery adapter",
- "Live import and reconciliation against Billing",
"server-rendered creator pages useful without JavaScript",
"server-rendered creator pages useful without JavaScript",
"/api/v1 by capability",
"/api/v1 by capability",
"per-actor limits (openvibe-sdk/limits)",
"per-actor limits (openvibe-sdk/limits)",
@@ -23,11 +23,9 @@ ExecStart=/usr/bin/env node /opt/openvibe.tips/server/index.js
@@ -23,11 +23,9 @@ ExecStart=/usr/bin/env node /opt/openvibe.tips/server/index.js
Restart=always
Restart=always
RestartSec=5
RestartSec=5
# DATABASE_URL, DATABASE_DIRECT_URL, VALKEY_URL and VALKEY_PREFIX come from the env file
# DATABASE_URL, DATABASE_DIRECT_URL, VALKEY_URL and VALKEY_PREFIX come from the env file
-# (roles/data/add-service.sh tips). The state directory keeps the SQLite file of the release before
-# PostgreSQL: read-only rollback for the 7-day N-1 window, read by scripts/migrate-to-postgres.js.
+# (roles/data/add-service.sh tips).
StateDirectory=openvibe-tips
StateDirectory=openvibe-tips
StateDirectoryMode=0700
StateDirectoryMode=0700
-Environment=TIPS_DB_PATH=/var/lib/openvibe-tips/tips.db
UMask=0077
UMask=0077
NoNewPrivileges=true
NoNewPrivileges=true
PrivateTmp=true
PrivateTmp=true
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+-- phase: contract
+-- after: 0001
+--
+-- The Live import and the SQLite migration era are gone. `legacy_source` and the `migration_maps`
+-- table (0001_initial) existed only to key imported Live rows to the Tips entities they became;
+-- nothing reads or writes them now (server/importer/live.js and the import scripts are deleted), so
+-- the one-time import's columns go and its map table is dropped.
+ALTER TABLE tip_interactions DROP COLUMN legacy_source;
+ALTER TABLE tip_goals DROP COLUMN legacy_source;
+DROP TABLE migration_maps;
@@ -9,7 +9,6 @@
@@ -9,7 +9,6 @@
"version": "0.1.0",
"version": "0.1.0",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
- "better-sqlite3": "^11.10.0",
"cookie-parser": "^1.4.7",
"cookie-parser": "^1.4.7",
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
@@ -110,7 +109,9 @@
@@ -110,7 +109,9 @@
"url": "https://feross.org/support"
"url": "https://feross.org/support"
}
}
],
],
- "license": "MIT"
+ "license": "MIT",
+ "optional": true,
+ "peer": true
},
},
"node_modules/better-sqlite3": {
"node_modules/better-sqlite3": {
"version": "11.10.0",
"version": "11.10.0",
@@ -118,6 +119,8 @@
@@ -118,6 +119,8 @@
"integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",
"integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",
"hasInstallScript": true,
"hasInstallScript": true,
"license": "MIT",
"license": "MIT",
+ "optional": true,
+ "peer": true,
"dependencies": {
"dependencies": {
"bindings": "^1.5.0",
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
"prebuild-install": "^7.1.1"
@@ -128,6 +131,8 @@
@@ -128,6 +131,8 @@
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"license": "MIT",
"license": "MIT",
+ "optional": true,
+ "peer": true,
"dependencies": {
"dependencies": {
"file-uri-to-path": "1.0.0"
"file-uri-to-path": "1.0.0"
}
}
@@ -137,6 +142,8 @@
@@ -137,6 +142,8 @@
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"license": "MIT",
"license": "MIT",
+ "optional": true,
+ "peer": true,
"dependencies": {
"dependencies": {
"buffer": "^5.5.0",
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"inherits": "^2.0.4",
@@ -186,6 +193,8 @@
@@ -186,6 +193,8 @@
}
}
],
],
"license": "MIT",
"license": "MIT",
+ "optional": true,
+ "peer": true,
"dependencies": {
"dependencies": {
"base64-js": "^1.3.1",
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
"ieee754": "^1.1.13"
@@ -239,7 +248,9 @@
@@ -239,7 +248,9 @@
"version": "1.1.4",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
- "license": "ISC"
+ "license": "ISC",
+ "optional": true,
+ "peer": true
},
},
"node_modules/cluster-key-slot": {
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"version": "1.1.2",
@@ -313,6 +324,8 @@
@@ -313,6 +324,8 @@
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"license": "MIT",
"license": "MIT",
+ "optional": true,
+ "peer": true,
"dependencies": {
"dependencies": {
"mimic-response": "^3.1.0"
"mimic-response": "^3.1.0"
},
},
@@ -328,6 +341,8 @@
@@ -328,6 +341,8 @@
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"license": "MIT",
"license": "MIT",
+ "optional": true,
+ "peer": true,
"engines": {
"engines": {
"node": ">=4.0.0"
"node": ">=4.0.0"
}
}
@@ -365,6 +380,8 @@
@@ -365,6 +380,8 @@
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"license": "Apache-2.0",
+ "optional": true,
+ "peer": true,
"engines": {
"engines": {
"node": ">=8"
"node": ">=8"
}
}
@@ -424,6 +441,8 @@
@@ -424,6 +441,8 @@
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"license": "MIT",
+ "optional": true,
+ "peer": true,
"dependencies": {
"dependencies": {
"once": "^1.4.0"
"once": "^1.4.0"
}
}
@@ -478,6 +497,8 @@
@@ -478,6 +497,8 @@
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"license": "(MIT OR WTFPL)",
"license": "(MIT OR WTFPL)",
+ "optional": true,
+ "peer": true,
"engines": {
"engines": {
"node": ">=6"
"node": ">=6"
}
}
@@ -554,7 +575,9 @@
@@ -554,7 +575,9 @@
"version": "1.0.0",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"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==",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
- "license": "MIT"
+ "license": "MIT",
+ "optional": true,
+ "peer": true
},
},
"node_modules/finalhandler": {
"node_modules/finalhandler": {
"version": "1.3.2",
"version": "1.3.2",
@@ -596,7 +619,9 @@
@@ -596,7 +619,9 @@
"version": "1.0.0",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
- "license": "MIT"
+ "license": "MIT",
+ "optional": true,
+ "peer": true
},
},
"node_modules/function-bind": {
"node_modules/function-bind": {
"version": "1.1.2",
"version": "1.1.2",
@@ -648,7 +673,9 @@
@@ -648,7 +673,9 @@
"version": "0.0.0",
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
- "license": "MIT"
+ "license": "MIT",
+ "optional": true,
+ "peer": true
},
},
"node_modules/gopd": {
"node_modules/gopd": {
"version": "1.2.0",
"version": "1.2.0",
@@ -736,7 +763,9 @@
@@ -736,7 +763,9 @@
"url": "https://feross.org/support"
"url": "https://feross.org/support"
}
}
],
],
- "license": "BSD-3-Clause"
+ "license": "BSD-3-Clause",
+ "optional": true,
+ "peer": true
},
},
"node_modules/inherits": {
"node_modules/inherits": {
"version": "2.0.4",
"version": "2.0.4",
@@ -748,7 +777,9 @@
@@ -748,7 +777,9 @@
"version": "1.3.8",
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
- "license": "ISC"
+ "license": "ISC",
+ "optional": true,
+ "peer": true
},
},
"node_modules/iovalkey": {
"node_modules/iovalkey": {
"version": "0.4.0",
"version": "0.4.0",
@@ -985,6 +1016,8 @@
@@ -985,6 +1016,8 @@
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"license": "MIT",
"license": "MIT",
+ "optional": true,
+ "peer": true,
"engines": {
"engines": {
"node": ">=10"
"node": ">=10"
},
},
@@ -997,6 +1030,8 @@
@@ -997,6 +1030,8 @@
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"license": "MIT",
+ "optional": true,
+ "peer": true,
"funding": {
"funding": {
"url": "https://github.com/sponsors/ljharb"
"url": "https://github.com/sponsors/ljharb"
}
}
@@ -1005,7 +1040,9 @@
@@ -1005,7 +1040,9 @@
"version": "0.5.3",
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
- "license": "MIT"
+ "license": "MIT",
+ "optional": true,
+ "peer": true
},
},
"node_modules/ms": {
"node_modules/ms": {
"version": "2.0.0",
"version": "2.0.0",
@@ -1017,7 +1054,9 @@
@@ -1017,7 +1054,9 @@
"version": "2.0.0",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
- "license": "MIT"
+ "license": "MIT",
+ "optional": true,
+ "peer": true
},
},
"node_modules/negotiator": {
"node_modules/negotiator": {
"version": "0.6.3",
"version": "0.6.3",
@@ -1033,6 +1072,8 @@
@@ -1033,6 +1072,8 @@
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz",
"integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==",
"integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==",
"license": "MIT",
"license": "MIT",
+ "optional": true,
+ "peer": true,
"dependencies": {
"dependencies": {
"semver": "^7.3.5"
"semver": "^7.3.5"
},
},
@@ -1069,6 +1110,8 @@
@@ -1069,6 +1110,8 @@
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"license": "ISC",
+ "optional": true,
+ "peer": true,
"dependencies": {
"dependencies": {
"wrappy": "1"
"wrappy": "1"
}
}
@@ -1276,6 +1319,8 @@
@@ -1276,6 +1319,8 @@
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
"license": "MIT",
"license": "MIT",
+ "optional": true,
+ "peer": true,
"dependencies": {
"dependencies": {
"detect-libc": "^2.0.0",
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"expand-template": "^2.0.3",
@@ -1319,6 +1364,8 @@
@@ -1319,6 +1364,8 @@
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"license": "MIT",
"license": "MIT",
+ "optional": true,
+ "peer": true,
"dependencies": {
"dependencies": {
"end-of-stream": "^1.1.0",
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
"once": "^1.3.1"
@@ -1369,6 +1416,8 @@
@@ -1369,6 +1416,8 @@
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
+ "optional": true,
+ "peer": true,
"dependencies": {
"dependencies": {
"deep-extend": "^0.6.0",
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"ini": "~1.3.0",
@@ -1384,6 +1433,8 @@
@@ -1384,6 +1433,8 @@
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"license": "MIT",
+ "optional": true,
+ "peer": true,
"dependencies": {
"dependencies": {
"inherits": "^2.0.3",
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"string_decoder": "^1.1.1",
@@ -1602,7 +1653,9 @@
@@ -1602,7 +1653,9 @@
"url": "https://feross.org/support"
"url": "https://feross.org/support"
}
}
],
],
- "license": "MIT"
+ "license": "MIT",
+ "optional": true,
+ "peer": true
},
},
"node_modules/simple-get": {
"node_modules/simple-get": {
"version": "4.0.1",
"version": "4.0.1",
@@ -1623,6 +1676,8 @@
@@ -1623,6 +1676,8 @@
}
}
],
],
"license": "MIT",
"license": "MIT",
+ "optional": true,
+ "peer": true,
"dependencies": {
"dependencies": {
"decompress-response": "^6.0.0",
"decompress-response": "^6.0.0",
"once": "^1.3.1",
"once": "^1.3.1",
@@ -1658,6 +1713,8 @@
@@ -1658,6 +1713,8 @@
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"license": "MIT",
+ "optional": true,
+ "peer": true,
"dependencies": {
"dependencies": {
"safe-buffer": "~5.2.0"
"safe-buffer": "~5.2.0"
}
}
@@ -1667,6 +1724,8 @@
@@ -1667,6 +1724,8 @@
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"license": "MIT",
"license": "MIT",
+ "optional": true,
+ "peer": true,
"engines": {
"engines": {
"node": ">=0.10.0"
"node": ">=0.10.0"
}
}
@@ -1676,6 +1735,8 @@
@@ -1676,6 +1735,8 @@
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
"integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
"integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
"license": "MIT",
"license": "MIT",
+ "optional": true,
+ "peer": true,
"dependencies": {
"dependencies": {
"chownr": "^1.1.1",
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"mkdirp-classic": "^0.5.2",
@@ -1688,6 +1749,8 @@
@@ -1688,6 +1749,8 @@
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"license": "MIT",
"license": "MIT",
+ "optional": true,
+ "peer": true,
"dependencies": {
"dependencies": {
"bl": "^4.0.3",
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"end-of-stream": "^1.4.1",
@@ -1713,6 +1776,8 @@
@@ -1713,6 +1776,8 @@
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"license": "Apache-2.0",
"license": "Apache-2.0",
+ "optional": true,
+ "peer": true,
"dependencies": {
"dependencies": {
"safe-buffer": "^5.0.1"
"safe-buffer": "^5.0.1"
},
},
@@ -1746,7 +1811,9 @@
@@ -1746,7 +1811,9 @@
"version": "1.0.2",
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
- "license": "MIT"
+ "license": "MIT",
+ "optional": true,
+ "peer": true
},
},
"node_modules/utils-merge": {
"node_modules/utils-merge": {
"version": "1.0.1",
"version": "1.0.1",
@@ -1770,7 +1837,9 @@
@@ -1770,7 +1837,9 @@
"version": "1.0.2",
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
- "license": "ISC"
+ "license": "ISC",
+ "optional": true,
+ "peer": true
},
},
"node_modules/xtend": {
"node_modules/xtend": {
"version": "4.0.2",
"version": "4.0.2",
@@ -9,9 +9,7 @@
@@ -9,9 +9,7 @@
"dev": "NODE_ENV=development node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
"test": "node test/run.js",
"test": "node test/run.js",
"test:pg": "TIPS_TEST_STORE=pg node test/run.js",
"test:pg": "TIPS_TEST_STORE=pg node test/run.js",
- "subscribe": "node scripts/subscribe.js",
- "import-live": "node scripts/import-live.js",
- "migrate-to-postgres": "node scripts/migrate-to-postgres.js"
+ "subscribe": "node scripts/subscribe.js"
},
},
"keywords": [
"keywords": [
"openvibe",
"openvibe",
@@ -23,7 +21,6 @@
@@ -23,7 +21,6 @@
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"repository": "github:OpenVibers/OpenVibe.Tips",
"repository": "github:OpenVibers/OpenVibe.Tips",
"dependencies": {
"dependencies": {
- "better-sqlite3": "^11.10.0",
"cookie-parser": "^1.4.7",
"cookie-parser": "^1.4.7",
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
deleted file mode 100644
deleted file mode 100644
@@ -1,67 +0,0 @@
@@ -1,67 +0,0 @@
-#!/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.
- *
- * Tips' side is its PostgreSQL database (DATABASE_URL; migrations applied with DATABASE_DIRECT_URL
- * first). The two snapshots are other services' SQLite files, which is why this operator tool, and
- * scripts/migrate-to-postgres.js, are the only code here that loads better-sqlite3.
- */
-const path = require('path');
-const Database = require('better-sqlite3');
-const { loadConfig } = require('../server/config');
-const { openDb, migrate } = 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.sqlitePath)) throw new Error(`${p} is Tips' own (pre-PostgreSQL) 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);
- await migrate(config, { serving: db });
- 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();
- await db.close();
- if (report.reconciliation && !report.reconciliation.ok) process.exit(1);
-}
-
-main().catch((e) => { console.error(e.message); process.exit(1); });
deleted file mode 100644
deleted file mode 100644
@@ -1,142 +0,0 @@
@@ -1,142 +0,0 @@
-#!/usr/bin/env node
-'use strict';
-/**
- * The one-time move of Tips' SQLite database into its PostgreSQL schema (ADR-035; the procedure is
- * openvibe-sdk docs/migrating-to-postgresql.md, section 6).
- *
- * node scripts/migrate-to-postgres.js [--sqlite <file>] [--pglite] [--json]
- *
- * 1. applies migrations/ as the owner (DATABASE_DIRECT_URL, a direct connection);
- * 2. copies every table with openvibe-sdk's importSqlite (batched, parents first, identities kept and
- * their sequences advanced) into emptied tables (truncate: a rehearsal can be repeated);
- * 3. verifies each table's row count and a checksum of every row on both sides, prints the report,
- * and exits 1 unless report.ok.
- *
- * The SQLite file (TIPS_DB_PATH, config.sqlitePath; --sqlite for a copy) is opened read-only: nothing in
- * it changes. --pglite imports into an in-memory PostgreSQL instead (a rehearsal with nothing to set up;
- * the report is all it leaves). --json prints the whole report.
- *
- * In production it runs once, from the new release's directory, as the service user with the service's
- * environment, while the service is stopped (the write freeze), before the PostgreSQL release starts.
- *
- * What does not map one to one:
- * settings not carried: the SQLite bookkeeping row (schema_version 3); ov_migrations
- * records the schema now (migrations/0001_initial.sql)
- * event_outbox → tips_event_outbox (the SDK outbox, outboxSchema('tips_event_outbox'))
- * idempotency_receipts → tips_event_inbox (the SDK inbox, inboxSchema('tips_event_inbox'))
- * JSON text 'null' → SQL NULL in the reference columns (interaction_effects.result,
- * paid_messages.chat_ref, paid_media_requests.queue_ref): the SQLite code
- * wrote JSON.stringify(null) when an adapter returned no reference, the
- * PostgreSQL code writes NULL, and both mean "no reference"
- * NUL, unpaired surrogate PostgreSQL stores no NUL character in text or jsonb and no unpaired surrogate
- * in jsonb (SQLite took both; the code strips them now): a NUL is dropped and an
- * unpaired surrogate becomes U+FFFD, in text and inside JSON values alike. The
- * report lists every column where that happened and how many values changed
- * No column is dropped.
- */
-const path = require('path');
-const { createDb, importSqlite } = require('openvibe-sdk/db');
-const { loadConfig } = require('../server/config');
-const { migrate, MIGRATIONS } = require('../server/db');
-
-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}`);
-
-/** A row map turning the JSON text 'null' into SQL NULL for these columns. */
-const jsonNullToSql = (...cols) => (row) => {
- for (const c of cols) if (row[c] === 'null') row[c] = null;
- return row;
-};
-
-const TABLES = {
- tips_event_outbox: { from: 'event_outbox' },
- tips_event_inbox: { from: 'idempotency_receipts' },
- interaction_effects: { map: jsonNullToSql('result') },
- paid_messages: { map: jsonNullToSql('chat_ref') },
- paid_media_requests: { map: jsonNullToSql('queue_ref') },
-};
-
-const cleanText = (v) => v.toWellFormed().replace(/\u0000/g, '');
-function cleanJson(v) {
- if (typeof v === 'string') return cleanText(v);
- if (Array.isArray(v)) return v.map(cleanJson);
- if (v && typeof v === 'object') return Object.fromEntries(Object.entries(v).map(([k, x]) => [cleanText(k), cleanJson(x)]));
- return v;
-}
-
-/**
- * importSqlite's `tables` for this schema: TABLES, and for every table a map that makes its text and
- * JSON values storable (see above). `cleaned` counts the distinct values changed per column (the
- * verification sees the same mapped values on both sides, so this is the record of what changed).
- */
-async function importOptions(db) {
- const cols = await db.many(`SELECT table_name, column_name, udt_name FROM information_schema.columns
- WHERE table_schema = current_schema() AND table_name <> 'ov_migrations' AND udt_name IN ('text', 'varchar', 'json', 'jsonb')`);
- const byTable = new Map();
- for (const c of cols) { if (!byTable.has(c.table_name)) byTable.set(c.table_name, []); byTable.get(c.table_name).push(c); }
- const cleaned = new Map(); // "table.column" → Set of the original values changed
- const note = (t, c, v) => { const k = `${t}.${c}`; if (!cleaned.has(k)) cleaned.set(k, new Set()); cleaned.get(k).add(v); };
- const tables = {};
- for (const [table, list] of byTable) {
- const base = TABLES[table] || {};
- tables[table] = {
- ...base,
- map(row) {
- const r = base.map ? base.map(row) : row;
- if (!r) return r;
- for (const { column_name: c, udt_name: udt } of list) {
- const v = r[c];
- if (typeof v !== 'string') continue;
- if (udt === 'json' || udt === 'jsonb') {
- let parsed;
- try { parsed = JSON.parse(v); } catch { continue; } // left for the importer to report
- const fixed = JSON.stringify(cleanJson(parsed));
- if (fixed !== JSON.stringify(parsed)) { note(table, c, v); r[c] = fixed; }
- } else {
- const fixed = cleanText(v);
- if (fixed !== v) { note(table, c, v); r[c] = fixed; }
- }
- }
- return r;
- },
- };
- }
- return { tables, cleaned: () => [...cleaned].map(([column, values]) => ({ column, values: values.size })) };
-}
-
-async function main() {
- const config = loadConfig();
- const sqlite = path.resolve(opt('sqlite') || config.sqlitePath);
- const quiet = { log() {}, warn: console.warn, error: console.error };
- let owner;
- if (flag('pglite')) {
- owner = createDb({ pglite: true, service: 'tips-import' });
- await owner.migrate({ dir: MIGRATIONS, log: quiet });
- } else {
- await migrate(config, { log: quiet });
- owner = createDb({ url: config.db.directUrl, service: 'tips-import', max: 2 });
- }
- try {
- const t0 = Date.now();
- const options = await importOptions(owner);
- const report = await importSqlite({ sqlite, db: owner, truncate: true, tables: options.tables, log: quiet });
- const cleaned = options.cleaned();
- if (flag('json')) console.log(JSON.stringify({ sqlite, into: owner.store, ...report, cleaned }, null, 2));
- else {
- console.log(`import ${sqlite} → ${owner.store} (${Date.now() - t0} ms): ${report.ok ? 'OK' : 'PROBLEMS'}`);
- for (const t of report.tables) console.log(` ${t.table.padEnd(24)} ${String(t.rows).padStart(8)} rows ${t.checksum || '-'}${t.source !== t.table ? ` (from ${t.source})` : ''}`);
- for (const c of cleaned) console.log(` cleaned: ${c.column}: ${c.values} value(s) with a NUL or an unpaired surrogate`);
- for (const p of report.problems) console.log(` problem: ${p.table}: ${p.problem}`);
- }
- return report.ok ? 0 : 1;
- } finally {
- await owner.close();
- }
-}
-
-if (require.main === module) {
- main().then((code) => process.exit(code), (e) => { console.error(`migrate-to-postgres failed: ${e.message}`); process.exit(1); });
-}
-
-module.exports = { TABLES, importOptions };
@@ -403,9 +403,9 @@ function v1Router({ domain, apiAuth, limits }) {
@@ -403,9 +403,9 @@ function v1Router({ domain, apiAuth, limits }) {
/** Async-safe handler: TipsError → problem+json; anything else → 500. */
/** Async-safe handler: TipsError → problem+json; anything else → 500. */
function wrap(fn) {
function wrap(fn) {
- return (req, res, next) => {
+ return async (req, res, next) => {
try {
try {
- const p = fn(req, res, next);
+ const p = await fn(req, res, next);
if (p && typeof p.catch === 'function') p.catch((e) => sendError(req, res, e));
if (p && typeof p.catch === 'function') p.catch((e) => sendError(req, res, e));
} catch (e) { sendError(req, res, e); }
} catch (e) { sendError(req, res, e); }
};
};
@@ -58,7 +58,7 @@ function createBillingClient(config, { fetchImpl = globalThis.fetch, tokenClient
@@ -58,7 +58,7 @@ function createBillingClient(config, { fetchImpl = globalThis.fetch, tokenClient
} catch (e) {
} catch (e) {
throw new BillingCallError(`Billing unreachable: ${e.message}`);
throw new BillingCallError(`Billing unreachable: ${e.message}`);
}
}
- if (res.status === 401 && !retried) { tokenClientFor().invalidate(); return call(method, path, { body, key, traceparent, retried: true }); }
+ if (res.status === 401 && !retried) { tokenClientFor().invalidate(); return await call(method, path, { body, key, traceparent, retried: true }); }
const data = await res.json().catch(() => null);
const data = await res.json().catch(() => null);
if (!res.ok) {
if (!res.ok) {
const code = (data && (data.code || data.error)) || `http_${res.status}`;
const code = (data && (data.code || data.error)) || `http_${res.status}`;
@@ -69,17 +69,17 @@ function createBillingClient(config, { fetchImpl = globalThis.fetch, tokenClient
@@ -69,17 +69,17 @@ function createBillingClient(config, { fetchImpl = globalThis.fetch, tokenClient
return {
return {
/** { intent, checkout_url } — kind purchase: the supporter buys `bits` of credit. */
/** { intent, checkout_url } — kind purchase: the supporter buys `bits` of credit. */
- createIntent: ({ provider, subject, bits, successUrl, cancelUrl, key, traceparent }) => call('POST', '/api/v1/intents', {
+ createIntent: async ({ provider, subject, bits, successUrl, cancelUrl, key, traceparent }) => await call('POST', '/api/v1/intents', {
body: { provider, kind: 'purchase', subject: { type: 'user', id: subject }, bits, success_url: successUrl, cancel_url: cancelUrl },
body: { provider, kind: 'purchase', subject: { type: 'user', id: subject }, bits, success_url: successUrl, cancel_url: cancelUrl },
key, traceparent,
key, traceparent,
}),
}),
/** { transaction } — credit of `from` → payable of `to`, tagged with the interaction. */
/** { 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', {
+ createTransfer: async ({ from, to, amount, kind, target, message, key, traceparent }) => await call('POST', '/api/v1/transfers', {
body: { from: { type: 'user', id: from }, to: { type: 'user', id: to }, amount, kind, target, message: message || undefined },
body: { from: { type: 'user', id: from }, to: { type: 'user', id: to }, amount, kind, target, message: message || undefined },
key, traceparent,
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'),
+ refundTransfer: async ({ txnId, amount, reason, key }) => await call('POST', `/api/v1/transfers/${encodeURIComponent(txnId)}/refund`, { body: { amount, reason }, key }),
+ rates: async () => await call('GET', '/api/v1/rates'),
};
};
}
}
@@ -32,7 +32,7 @@ function loadConfig(env = process.env) {
@@ -32,7 +32,7 @@ function loadConfig(env = process.env) {
// PostgreSQL (ADR-035; OpenVibe.Host's data role writes all four into /etc/openvibe/tips.env):
// PostgreSQL (ADR-035; OpenVibe.Host's data role writes all four into /etc/openvibe/tips.env):
// DATABASE_URL is the pooled runtime role through PgBouncer (transaction mode), DATABASE_DIRECT_URL
// DATABASE_URL is the pooled runtime role through PgBouncer (transaction mode), DATABASE_DIRECT_URL
- // the owner role on a direct connection, for migrations at boot and the one-time import.
+ // the owner role on a direct connection, for migrations at boot.
db: {
db: {
url: env.DATABASE_URL || '',
url: env.DATABASE_URL || '',
directUrl: env.DATABASE_DIRECT_URL || '',
directUrl: env.DATABASE_DIRECT_URL || '',
@@ -43,10 +43,6 @@ function loadConfig(env = process.env) {
@@ -43,10 +43,6 @@ function loadConfig(env = process.env) {
url: env.VALKEY_URL || '',
url: env.VALKEY_URL || '',
prefix: env.VALKEY_PREFIX || 'ov:tips:',
prefix: env.VALKEY_PREFIX || 'ov:tips:',
},
},
- // The SQLite file of the release before PostgreSQL: read (never written) only by
- // scripts/migrate-to-postgres.js. The systemd unit sets /var/lib/openvibe-tips/tips.db.
- sqlitePath: env.TIPS_DB_PATH || './data/tips.db',
-
// Identity: service tokens and user tokens are RS256 JWTs signed by OpenVibe.Network.
// Identity: service tokens and user tokens are RS256 JWTs signed by OpenVibe.Network.
network: {
network: {
url: networkUrl,
url: networkUrl,
@@ -56,8 +52,7 @@ function loadConfig(env = process.env) {
@@ -56,8 +52,7 @@ function loadConfig(env = process.env) {
},
},
audience: env.TIPS_AUDIENCE || 'openvibe.tips',
audience: env.TIPS_AUDIENCE || 'openvibe.tips',
// Tips' own client credentials (client `tips` in the Network): service tokens for Billing,
// 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).
+ // Events and Live (chat delivery); and the OAuth code flow for browser sign-in.
oauth: {
oauth: {
clientId: env.OV_OAUTH_CLIENT_ID || 'tips',
clientId: env.OV_OAUTH_CLIENT_ID || 'tips',
clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
@@ -22,7 +22,6 @@
@@ -22,7 +22,6 @@
* overlay_configs alert / goal overlay settings
* overlay_configs alert / goal overlay settings
* overlay_deliveries what overlays were sent (monotonic seq per creator, replayable)
* 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)
* 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), tip_moderators / _invites / _moderation_log,
* Supporting: overlay_tokens (hashed, scoped, revocable), tip_moderators / _invites / _moderation_log,
* api_idempotency, import_runs, tips_event_outbox + tips_event_inbox (openvibe-sdk outbox and inbox).
* api_idempotency, import_runs, tips_event_outbox + tips_event_inbox (openvibe-sdk outbox and inbox).
@@ -43,7 +43,7 @@ function createLiveChatAdapter(config, { fetchImpl = globalThis.fetch, tokenClie
@@ -43,7 +43,7 @@ function createLiveChatAdapter(config, { fetchImpl = globalThis.fetch, tokenClie
} catch (e) {
} catch (e) {
throw new Error(`Live unreachable: ${e.message}`);
throw new Error(`Live unreachable: ${e.message}`);
}
}
- if (res.status === 401 && !retried) { client().invalidate(); return deliver(job, true); }
+ if (res.status === 401 && !retried) { client().invalidate(); return await deliver(job, true); }
const body = await res.json().catch(() => null);
const body = await res.json().catch(() => null);
if (!res.ok) {
if (!res.ok) {
const e = new Error(`Live ${res.status}: ${(body && (body.detail || body.error || body.code)) || 'refused'}`);
const e = new Error(`Live ${res.status}: ${(body && (body.detail || body.error || body.code)) || 'refused'}`);
@@ -141,7 +141,7 @@ function createGoals(ctx) {
@@ -141,7 +141,7 @@ function createGoals(ctx) {
const id = prefixedId('tgoal', ctx.now());
const id = prefixedId('tgoal', ctx.now());
await t.exec(sql`INSERT INTO tip_goals (id, creator_subject, title, description, target_amount, image_url, status, sort_order, created_at, updated_at)
await t.exec(sql`INSERT INTO tip_goals (id, creator_subject, title, description, target_amount, image_url, status, sort_order, created_at, updated_at)
VALUES (${id}, ${creator}, ${f.title}, ${f.description || null}, ${f.target_amount}, ${f.image_url || null}, 'active', ${f.sort_order || 0}, ${at}, ${at})`);
VALUES (${id}, ${creator}, ${f.title}, ${f.description || null}, ${f.target_amount}, ${f.image_url || null}, 'active', ${f.sort_order || 0}, ${at}, ${at})`);
- return changed(t, id, 'created');
+ return await changed(t, id, 'created');
});
});
}
}
@@ -150,23 +150,23 @@ function createGoals(ctx) {
@@ -150,23 +150,23 @@ function createGoals(ctx) {
if (goal.status !== 'active') fail(409, 'tips.goal_closed', 'a closed goal cannot be edited');
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}`);
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 f = fields(input, true);
- if (!Object.keys(f).length) return present(db, goal);
+ if (!Object.keys(f).length) return await present(db, goal);
return ctx.tx(async (t) => {
return ctx.tx(async (t) => {
const cur = await t.one(sql`SELECT * FROM tip_goals WHERE id = ${goal.id} FOR UPDATE`);
const cur = await t.one(sql`SELECT * FROM tip_goals WHERE id = ${goal.id} FOR UPDATE`);
if (cur.status !== 'active') fail(409, 'tips.goal_closed', 'a closed goal cannot be edited');
if (cur.status !== 'active') fail(409, 'tips.goal_closed', 'a closed goal cannot be edited');
if (input.revision != null && Number(input.revision) !== cur.revision) fail(409, 'tips.revision_conflict', `the goal is at revision ${cur.revision}`);
if (input.revision != null && Number(input.revision) !== cur.revision) fail(409, 'tips.revision_conflict', `the goal is at revision ${cur.revision}`);
await t.exec(sql`UPDATE tip_goals SET ${sql.set(f)} WHERE id = ${goal.id}`);
await t.exec(sql`UPDATE tip_goals SET ${sql.set(f)} WHERE id = ${goal.id}`);
await markReached(t, goal.id);
await markReached(t, goal.id);
- return changed(t, goal.id, 'updated');
+ return await changed(t, goal.id, 'updated');
});
});
}
}
async function close(goal) {
async function close(goal) {
- if (goal.status === 'closed') return present(db, goal);
+ if (goal.status === 'closed') return await present(db, goal);
return ctx.tx(async (t) => {
return ctx.tx(async (t) => {
const n = await t.exec(sql`UPDATE tip_goals SET status = 'closed', closed_at = ${iso(ctx.now())} WHERE id = ${goal.id} AND status = 'active'`);
const n = await t.exec(sql`UPDATE tip_goals SET status = 'closed', closed_at = ${iso(ctx.now())} WHERE id = ${goal.id} AND status = 'active'`);
- if (!n) return present(t, await get(t, goal.id));
- return changed(t, goal.id, 'closed');
+ if (!n) return await present(t, await get(t, goal.id));
+ return await changed(t, goal.id, 'closed');
});
});
}
}
@@ -208,7 +208,7 @@ function createGoals(ctx) {
@@ -208,7 +208,7 @@ function createGoals(ctx) {
// The overlay's goal update names the supporter as the alert does; not when the amount is hidden
// The overlay's goal update names the supporter as the alert does; not when the amount is hidden
// (the bar's jump would otherwise pin the amount on them), nor while moderation holds it.
// (the bar's jump would otherwise pin the amount on them), nor while moderation holds it.
const by = interaction.hide_amount || (interaction.moderation && interaction.moderation !== 'visible') ? null : publicView(interaction, { filter }).supporter_name;
const by = interaction.hide_amount || (interaction.moderation && interaction.moderation !== 'visible') ? null : publicView(interaction, { filter }).supporter_name;
- return changed(t, g.id, reachedNow ? 'reached' : 'contribution', { interactionId: interaction.id, by });
+ return await changed(t, g.id, reachedNow ? 'reached' : 'contribution', { interactionId: interaction.id, by });
}
}
/** Inside the reversal transaction: take `bits` back off every goal the interaction counted toward. */
/** Inside the reversal transaction: take `bits` back off every goal the interaction counted toward. */
@@ -65,7 +65,7 @@ const TRANSFER_BATCH = 10;
@@ -65,7 +65,7 @@ const TRANSFER_BATCH = 10;
const TRANSFER_LEASE_MS = 5 * 60_000;
const TRANSFER_LEASE_MS = 5 * 60_000;
const COLUMNS = ['id', 'creator_subject', 'supporter_subject', 'supporter_name', 'kind', 'amount', 'amount_cents', 'message', 'request',
const COLUMNS = ['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',
'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', 'anonymous', 'hide_amount', 'private_message', 'created_at', 'updated_at'];
+ 'idempotency_key', 'origin', 'target', 'anonymous', 'hide_amount', 'private_message', 'created_at', 'updated_at'];
function createInteractions(ctx) {
function createInteractions(ctx) {
const { db, config, MONEY } = ctx;
const { db, config, MONEY } = ctx;
@@ -148,7 +148,7 @@ function createInteractions(ctx) {
@@ -148,7 +148,7 @@ function createInteractions(ctx) {
const at = iso(ctx.now());
const at = iso(ctx.now());
const r = {
const r = {
supporter_subject: null, supporter_name: null, amount_cents: null, message: null, request: {}, test: false, billing_txn_id: null,
supporter_subject: null, supporter_name: null, amount_cents: null, message: null, request: {}, test: false, billing_txn_id: null,
- billing_intent_id: null, funding_txn_id: null, provider: null, provider_ref: null, legacy_source: null, idempotency_key: null,
+ billing_intent_id: null, funding_txn_id: null, provider: null, provider_ref: null, idempotency_key: null,
target: null, origin: 'tips', anonymous: false, hide_amount: false, private_message: false, created_at: at, ...row,
target: null, origin: 'tips', anonymous: false, hide_amount: false, private_message: false, created_at: at, ...row,
};
};
const values = {
const values = {
@@ -208,11 +208,11 @@ function createInteractions(ctx) {
@@ -208,11 +208,11 @@ function createInteractions(ctx) {
key: `tips:transfer:${i.id}`, traceparent,
key: `tips:transfer:${i.id}`, traceparent,
});
});
const txn = out.transaction;
const txn = out.transaction;
- await ctx.tx(async (t) => settleByTransaction(t, await lock(t, i.id), { id: txn.id, test: !!txn.test }), MONEY);
+ await ctx.tx(async (t) => await settleByTransaction(t, await lock(t, i.id), { id: txn.id, test: !!txn.test }), MONEY);
return { settled: true };
return { settled: true };
} catch (e) {
} catch (e) {
if (e instanceof BillingCallError && !e.retryable) {
if (e instanceof BillingCallError && !e.retryable) {
- await ctx.tx(async (t) => failPayment(t, await lock(t, i.id), e.code || 'billing.refused', e.message), MONEY);
+ await ctx.tx(async (t) => await failPayment(t, await lock(t, i.id), e.code || 'billing.refused', e.message), MONEY);
return { settled: false, refused: e.code, detail: e.body && e.body.detail };
return { settled: false, refused: e.code, detail: e.body && e.body.detail };
}
}
// Due again after the attempt's backoff (TRANSFER_BACKOFF[attempts - 1], the last one repeating).
// Due again after the attempt's backoff (TRANSFER_BACKOFF[attempts - 1], the last one repeating).
@@ -237,7 +237,7 @@ function createInteractions(ctx) {
@@ -237,7 +237,7 @@ function createInteractions(ctx) {
});
});
} catch (e) {
} catch (e) {
if (e instanceof BillingCallError && !e.retryable) {
if (e instanceof BillingCallError && !e.retryable) {
- await ctx.tx(async (t) => failPayment(t, await lock(t, i.id), e.code || 'billing.refused', e.message), MONEY);
+ await ctx.tx(async (t) => await failPayment(t, await lock(t, i.id), e.code || 'billing.refused', e.message), MONEY);
fail(e.status === 409 ? 409 : 422, e.code || 'billing.refused', (e.body && e.body.detail) || 'Billing refused the checkout');
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');
fail(502, 'tips.billing_unavailable', 'checkout is unavailable right now; nothing was charged — try again shortly');
@@ -280,7 +280,7 @@ function createInteractions(ctx) {
@@ -280,7 +280,7 @@ function createInteractions(ctx) {
if (other && other.id !== i.id) return other;
if (other && other.id !== i.id) return other;
const row = await t.one(sql`UPDATE tip_interactions SET billing_txn_id = ${txn.id}, test = test OR ${!!txn.test}, transfer_due = false, failure = NULL
const row = await t.one(sql`UPDATE tip_interactions SET billing_txn_id = ${txn.id}, test = test OR ${!!txn.test}, transfer_due = false, failure = NULL
WHERE id = ${i.id} RETURNING *`);
WHERE id = ${i.id} RETURNING *`);
- return settle(t, row);
+ return await settle(t, row);
}
}
/** Inside a MONEY transaction, `i` locked (or created by it): pending/failed → settled, and the whole effect path. */
/** Inside a MONEY transaction, `i` locked (or created by it): pending/failed → settled, and the whole effect path. */
@@ -465,19 +465,15 @@ function createInteractions(ctx) {
@@ -465,19 +465,15 @@ function createInteractions(ctx) {
}
}
/**
/**
- * The goal a PowerChat tip asked for: its app_purpose/app_ref carries `goal:<id>` — a Tips goal id,
- * or a Live donation_goals id (links minted by Live), which the Live import mapped to a Tips goal.
- * goals.contribute() still checks it is the creator's and active, else falls back to Live's rule.
+ * The goal a PowerChat tip asked for: its app_purpose/app_ref carries `goal:<id>` — a Tips goal id.
+ * goals.contribute() still checks it is the creator's and active, else falls back to the
+ * sole-active-goal rule.
*/
*/
- async function goalFromPurpose(q, ...refs) {
+ function goalFromPurpose(...refs) {
for (const v of refs) {
for (const v of refs) {
const m = storable(v || '').match(/(?:^|[:_-])goal[:_-]?([A-Za-z0-9_]+)/i);
const m = storable(v || '').match(/(?:^|[:_-])goal[:_-]?([A-Za-z0-9_]+)/i);
if (!m) continue;
if (!m) continue;
- if (/^\d+$/.test(m[1])) {
- const map = await q.maybe(sql`SELECT target_id FROM migration_maps WHERE source = 'live' AND source_table = 'donation_goals' AND source_id = ${m[1]} AND status = 'imported'`);
- if (map && map.target_id) return map.target_id;
- continue;
- }
+ if (/^\d+$/.test(m[1])) continue; // a numeric id names no Tips goal (goal ids carry a prefix)
return m[1];
return m[1];
}
}
return null;
return null;
@@ -502,7 +498,7 @@ function createInteractions(ctx) {
@@ -502,7 +498,7 @@ function createInteractions(ctx) {
const bits = Number(p.value_bits);
const bits = Number(p.value_bits);
const amount = Math.min(Number.isSafeInteger(bits) && bits > 0 ? bits : cents, config.limits.maxBits);
const amount = Math.min(Number.isSafeInteger(bits) && bits > 0 ? bits : cents, config.limits.maxBits);
const message = p.message ? storable(String(p.message).replace(/\r\n?/g, '\n').trim().slice(0, config.limits.messageChars)) || null : null;
const message = p.message ? storable(String(p.message).replace(/\r\n?/g, '\n').trim().slice(0, config.limits.messageChars)) || null : null;
- const goalId = await goalFromPurpose(t, p.app_purpose, p.app_ref);
+ const goalId = goalFromPurpose(p.app_purpose, p.app_ref);
const i = await insert(t, {
const i = await insert(t, {
id: prefixedId('tint', ctx.now()), creator_subject: creator, supporter_subject: null,
id: prefixedId('tint', ctx.now()), creator_subject: creator, supporter_subject: null,
supporter_name: p.anonymous ? 'Anonymous' : (displayName(p.donor_name) || 'Someone'), anonymous: !!p.anonymous, kind: 'tip', amount, amount_cents: cents,
supporter_name: p.anonymous ? 'Anonymous' : (displayName(p.donor_name) || 'Someone'), anonymous: !!p.anonymous, kind: 'tip', amount, amount_cents: cents,
@@ -596,7 +592,6 @@ function createInteractions(ctx) {
@@ -596,7 +592,6 @@ function createInteractions(ctx) {
out.effects = effects;
out.effects = effects;
out.provider = i.provider || null;
out.provider = i.provider || null;
out.provider_ref = i.provider_ref || null;
out.provider_ref = i.provider_ref || null;
- out.legacy_source = i.legacy_source || null;
}
}
return out;
return out;
}
}
@@ -149,8 +149,8 @@ function createModeration(ctx) {
@@ -149,8 +149,8 @@ function createModeration(ctx) {
? t.one(sql`UPDATE tip_interactions SET filtered = true, moderation = 'held', moderated_at = ${at}, moderated_by = 'filter', updated_at = ${at} WHERE id = ${i.id} RETURNING *`)
? t.one(sql`UPDATE tip_interactions SET filtered = true, moderation = 'held', moderated_at = ${at}, moderated_by = 'filter', updated_at = ${at} WHERE id = ${i.id} RETURNING *`)
: t.one(sql`UPDATE tip_interactions SET filtered = true, updated_at = ${at} WHERE id = ${i.id} RETURNING *`);
: t.one(sql`UPDATE tip_interactions SET filtered = true, updated_at = ${at} WHERE id = ${i.id} RETURNING *`);
}
}
- function recordScreened(t, i, verdict) {
- return record(t, i, verdict.hold ? 'held' : 'filtered', { role: 'filter', reason: 'blocked_words' });
+ async function recordScreened(t, i, verdict) {
+ return await record(t, i, verdict.hold ? 'held' : 'filtered', { role: 'filter', reason: 'blocked_words' });
}
}
/** Who is acting: { role, actor } for the creator, a moderator or a granted service; else null. */
/** Who is acting: { role, actor } for the creator, a moderator or a granted service; else null. */
@@ -109,7 +109,7 @@ function createOverlayHub({ valkey = null, pushNew, heartbeatMs, now = () => Dat
@@ -109,7 +109,7 @@ function createOverlayHub({ valkey = null, pushNew, heartbeatMs, now = () => Dat
/** Run fn for a client after whatever it is already doing (its pushes never interleave). */
/** Run fn for a client after whatever it is already doing (its pushes never interleave). */
function queue(client, fn) {
function queue(client, fn) {
- client.chain = client.chain.then(() => (client.closed ? null : fn())).catch((e) => log.warn('[Tips] overlay push:', e.message));
+ client.chain = client.chain.then(async () => (client.closed ? null : await fn())).catch((e) => log.warn('[Tips] overlay push:', e.message));
return client.chain;
return client.chain;
}
}
/** New deliveries for this client: coalesced, so a burst of notifications reads once. */
/** New deliveries for this client: coalesced, so a burst of notifications reads once. */
@@ -214,7 +214,7 @@ function createOverlayHub({ valkey = null, pushNew, heartbeatMs, now = () => Dat
@@ -214,7 +214,7 @@ function createOverlayHub({ valkey = null, pushNew, heartbeatMs, now = () => Dat
config: (creator, config) => publish(creator, { t: 'config', config }),
config: (creator, config) => publish(creator, { t: 'config', config }),
revoke: (creator, tokenId) => publish(creator, { t: 'revoke', token_id: tokenId }),
revoke: (creator, tokenId) => publish(creator, { t: 'revoke', token_id: tokenId }),
/** Open streams of this creator's overlays, in every process. */
/** Open streams of this creator's overlays, in every process. */
- connected: (creator) => slots('count', creator),
+ connected: async (creator) => await slots('count', creator),
closeAll() { for (const entry of [...creators.values()]) for (const c of [...entry.clients]) detach(c); },
closeAll() { for (const entry of [...creators.values()]) for (const c of [...entry.clients]) detach(c); },
async close() { this.closeAll(); await ps.close(); },
async close() { this.closeAll(); await ps.close(); },
};
};
@@ -142,7 +142,7 @@ function createOverlays(ctx) {
@@ -142,7 +142,7 @@ function createOverlays(ctx) {
return ctx.tx(async (t) => {
return ctx.tx(async (t) => {
const c = await t.one(sql`SELECT * FROM overlay_configs WHERE id = ${config.id} FOR UPDATE`);
const c = await t.one(sql`SELECT * FROM overlay_configs WHERE id = ${config.id} FOR UPDATE`);
if (input.revision != null && Number(input.revision) !== c.revision) fail(409, 'tips.revision_conflict', `the config is at revision ${c.revision}`);
if (input.revision != null && Number(input.revision) !== c.revision) fail(409, 'tips.revision_conflict', `the config is at revision ${c.revision}`);
- return changeConfig(t, c, input);
+ return await changeConfig(t, c, input);
});
});
}
}
async function changeConfig(t, c, input) {
async function changeConfig(t, c, input) {
@@ -189,9 +189,9 @@ function createOverlays(ctx) {
@@ -189,9 +189,9 @@ function createOverlays(ctx) {
}
}
/** Inside the settlement transaction (or a simulation). `filter`: the creator's word filter. */
/** Inside the settlement transaction (or a simulation). `filter`: the creator's word filter. */
- function addAlert(t, interaction, filter) {
+ async function addAlert(t, interaction, filter) {
const payload = publicView(interaction, { at: iso(ctx.now()), filter });
const payload = publicView(interaction, { at: iso(ctx.now()), filter });
- return insertDelivery(t, interaction.creator_subject, 'alert', { interactionId: interaction.id, payload, test: interaction.test, dedupe: `alert:${interaction.id}` });
+ return await insertDelivery(t, interaction.creator_subject, 'alert', { interactionId: interaction.id, payload, test: interaction.test, dedupe: `alert:${interaction.id}` });
}
}
/**
/**
@@ -215,7 +215,7 @@ function createOverlays(ctx) {
@@ -215,7 +215,7 @@ function createOverlays(ctx) {
await t.exec(sql`UPDATE overlay_deliveries d SET payload = v.payload
await t.exec(sql`UPDATE overlay_deliveries d SET payload = v.payload
FROM jsonb_to_recordset(${sql.json(updates)}) AS v(seq bigint, payload jsonb) WHERE d.seq = v.seq`);
FROM jsonb_to_recordset(${sql.json(updates)}) AS v(seq bigint, payload jsonb) WHERE d.seq = v.seq`);
}
}
- const refreshInteraction = (t, interaction, filter) => refreshInteractions(t, [interaction], new Map([[interaction.creator_subject, filter]]));
+ const refreshInteraction = async (t, interaction, filter) => await refreshInteractions(t, [interaction], new Map([[interaction.creator_subject, filter]]));
/** Inside a transaction: a moderator hid the interaction. Its alert is never sent or replayed again. */
/** Inside a transaction: a moderator hid the interaction. Its alert is never sent or replayed again. */
async function retract(t, interaction, filter) {
async function retract(t, interaction, filter) {
@@ -230,8 +230,8 @@ function createOverlays(ctx) {
@@ -230,8 +230,8 @@ function createOverlays(ctx) {
await refreshInteraction(t, interaction, filter);
await refreshInteraction(t, interaction, filter);
}
}
- function addGoalDelivery(t, creator, goalView, { reason, interactionId, by, dedupe, test = false }) {
- return insertDelivery(t, creator, 'goal', { interactionId, goalId: goalView.id, payload: { goal: goalView, reason, by: by || null, test }, test, dedupe });
+ async function addGoalDelivery(t, creator, goalView, { reason, interactionId, by, dedupe, test = false }) {
+ return await insertDelivery(t, creator, 'goal', { interactionId, goalId: goalView.id, payload: { goal: goalView, reason, by: by || null, test }, test, dedupe });
}
}
/**
/**
@@ -65,7 +65,7 @@ function createProfiles(ctx) {
@@ -65,7 +65,7 @@ function createProfiles(ctx) {
const bySubject = async (q, s) => parse(await q.maybe(sql`SELECT * FROM creator_tip_profiles WHERE creator_subject = ${String(s || '')}`));
const bySubject = async (q, s) => parse(await q.maybe(sql`SELECT * FROM creator_tip_profiles WHERE creator_subject = ${String(s || '')}`));
const byHandle = async (q, h) => { const n = normalizeHandle(h); return n ? parse(await q.maybe(sql`SELECT * FROM creator_tip_profiles WHERE handle = ${n}`)) : null; };
const byHandle = async (q, h) => { const n = normalizeHandle(h); return n ? parse(await q.maybe(sql`SELECT * FROM creator_tip_profiles WHERE handle = ${n}`)) : null; };
/** usr_… or a handle (with or without @). */
/** usr_… or a handle (with or without @). */
- const resolve = (q, ref) => (/^usr_/.test(String(ref || '')) ? bySubject(q, String(ref)) : byHandle(q, ref));
+ const resolve = async (q, ref) => (/^usr_/.test(String(ref || '')) ? await bySubject(q, String(ref)) : await byHandle(q, ref));
/** A handle not held by another creator: the username, else username-<suffix of the subject>. */
/** A handle not held by another creator: the username, else username-<suffix of the subject>. */
async function freeHandle(q, subject, username) {
async function freeHandle(q, subject, username) {
@@ -81,7 +81,7 @@ function createProfiles(ctx) {
@@ -81,7 +81,7 @@ function createProfiles(ctx) {
*/
*/
async function ensure(subject, claims = {}, attempt = 0) {
async function ensure(subject, claims = {}, attempt = 0) {
try { return await ensureOnce(subject, claims); } catch (e) {
try { return await ensureOnce(subject, claims); } catch (e) {
- if (e.code === '23505' && e.constraint === HANDLE_TAKEN && attempt < 2) return ensure(subject, claims, attempt + 1);
+ if (e.code === '23505' && e.constraint === HANDLE_TAKEN && attempt < 2) return await ensure(subject, claims, attempt + 1);
throw e;
throw e;
}
}
}
}
@@ -93,7 +93,7 @@ function createProfiles(ctx) {
@@ -93,7 +93,7 @@ function createProfiles(ctx) {
const row = await db.maybe(sql`INSERT INTO creator_tip_profiles (creator_subject, handle, display_name, avatar_url, created_at, updated_at)
const row = await db.maybe(sql`INSERT INTO creator_tip_profiles (creator_subject, handle, display_name, avatar_url, created_at, updated_at)
VALUES (${subject}, ${await freeHandle(db, subject, username)}, ${name}, ${safeUrl(avatarUrl)}, ${at}, ${at})
VALUES (${subject}, ${await freeHandle(db, subject, username)}, ${name}, ${safeUrl(avatarUrl)}, ${at}, ${at})
ON CONFLICT (creator_subject) DO NOTHING RETURNING *`);
ON CONFLICT (creator_subject) DO NOTHING RETURNING *`);
- return row ? parse(row) : bySubject(db, subject);
+ return row ? parse(row) : await bySubject(db, subject);
}
}
const handle = normalizeHandle(username) && normalizeHandle(username) !== existing.handle ? await freeHandle(db, subject, username) : existing.handle;
const handle = normalizeHandle(username) && normalizeHandle(username) !== existing.handle ? await freeHandle(db, subject, username) : existing.handle;
if (handle !== existing.handle || name !== existing.display_name || (safeUrl(avatarUrl) || null) !== (existing.avatar_url || null)) {
if (handle !== existing.handle || name !== existing.display_name || (safeUrl(avatarUrl) || null) !== (existing.avatar_url || null)) {
@@ -119,7 +119,7 @@ function createProfiles(ctx) {
@@ -119,7 +119,7 @@ function createProfiles(ctx) {
const p = parse(await t.maybe(sql`SELECT * FROM creator_tip_profiles WHERE creator_subject = ${String(subject || '')} FOR UPDATE`));
const p = parse(await t.maybe(sql`SELECT * FROM creator_tip_profiles WHERE creator_subject = ${String(subject || '')} FOR UPDATE`));
if (!p) fail(404, 'tips.profile_not_found', 'no tip profile for this creator');
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}`);
if (expectedRevision != null && Number(expectedRevision) !== p.revision) fail(409, 'tips.revision_conflict', `the profile is at revision ${p.revision}`);
- return change(t, p, patch);
+ return await change(t, p, patch);
});
});
}
}
deleted file mode 100644
deleted file mode 100644
@@ -1,313 +0,0 @@
@@ -1,313 +0,0 @@
-'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.
- *
- * The snapshots are other services' SQLite files, read synchronously (better-sqlite3 handles passed in
- * by scripts/import-live.js); everything written goes to Tips' PostgreSQL in one SERIALIZABLE
- * transaction (it records settled money and applies refunds), whose report starts over on a retry.
- */
-const { sql } = require('openvibe-sdk/db');
-const { iso, prefixedId, json, storable } = 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;
-};
-// The snapshot (SQLite, read-only) side.
-const tableExists = (snap, name) => !!snap.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name);
-const columns = (snap, t) => (tableExists(snap, t) ? snap.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 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: {}, holds: [], excluded: [], unlinked: [], reconciliation: null,
- };
- const fresh = () => ({ imported: 0, held: 0, excluded: 0, refunds_applied: 0, external: 0, goals: 0, unchanged: 0, released: 0, linked_to_billing: 0, unlinked: 0 });
- Object.assign(report.counts, fresh());
-
- // ── 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');
- // Live's chat_messages carries its time in `timestamp` (older snapshots: created_at).
- const chatTs = chatCols.includes('created_at') ? 'created_at' : (chatCols.includes('timestamp') ? 'timestamp' : 'NULL');
- const chatDonations = chatCols.includes('metadata')
- ? live.prepare(`SELECT id, channel_user_id, username, message, metadata, ${chatTs} AS 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) ? storable(u.display_name || u.username) : null; };
- report.counts.identities = { resolved: [...ids].filter((i) => subjectOf(i)).length, unmapped: [...ids].filter((i) => !subjectOf(i)).length };
-
- let q = null; // the import's transaction
- const mapRow = (_source, table, id) => q.maybe(sql`SELECT * FROM migration_maps WHERE source = 'live' AND source_table = ${table} AND source_id = ${id}`);
- const record = (table, id, fields) => {
- const f = { target_type: null, target_id: null, reason: null, billing: null, ...fields };
- return q.exec(sql`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}, ${String(id)}, ${f.target_type}, ${f.target_id}, ${f.status}, ${f.reason}, ${f.billing}, ${runId}, ${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 run = async (t0) => {
- q = t0;
- // A retried attempt (serialization failure) reports from scratch.
- Object.assign(report.counts, fresh(), { live: report.counts.live, identities: report.counts.identities });
- report.holds = []; report.excluded = []; report.unlinked = [];
- // ── 2. donations ─────────────────────────────────────
- for (const t of txns.filter((x) => x.type === 'donation')) {
- const prev = await mapRow('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) {
- await q.exec(sql`UPDATE tip_interactions SET billing_txn_id = ${b} WHERE id = ${prev.target_id} AND billing_txn_id IS NULL`);
- await 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`;
- await 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`;
- await 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 ? storable(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 = await domain.interactions.insert(q, {
- 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, 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,
- });
- await q.exec(sql`UPDATE tip_interactions SET settled_at = ${created}, delivered_at = ${created} WHERE id = ${i.id}`);
- await 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 = await mapRow('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) { await 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;
- // The donations a refund may apply to, locked in id order (the domain's lock order).
- const candidates = creator ? (await q.many(sql`SELECT * FROM tip_interactions WHERE creator_subject = ${creator} AND settlement = 'imported'
- AND starts_with(legacy_source, 'live:transactions:') AND supporter_subject IS NOT DISTINCT FROM ${donor}::text AND created_at <= ${created}
- AND amount - reversed_bits > 0 ORDER BY id FOR UPDATE`)).sort((a, b) => (a.created_at < b.created_at ? 1 : a.created_at > b.created_at ? -1 : 0)) : [];
- // 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) {
- await 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 = [...(c.reversal_txn_ids || []), `live:transactions:${t.id}`];
- await q.exec(sql`UPDATE tip_interactions SET reversed_bits = reversed_bits + ${take}, payment_state = 'reversed', reversed_at = ${created},
- reversal_txn_ids = ${sql.json(ids)} WHERE id = ${c.id}`);
- applied.push({ interaction: c.id, bits: take });
- }
- await 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 = await mapRow('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);
- await record('chat_messages', c.id, { status: 'excluded', reason: `celebration of site-routed tip (Live transaction ${twin.id})` });
- report.counts.excluded++;
- continue;
- }
- if (amount <= 0) { await record('chat_messages', c.id, { status: 'excluded', reason: 'no amount' }); report.counts.excluded++; continue; }
- const creator = subjectOf(c.channel_user_id);
- if (!creator) {
- await 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 = await domain.interactions.insert(q, {
- id: prefixedId('tint', Date.parse(created)), creator_subject: creator, supporter_name: m.username ? storable(String(m.username).slice(0, 80)) : 'PowerChat supporter',
- kind: 'tip', amount, amount_cents: amount, message: m.message ? storable(String(m.message).slice(0, 500)) : null, funding: 'external', settlement: 'external',
- payment_state: 'settled', delivery_state: 'delivered', test: !!(resetAt && created < resetAt), provider: 'powerchat',
- provider_ref: `live-chat:${c.id}`, legacy_source: `live:chat_messages:${c.id}`, origin: 'import', created_at: created,
- });
- await q.exec(sql`UPDATE tip_interactions SET settled_at = ${created}, delivered_at = ${created} WHERE id = ${i.id}`);
- await 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 = await mapRow('live', 'donation_goals', String(g.id));
- if (prev && prev.status !== 'held') { report.counts.unchanged++; continue; }
- const creator = subjectOf(g.user_id);
- if (!creator) { await 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) { await 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));
- await q.exec(sql`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 (${id}, ${creator}, ${storable(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})`);
- await 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 = await reconcile(domain, billingSource, q); throw new DryRun(); }
- };
-
- // Serializable while the service writes too: a conflict retries the whole run (idempotent), up to 10 times.
- try { await domain.tx(run, { ...domain.MONEY, retries: 10 }); } catch (e) { if (!(e instanceof DryRun)) throw e; }
- if (!dryRun) report.reconciliation = await reconcile(domain, billingSource);
- report.finished_at = iso(domain.now());
- await domain.db.exec(sql`INSERT INTO import_runs (id, source, dry_run, started_at, finished_at, report)
- VALUES (${runId}, 'live', ${!!dryRun}, ${report.started_at}, ${report.finished_at}, ${sql.json(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. */
-async function reconcile(domain, billingSource, q = domain.db) {
- if (!billingSource) return null;
- const billing = billingSource.creatorTotals();
- const tips = new Map((await q.many(sql`SELECT creator_subject AS s, SUM(amount - reversed_bits)::bigint AS n FROM tip_interactions
- WHERE NOT test AND settlement IN ('billing', 'imported') AND payment_state IN ('settled', 'reversed') GROUP BY creator_subject`)).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 };
@@ -66,8 +66,8 @@ async function main() {
@@ -66,8 +66,8 @@ async function main() {
if (valkey) await valkey.close().catch(() => {});
if (valkey) await valkey.close().catch(() => {});
process.exit(0);
process.exit(0);
}
}
- process.on('SIGTERM', () => { shutdown('SIGTERM'); });
- process.on('SIGINT', () => { shutdown('SIGINT'); });
+ process.on('SIGTERM', async () => { await shutdown('SIGTERM'); });
+ process.on('SIGINT', async () => { await shutdown('SIGINT'); });
}
}
main().catch((e) => {
main().catch((e) => {
@@ -6,16 +6,11 @@
@@ -6,16 +6,11 @@
* keys the Network's RS256 public key (OV_NETWORK_PUBLIC_KEY, else GET /api/.well-known/jwks,
* 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
* 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.
* (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 crypto = require('crypto');
-const { serviceAuth } = require('openvibe-contracts');
const jwt = require('jsonwebtoken');
const jwt = require('jsonwebtoken');
const { OpenVibeAuthClient } = require('openvibe-shared/auth-client');
const { OpenVibeAuthClient } = require('openvibe-shared/auth-client');
-const BATCH = 500;
-
function createKeyProvider(config, { fetchImpl = globalThis.fetch, log = console } = {}) {
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 pem = config.network.publicKey ? crypto.createPublicKey(config.network.publicKey).export({ type: 'spki', format: 'pem' }) : null;
let timer = null;
let timer = null;
@@ -39,7 +34,7 @@ function createKeyProvider(config, { fetchImpl = globalThis.fetch, log = console
@@ -39,7 +34,7 @@ function createKeyProvider(config, { fetchImpl = globalThis.fetch, log = console
if (config.network.publicKey || timer) return;
if (config.network.publicKey || timer) return;
const retry = () => load().then((k) => { if (!k) setTimeout(retry, 30_000).unref(); });
const retry = () => load().then((k) => { if (!k) setTimeout(retry, 30_000).unref(); });
retry();
retry();
- timer = setInterval(() => { load(); }, 6 * 60 * 60 * 1000);
+ timer = setInterval(async () => { await load(); }, 6 * 60 * 60 * 1000);
timer.unref();
timer.unref();
}
}
function stop() { if (timer) clearInterval(timer); timer = null; }
function stop() { if (timer) clearInterval(timer); timer = null; }
@@ -74,47 +69,4 @@ function createUserAuth(config, keys) {
@@ -74,47 +69,4 @@ function createUserAuth(config, keys) {
return { client, verify };
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 };
+module.exports = { createKeyProvider, createUserAuth };
@@ -19,7 +19,7 @@ function fail(status, code, detail, extra) { throw new TipsError(status, code, d
@@ -19,7 +19,7 @@ function fail(status, code, detail, extra) { throw new TipsError(status, code, d
const prefixedId = (prefix, ms = Date.now()) => `${prefix}_${ids.ulid(ms)}`;
const prefixedId = (prefix, ms = Date.now()) => `${prefix}_${ids.ulid(ms)}`;
const iso = (ms) => new Date(ms).toISOString();
const iso = (ms) => new Date(ms).toISOString();
const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
-/** JSON text (a SQLite snapshot's column) → value, or `d`. PostgreSQL jsonb columns come back parsed. */
+/** JSON text → value, or `d`. PostgreSQL jsonb columns come back parsed. */
const json = (v, d) => { if (v == null || v === '') return d; try { return JSON.parse(v); } catch { return d; } };
const json = (v, d) => { if (v == null || v === '') return d; try { return JSON.parse(v); } catch { return d; } };
function positiveInt(v, field, max) {
function positiveInt(v, field, max) {
@@ -33,8 +33,8 @@ function positiveInt(v, field, max) {
@@ -33,8 +33,8 @@ function positiveInt(v, field, max) {
// from the creator's filter and turn text around on stream (docs/threat-review.md).
// from the creator's filter and turn text around on stream (docs/threat-review.md).
const INVISIBLE = /[\u00AD\u200B-\u200F\u202A-\u202E\u2060-\u2064\u2066-\u2069\uFEFF]/g;
const INVISIBLE = /[\u00AD\u200B-\u200F\u202A-\u202E\u2060-\u2064\u2066-\u2069\uFEFF]/g;
-// PostgreSQL stores no NUL character in text or jsonb, and jsonb refuses an unpaired surrogate (SQLite
-// took both): every text from outside goes through storable() before it reaches the database.
+// PostgreSQL stores no NUL character in text or jsonb, and jsonb refuses an unpaired surrogate:
+// every text from outside goes through storable() before it reaches the database.
const NUL = /\u0000/g;
const NUL = /\u0000/g;
/** A string PostgreSQL can store: NUL characters dropped, unpaired surrogates replaced by U+FFFD. */
/** A string PostgreSQL can store: NUL characters dropped, unpaired surrogates replaced by U+FFFD. */
const storable = (v) => String(v).toWellFormed().replace(NUL, '');
const storable = (v) => String(v).toWellFormed().replace(NUL, '');
@@ -112,11 +112,11 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
@@ -112,11 +112,11 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
}
}
r.get('/receipts/erase', withViewer, async (req, res) => {
r.get('/receipts/erase', withViewer, async (req, res) => {
if (!req.viewer) return res.redirect(`/auth/login?next=${encodeURIComponent('/receipts/erase')}`);
if (!req.viewer) return res.redirect(`/auth/login?next=${encodeURIComponent('/receipts/erase')}`);
- return renderErase(req, res);
+ return await renderErase(req, res);
});
});
r.post('/receipts/erase', withViewer, form, async (req, res) => {
r.post('/receipts/erase', withViewer, form, async (req, res) => {
if (!req.viewer) return res.redirect(303, `/auth/login?next=${encodeURIComponent('/receipts/erase')}`);
if (!req.viewer) return res.redirect(303, `/auth/login?next=${encodeURIComponent('/receipts/erase')}`);
- if (!csrfOk(req) || !bool(req.body.confirm)) return renderErase(req, res, 403);
+ if (!csrfOk(req) || !bool(req.body.confirm)) return await renderErase(req, res, 403);
await interactions.erase(req.viewer.subject);
await interactions.erase(req.viewer.subject);
return res.redirect(303, `/receipts?done=${encodeURIComponent('Your data was erased from your tips')}`);
return res.redirect(303, `/receipts?done=${encodeURIComponent('Your data was erased from your tips')}`);
});
});
@@ -163,21 +163,21 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
@@ -163,21 +163,21 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
r.get('/dashboard', withViewer, async (req, res) => {
r.get('/dashboard', withViewer, async (req, res) => {
if (!req.viewer) return res.redirect(`/auth/login?next=${encodeURIComponent('/dashboard')}`);
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.
// Only our own confirmations are shown: a crafted ?done= link cannot put words on the page.
- return renderDashboard(req, res, { flash: FLASH.has(String(req.query.done || '')) ? String(req.query.done) : null });
+ return await 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. */
/** A dashboard form action: sign-in + anti-forgery, errors re-render the dashboard. */
function action(path, fn) {
function action(path, fn) {
r.post(path, withViewer, form, async (req, res) => {
r.post(path, withViewer, form, async (req, res) => {
if (!req.viewer) return res.redirect(`/auth/login?next=${encodeURIComponent('/dashboard')}`);
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 });
+ if (!csrfOk(req)) return await renderDashboard(req, res, { error: 'That form expired. Please try again.', status: 403 });
try {
try {
const p = await ensureProfile(req);
const p = await ensureProfile(req);
const out = await fn(req, p);
const out = await fn(req, p);
if (out && out.render) return out.render(res);
if (out && out.render) return out.render(res);
return res.redirect(303, `/dashboard?done=${encodeURIComponent((out && out.done) || 'Saved')}`);
return res.redirect(303, `/dashboard?done=${encodeURIComponent((out && out.done) || 'Saved')}`);
} catch (e) {
} catch (e) {
- if (e instanceof TipsError) return renderDashboard(req, res, { error: e.detail || e.message, status: e.status >= 500 ? 500 : 400 });
+ if (e instanceof TipsError) return await renderDashboard(req, res, { error: e.detail || e.message, status: e.status >= 500 ? 500 : 400 });
throw e;
throw e;
}
}
});
});
@@ -335,19 +335,19 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
@@ -335,19 +335,19 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
if (!req.viewer) return res.redirect(`/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
if (!req.viewer) return res.redirect(`/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
const { p, actor } = await modAccess(req);
const { p, actor } = await modAccess(req);
if (!p || !actor) return notFound(req, res);
if (!p || !actor) return notFound(req, res);
- return renderModerate(req, res, p);
+ return await renderModerate(req, res, p);
});
});
r.post('/moderate/:handle/:id/:act(hide|restore)', withViewer, form, async (req, res) => {
r.post('/moderate/:handle/:id/:act(hide|restore)', withViewer, form, async (req, res) => {
if (!req.viewer) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/moderate/${req.params.handle}`)}`);
if (!req.viewer) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/moderate/${req.params.handle}`)}`);
const { p, actor } = await modAccess(req);
const { p, actor } = await modAccess(req);
if (!p || !actor) return notFound(req, res);
if (!p || !actor) return notFound(req, res);
- if (!csrfOk(req)) return renderModerate(req, res, p, { error: 'That form expired. Please try again.', status: 403 });
+ if (!csrfOk(req)) return await renderModerate(req, res, p, { error: 'That form expired. Please try again.', status: 403 });
const i = await interactions.get(db, req.params.id);
const i = await interactions.get(db, req.params.id);
if (!i || i.creator_subject !== p.creator_subject) return notFound(req, res);
if (!i || i.creator_subject !== p.creator_subject) return notFound(req, res);
try {
try {
await moderation[req.params.act](i, { ...actor, reason: req.body.reason });
await moderation[req.params.act](i, { ...actor, reason: req.body.reason });
} catch (e) {
} catch (e) {
- if (e instanceof TipsError) return renderModerate(req, res, p, { error: e.detail || e.message, status: 400 });
+ if (e instanceof TipsError) return await renderModerate(req, res, p, { error: e.detail || e.message, status: 400 });
throw e;
throw e;
}
}
return res.redirect(303, `/moderate/${encodeURIComponent(p.handle)}?done=${req.params.act === 'hide' ? 'Hidden' : 'Shown'}`);
return res.redirect(303, `/moderate/${encodeURIComponent(p.handle)}?done=${req.params.act === 'hide' ? 'Hidden' : 'Shown'}`);
@@ -371,7 +371,7 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
@@ -371,7 +371,7 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
body: pages.creatorPage({ profile: p, goals: list, viewer: req.viewer, csrf, idem: idem(), values, providers, error, ownerPreview: !p.page_enabled }),
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' });
}), 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', withViewer, async (req, res, next) => (profiles.normalizeHandle(req.params.handle) ? await creatorView(req, res) : next()));
r.get('/:handle/goals', withViewer, async (req, res, next) => {
r.get('/:handle/goals', withViewer, async (req, res, next) => {
const p = await profiles.byHandle(db, req.params.handle);
const p = await profiles.byHandle(db, req.params.handle);
if (!p) return next();
if (!p) return next();
@@ -407,7 +407,7 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
@@ -407,7 +407,7 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
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,
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, anonymous: bool(b.anonymous), hide_amount: bool(b.hide_amount), private_message: bool(b.private_message),
supporter_name: b.supporter_name, anonymous: bool(b.anonymous), hide_amount: bool(b.hide_amount), private_message: bool(b.private_message),
};
};
- if (!csrfOk(req)) return creatorView(req, res, { values, error: 'That form expired. Please send it again.', status: 403 });
+ if (!csrfOk(req)) return await creatorView(req, res, { values, error: 'That form expired. Please send it again.', status: 403 });
const owner = req.viewer.subject === p.creator_subject;
const owner = req.viewer.subject === p.creator_subject;
if (!p.page_enabled && !owner) return notFound(req, res);
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;
const key = /^form-[A-Za-z0-9_-]{8,40}$/.test(String(b.idem || '')) ? `form:${req.viewer.subject}:${b.idem}` : null;
@@ -426,7 +426,7 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
@@ -426,7 +426,7 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
const out = await interactions.transfer(i);
const out = await interactions.transfer(i);
if (out.refused) {
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}).`;
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 });
+ return await creatorView(req, res, { values, error: msg, status: 409 });
}
}
} else {
} else {
i = (await interactions.startCheckout(i, { provider: b.provider })).interaction;
i = (await interactions.startCheckout(i, { provider: b.provider })).interaction;
@@ -435,7 +435,7 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
@@ -435,7 +435,7 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
}
}
return res.redirect(303, `/receipts/${i.id}`);
return res.redirect(303, `/receipts/${i.id}`);
} catch (e) {
} catch (e) {
- if (e instanceof TipsError) return creatorView(req, res, { values, error: e.detail || e.message, status: e.status >= 500 ? 502 : 400 });
+ if (e instanceof TipsError) return await creatorView(req, res, { values, error: e.detail || e.message, status: e.status >= 500 ? 502 : 400 });
return next(e);
return next(e);
}
}
});
});
@@ -72,17 +72,15 @@ const { boot, check, done } = require('./helpers/app');
@@ -72,17 +72,15 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(totals.settled_via_billing, 0);
assert.strictEqual(totals.settled_via_billing, 0);
});
});
- await check('the goal the tip asked for: a Tips goal id, or a Live goal id through the import map', async () => {
+ await check('the goal the tip asked for: a Tips goal id', async () => {
const a = (await t.call('POST', '/api/v1/goals', { user: alex, body: { title: 'Desk', target_amount: 900 } })).json.goal;
const a = (await t.call('POST', '/api/v1/goals', { user: alex, body: { title: 'Desk', target_amount: 900 } })).json.goal;
const b = (await t.call('POST', '/api/v1/goals', { user: alex, body: { title: 'Chair', target_amount: 900 } })).json.goal;
const b = (await t.call('POST', '/api/v1/goals', { user: alex, body: { title: 'Chair', target_amount: 900 } })).json.goal;
await t.deliver(receipt({ provider_event_id: 'ext-goal-tips', amount_cents: 100, value_bits: 100, app_purpose: `goal:${a.id}` }));
await t.deliver(receipt({ provider_event_id: 'ext-goal-tips', amount_cents: 100, value_bits: 100, app_purpose: `goal:${a.id}` }));
- await t.db.exec(`INSERT INTO migration_maps (source, source_table, source_id, target_type, target_id, status, run_id, created_at, updated_at)
- VALUES ('live', 'donation_goals', '12', 'goal', $1, 'imported', 'imp_test', $2, $2)`, [b.id, new Date().toISOString()]);
- await t.deliver(receipt({ provider_event_id: 'ext-goal-live', amount_cents: 200, value_bits: 200, app_purpose: 'goal:12' }));
+ await t.deliver(receipt({ provider_event_id: 'ext-goal-other', amount_cents: 200, value_bits: 200, app_purpose: `goal:${b.id}` }));
await t.deliver(receipt({ provider_event_id: 'ext-goal-none', amount_cents: 300, value_bits: 300 }));
await t.deliver(receipt({ provider_event_id: 'ext-goal-none', amount_cents: 300, value_bits: 300 }));
assert.strictEqual((await domain.goals.present(t.db, await domain.goals.get(t.db, a.id))).current_amount, 100);
assert.strictEqual((await domain.goals.present(t.db, await domain.goals.get(t.db, a.id))).current_amount, 100);
assert.strictEqual((await domain.goals.present(t.db, await domain.goals.get(t.db, b.id))).current_amount, 200);
assert.strictEqual((await domain.goals.present(t.db, await domain.goals.get(t.db, b.id))).current_amount, 200);
- assert.strictEqual(await t.db.value('SELECT count(*) FROM tip_goal_contributions WHERE interaction_id = $1', [(await byRef('ext-goal-none')).id]), 0, 'several active goals and no pick: none (Live\'s rule)');
+ assert.strictEqual(await t.db.value('SELECT count(*) FROM tip_goal_contributions WHERE interaction_id = $1', [(await byRef('ext-goal-none')).id]), 0, 'several active goals and no pick: none');
});
});
await check('anonymous: no donor name; a creator with no tip page is still announced', async () => {
await check('anonymous: no donor name; a creator with no tip page is still announced', async () => {
deleted file mode 100644
deleted file mode 100644
@@ -1,301 +0,0 @@
@@ -1,301 +0,0 @@
-
-CREATE TABLE settings (
- id INTEGER PRIMARY KEY CHECK (id = 1),
- schema_version INTEGER NOT NULL,
- created_at TEXT NOT NULL
-);
-
-CREATE TABLE 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
-, page_settings TEXT NOT NULL DEFAULT '{}', filter_words TEXT NOT NULL DEFAULT '[]', filter_action TEXT NOT NULL DEFAULT 'mask', filter_links INTEGER NOT NULL DEFAULT 1);
-
-CREATE TABLE 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 | billing-external (announced by Billing, delivered here) | 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, anonymous INTEGER NOT NULL DEFAULT 0, hide_amount INTEGER NOT NULL DEFAULT 0, private_message INTEGER NOT NULL DEFAULT 0, erased_at TEXT, moderation TEXT NOT NULL DEFAULT 'visible', moderated_at TEXT, moderated_by TEXT, filtered INTEGER NOT NULL DEFAULT 0,
- UNIQUE (provider, provider_ref),
- UNIQUE (legacy_source)
-);
-
-CREATE TABLE 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 TABLE 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 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 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 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 TABLE 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 TABLE 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
-, hidden INTEGER NOT NULL DEFAULT 0);
-
-CREATE TABLE 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 TABLE 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 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 tip_moderators (
- creator_subject TEXT NOT NULL,
- moderator_subject TEXT NOT NULL,
- name TEXT, -- display name when they joined (for the creator's list)
- added_by TEXT NOT NULL, -- the creator, a service, or 'invite:<id>'
- created_at TEXT NOT NULL,
- removed_at TEXT,
- PRIMARY KEY (creator_subject, moderator_subject)
-);
-
-CREATE TABLE tip_moderator_invites (
- id TEXT PRIMARY KEY, -- tmin_<ULID>
- creator_subject TEXT NOT NULL,
- token_hash TEXT NOT NULL UNIQUE, -- sha256 of the link's secret (shown once)
- created_by TEXT NOT NULL,
- created_at TEXT NOT NULL,
- expires_at TEXT NOT NULL,
- used_at TEXT,
- used_by TEXT,
- revoked_at TEXT
-);
-
-CREATE TABLE tip_moderation_log (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- creator_subject TEXT NOT NULL,
- interaction_id TEXT NOT NULL,
- action TEXT NOT NULL CHECK (action IN ('held', 'filtered', 'hidden', 'restored')),
- by_role TEXT NOT NULL CHECK (by_role IN ('filter', 'creator', 'moderator', 'service')),
- actor TEXT, -- usr_… / svc:… ; null for the filter
- reason TEXT,
- created_at TEXT NOT NULL
-);
-
-CREATE TABLE 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
-);
-
-CREATE TABLE event_outbox (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- event_id TEXT NOT NULL UNIQUE,
- envelope TEXT NOT NULL,
- traceparent TEXT,
- created_at INTEGER NOT NULL,
- attempts INTEGER NOT NULL DEFAULT 0,
- next_attempt_at INTEGER NOT NULL DEFAULT 0,
- sent_at INTEGER,
- seq INTEGER,
- rejected_at INTEGER,
- last_error TEXT
- );
-
-CREATE TABLE idempotency_receipts (
- consumer TEXT NOT NULL,
- event_id TEXT NOT NULL,
- processed_at INTEGER NOT NULL,
- PRIMARY KEY (consumer, event_id)
- );
-
-CREATE INDEX idx_tint_creator ON tip_interactions (creator_subject, created_at);
-
-CREATE INDEX idx_tint_supporter ON tip_interactions (supporter_subject, created_at);
-
-CREATE INDEX idx_tint_pending ON tip_interactions (payment_state, funding);
-
-CREATE INDEX idx_goal_creator ON tip_goals (creator_subject, status);
-
-CREATE INDEX idx_ovc_creator ON overlay_configs (creator_subject);
-
-CREATE INDEX idx_ovt_creator ON overlay_tokens (creator_subject);
-
-CREATE INDEX idx_ovd_creator ON overlay_deliveries (creator_subject, seq);
-
-CREATE INDEX idx_ovd_pending ON overlay_deliveries (seq) WHERE status = 'pending';
-
-CREATE INDEX idx_effects_due ON interaction_effects (state, next_attempt_at);
-
-CREATE INDEX idx_tmod_moderator ON tip_moderators (moderator_subject);
-
-CREATE INDEX idx_tmodlog_creator ON tip_moderation_log (creator_subject, id);
-
-CREATE INDEX idx_event_outbox_due ON event_outbox(sent_at, rejected_at, next_attempt_at);
@@ -27,7 +27,8 @@ const valkeyAvailable = () => !!process.env.OV_TEST_VALKEY_URL;
@@ -27,7 +27,8 @@ const valkeyAvailable = () => !!process.env.OV_TEST_VALKEY_URL;
async function testDb({ store = process.env.TIPS_TEST_STORE || 'pglite', max = 4 } = {}) {
async function testDb({ store = process.env.TIPS_TEST_STORE || 'pglite', max = 4 } = {}) {
if (store !== 'pg') {
if (store !== 'pg') {
const db = createDb({ pglite: true, service: 'tips-test', log: quiet });
const db = createDb({ pglite: true, service: 'tips-test', log: quiet });
- await db.migrate({ dir: MIGRATIONS, log: quiet });
+ // windowDays 0: a test wants the final schema, not a contract held for its N-1 window.
+ await db.migrate({ dir: MIGRATIONS, log: quiet, windowDays: 0 });
return { db, store: 'pglite', close: () => db.close().catch(() => {}) };
return { db, store: 'pglite', close: () => db.close().catch(() => {}) };
}
}
if (!pgAvailable()) throw new Error('store pg needs OV_TEST_PG_URL and OV_TEST_PG_DIRECT_URL (openvibe-sdk scripts/test-services.sh up)');
if (!pgAvailable()) throw new Error('store pg needs OV_TEST_PG_URL and OV_TEST_PG_DIRECT_URL (openvibe-sdk scripts/test-services.sh up)');
@@ -62,7 +63,7 @@ async function testDb({ store = process.env.TIPS_TEST_STORE || 'pglite', max = 4
@@ -62,7 +63,7 @@ async function testDb({ store = process.env.TIPS_TEST_STORE || 'pglite', max = 4
let db;
let db;
try {
try {
const ownerDb = createDb({ url: as(process.env.OV_TEST_PG_DIRECT_URL, owner), service: 'tips-test-migrate', max: 1, log: quiet });
const ownerDb = createDb({ url: as(process.env.OV_TEST_PG_DIRECT_URL, owner), service: 'tips-test-migrate', max: 1, log: quiet });
- try { await ownerDb.migrate({ dir: MIGRATIONS, log: quiet }); } finally { await ownerDb.close(); }
+ try { await ownerDb.migrate({ dir: MIGRATIONS, log: quiet, windowDays: 0 }); } finally { await ownerDb.close(); }
db = createDb({ url: as(process.env.OV_TEST_PG_URL, name), service: 'tips-test', max, log: quiet });
db = createDb({ url: as(process.env.OV_TEST_PG_URL, name), service: 'tips-test', max, log: quiet });
} catch (e) { await drop().catch(() => {}); throw e; } // a failed setup leaves nothing behind
} catch (e) { await drop().catch(() => {}); throw e; } // a failed setup leaves nothing behind
return {
return {
deleted file mode 100644
deleted file mode 100644
@@ -1,191 +0,0 @@
@@ -1,191 +0,0 @@
-'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, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP); -- Live's column name
- 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, timestamp) 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(await t.db.value('SELECT count(*) FROM tip_interactions'), 0);
- assert.strictEqual(await t.db.value('SELECT count(*) FROM migration_maps'), 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.maybe('SELECT * FROM tip_interactions WHERE legacy_source = $1', [`live:transactions:${id}`]);
- const two = await 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((await byLegacy(1)).test, true, 'before stats_vibes_reset_at');
- const ghost = await byLegacy(3);
- assert.strictEqual(ghost.supporter_subject, null);
- assert.strictEqual(ghost.supporter_name, 'Ghost');
- assert.strictEqual((await byLegacy(6)).kind, 'media_request');
- assert.strictEqual((await byLegacy(8)).funding, 'provider');
- assert.strictEqual(await byLegacy(4), null, 'held, not dropped');
- const held = await t.db.maybe("SELECT * FROM migration_maps WHERE source_table = 'transactions' AND source_id = '4'");
- assert.strictEqual(held.status, 'held');
- const excluded = await t.db.maybe("SELECT * FROM migration_maps WHERE source_table = 'transactions' AND source_id = '5'");
- 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(await t.db.value('SELECT count(*) FROM interaction_effects'), 0);
- assert.strictEqual(await t.db.value('SELECT count(*) FROM overlay_deliveries'), 0);
- assert.strictEqual((await t.outboxRows('tips.interaction.ready')).length, 0);
- });
-
- await check('the refund nets out against the donation it undoes', async () => {
- const six = await t.db.maybe("SELECT * FROM tip_interactions WHERE legacy_source = 'live:transactions:6'");
- 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 = await t.db.maybe("SELECT * FROM tip_interactions WHERE legacy_source = 'live:chat_messages:2'");
- assert.strictEqual(ext.settlement, 'external');
- assert.strictEqual(ext.amount, 700);
- assert.strictEqual(ext.supporter_name, 'Direct');
- const twin = await t.db.maybe("SELECT * FROM migration_maps WHERE source_table = 'chat_messages' AND source_id = '1'");
- assert.strictEqual(twin.status, 'excluded');
- assert.match(twin.reason, /site-routed tip \(Live transaction 8\)/);
- assert.strictEqual(await t.db.value("SELECT count(*) FROM migration_maps WHERE source_table = 'chat_messages' AND source_id = '3'"), 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 = await t.db.maybe("SELECT * FROM tip_goals WHERE legacy_source = 'live:donation_goals:1'");
- const view = await domain.goals.present(t.db, 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 = await reconcile(domain, billingSnapshotSource(bdb));
- assert.strictEqual(r.ok, true, JSON.stringify(r.mismatches));
- assert.strictEqual((await domain.interactions.totals(t.db, subjects[1])).settled_via_billing, 740);
- assert.strictEqual((await domain.interactions.totals(t.db, subjects[1])).external, 700);
- });
-
- await check('a re-run changes nothing; a newly mapped creator is released from hold', async () => {
- const before = await t.db.value('SELECT count(*) FROM tip_interactions');
- const again = await run();
- assert.strictEqual(again.counts.imported, 0);
- assert.strictEqual(again.counts.goals, 0);
- assert.ok(again.counts.unchanged > 0);
- assert.strictEqual(await t.db.value('SELECT count(*) FROM tip_interactions'), 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 = await t.db.maybe("SELECT * FROM tip_interactions WHERE legacy_source = 'live:transactions:4'");
- assert.strictEqual(four.creator_subject, heldUser.subject);
- assert.strictEqual(four.billing_txn_id, 'txn_import_4');
- assert.strictEqual(await t.db.value("SELECT status FROM migration_maps WHERE source_table = 'transactions' AND source_id = '4'"), '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((await t.db.many("SELECT source_id FROM migration_maps WHERE source_table = 'transactions'")).map((x) => x.source_id));
- assert.deepStrictEqual(tipRows.filter((id) => !mapped.has(id)), []);
- const excluded = await t.db.many("SELECT * FROM migration_maps WHERE status = 'excluded'");
- assert.ok(excluded.every((x) => x.reason));
- });
-
- live.close(); bdb.close();
- await t.close();
- done();
-})().catch((e) => { console.error(e); process.exit(1); });
@@ -1,18 +1,15 @@
@@ -1,18 +1,15 @@
'use strict';
'use strict';
// migrations/ (ADR-035, ADR-028): the files parse with their phase, apply in order, and apply again
// migrations/ (ADR-035, ADR-028): the files parse with their phase, apply in order, and apply again
-// without change; the schema carries every column the SQLite release had (nothing lost in the import);
-// every query shape the code runs can be served by an index; and no runtime file loads better-sqlite3.
+// without change; a contract migration drops what an earlier expand left behind; and every query shape
+// the code runs can be served by an index.
const assert = require('assert');
const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
-const Database = require('better-sqlite3');
const { createDb, sql } = require('openvibe-sdk/db');
const { createDb, sql } = require('openvibe-sdk/db');
const { MIGRATIONS } = require('../server/db');
const { MIGRATIONS } = require('../server/db');
-const { TABLES } = require('../scripts/migrate-to-postgres');
const { check, done } = require('./helpers/app');
const { check, done } = require('./helpers/app');
const quiet = { log() {} };
const quiet = { log() {} };
-const ROOT = path.join(__dirname, '..');
/** The migration files as the SDK reads them: NNNN_name.sql with a phase header (and after: for a contract). */
/** The migration files as the SDK reads them: NNNN_name.sql with a phase header (and after: for a contract). */
function parse(dir) {
function parse(dir) {
@@ -38,17 +35,24 @@ function parse(dir) {
@@ -38,17 +35,24 @@ function parse(dir) {
});
});
await check('they apply once, in order, and a second run changes nothing', async () => {
await check('they apply once, in order, and a second run changes nothing', async () => {
- const first = await db.migrate({ dir: MIGRATIONS, log: quiet });
+ const first = await db.migrate({ dir: MIGRATIONS, log: quiet, windowDays: 0 });
assert.deepStrictEqual(first.applied.map((m) => m.id), parse(MIGRATIONS).map((m) => m.id));
assert.deepStrictEqual(first.applied.map((m) => m.id), parse(MIGRATIONS).map((m) => m.id));
const schema = async () => db.many(sql`SELECT table_name, column_name, data_type, is_nullable, column_default FROM information_schema.columns
const schema = async () => db.many(sql`SELECT table_name, column_name, data_type, is_nullable, column_default FROM information_schema.columns
WHERE table_schema = 'public' ORDER BY table_name, ordinal_position`);
WHERE table_schema = 'public' ORDER BY table_name, ordinal_position`);
const before = await schema();
const before = await schema();
- const second = await db.migrate({ dir: MIGRATIONS, log: quiet });
+ const second = await db.migrate({ dir: MIGRATIONS, log: quiet, windowDays: 0 });
assert.deepStrictEqual([second.applied, second.held], [[], []]);
assert.deepStrictEqual([second.applied, second.held], [[], []]);
assert.deepStrictEqual(await schema(), before, 'the schema is the same after a second run');
assert.deepStrictEqual(await schema(), before, 'the schema is the same after a second run');
assert.strictEqual(await db.value(sql`SELECT count(*) FROM ov_migrations`), parse(MIGRATIONS).length);
assert.strictEqual(await db.value(sql`SELECT count(*) FROM ov_migrations`), parse(MIGRATIONS).length);
});
});
+ await check('the contract migration drops the legacy import columns and the map table', async () => {
+ assert.deepStrictEqual(await db.many(sql`SELECT table_name FROM information_schema.columns
+ WHERE table_schema = 'public' AND column_name = 'legacy_source'`), []);
+ assert.deepStrictEqual(await db.many(sql`SELECT table_name FROM information_schema.tables
+ WHERE table_schema = 'public' AND table_name = 'migration_maps'`), []);
+ });
+
await check('every table has a primary key; money and time columns have their types', async () => {
await check('every table has a primary key; money and time columns have their types', async () => {
const noKey = await db.many(sql`SELECT c.relname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
const noKey = await db.many(sql`SELECT c.relname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND c.relkind = 'r' AND NOT EXISTS (SELECT 1 FROM pg_index i WHERE i.indrelid = c.oid AND i.indisprimary)`);
WHERE n.nspname = 'public' AND c.relkind = 'r' AND NOT EXISTS (SELECT 1 FROM pg_index i WHERE i.indrelid = c.oid AND i.indisprimary)`);
@@ -69,27 +73,6 @@ function parse(dir) {
@@ -69,27 +73,6 @@ function parse(dir) {
assert.strictEqual(await type('api_idempotency', 'response'), 'text', 'replayed byte for byte');
assert.strictEqual(await type('api_idempotency', 'response'), 'text', 'replayed byte for byte');
});
});
- await check('nothing the SQLite release stored is left behind: every source table and column has a target', async () => {
- const s = new Database(':memory:');
- s.exec(fs.readFileSync(path.join(__dirname, 'fixtures', 'sqlite-v3-schema.sql'), 'utf8'));
- const from = (target) => (TABLES[target] && TABLES[target].from) || target;
- const targets = new Map();
- for (const r of await db.many(sql`SELECT table_name, column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name <> 'ov_migrations'`)) {
- const src = from(r.table_name);
- if (!targets.has(src)) targets.set(src, new Set());
- targets.get(src).add(r.column_name);
- }
- const missing = [];
- for (const { name } of s.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all()) {
- if (name === 'settings') continue; // not carried on purpose: ov_migrations records the schema (0001_initial.sql)
- if (!targets.has(name)) { missing.push(name); continue; }
- for (const c of s.prepare(`PRAGMA table_info(${name})`).all()) if (!targets.get(name).has(c.name)) missing.push(`${name}.${c.name}`);
- }
- s.close();
- assert.deepStrictEqual(missing, [], 'no table or column dropped (dropColumns stays empty)');
- assert.ok(Object.values(TABLES).every((o) => !o.dropColumns), 'no dropColumns');
- });
-
await check('every query shape the code runs can be served by an index (no sequential scan of its table)', async () => {
await check('every query shape the code runs can be served by an index (no sequential scan of its table)', async () => {
// With sequential scans switched off for the plan, a shape that has no usable index still plans a
// With sequential scans switched off for the plan, a shape that has no usable index still plans a
// Seq Scan (at a prohibitive cost): that is what this looks for.
// Seq Scan (at a prohibitive cost): that is what this looks for.
@@ -125,7 +108,6 @@ function parse(dir) {
@@ -125,7 +108,6 @@ function parse(dir) {
'the weekly prune of stored answers': ['api_idempotency', sql`DELETE FROM api_idempotency WHERE created_at < now()`],
'the weekly prune of stored answers': ['api_idempotency', sql`DELETE FROM api_idempotency WHERE created_at < now()`],
'an erasure scrubbing outbox copies': ['tips_event_outbox', sql`SELECT id FROM tips_event_outbox WHERE envelope->'subject'->>'type' = 'interaction' AND envelope->'subject'->>'id' = ANY('{a}'::text[])`],
'an erasure scrubbing outbox copies': ['tips_event_outbox', sql`SELECT id FROM tips_event_outbox WHERE envelope->'subject'->>'type' = 'interaction' AND envelope->'subject'->>'id' = ANY('{a}'::text[])`],
'the rejected-events count': ['tips_event_outbox', sql`SELECT count(*) FROM tips_event_outbox WHERE rejected_at IS NOT NULL`],
'the rejected-events count': ['tips_event_outbox', sql`SELECT count(*) FROM tips_event_outbox WHERE rejected_at IS NOT NULL`],
- 'a Live goal through the import map': ['migration_maps', sql`SELECT target_id FROM migration_maps WHERE source = 'live' AND source_table = 'donation_goals' AND source_id = '12'`],
};
};
const scans = [];
const scans = [];
for (const [name, [table, q]] of Object.entries(shapes)) {
for (const [name, [table, q]] of Object.entries(shapes)) {
@@ -141,21 +123,6 @@ function parse(dir) {
@@ -141,21 +123,6 @@ function parse(dir) {
assert.deepStrictEqual(scans, []);
assert.deepStrictEqual(scans, []);
});
});
- await check('no runtime file loads better-sqlite3 (only the import tools may)', async () => {
- const offenders = [];
- const walk = (dir) => {
- for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
- const p = path.join(dir, e.name);
- if (e.isDirectory()) walk(p);
- else if (e.name.endsWith('.js') && /require\(['"]better-sqlite3['"]\)|\.prepare\(|db\.transaction\(/.test(fs.readFileSync(p, 'utf8').replace(/(live|bdb|snap)\.prepare\(/g, ''))) offenders.push(path.relative(ROOT, p));
- }
- };
- walk(path.join(ROOT, 'server'));
- assert.deepStrictEqual(offenders, []);
- const loaders = fs.readdirSync(path.join(ROOT, 'scripts')).filter((f) => /require\(['"]better-sqlite3['"]\)/.test(fs.readFileSync(path.join(ROOT, 'scripts', f), 'utf8')));
- assert.deepStrictEqual(loaders, ['import-live.js'], "the Live import reads Live's and Billing's snapshot files; migrate-to-postgres.js reads Tips' through importSqlite");
- });
-
await db.close();
await db.close();
done();
done();
})().catch((e) => { console.error(e); process.exit(1); });
})().catch((e) => { console.error(e); process.exit(1); });
deleted file mode 100644
deleted file mode 100644
@@ -1,189 +0,0 @@
@@ -1,189 +0,0 @@
-'use strict';
-// The one-time import (ADR-035; scripts/migrate-to-postgres.js): a SQLite file with the last SQLite
-// release's schema (test/fixtures/sqlite-v3-schema.sql) and rows of every table goes through
-// openvibe-sdk's importSqlite into the migrated schema: report.ok, every row counted and checksummed,
-// values converted by column type (0/1 → boolean, ISO text → timestamptz, JSON text → jsonb), identities
-// kept and continued, the SQLite file untouched; and the service then serves the imported data.
-const assert = require('assert');
-const crypto = require('crypto');
-const fs = require('fs');
-const os = require('os');
-const path = require('path');
-const { execFileSync } = require('child_process');
-const Database = require('better-sqlite3');
-const { ids } = require('openvibe-contracts');
-const { importSqlite } = require('openvibe-sdk/db');
-const { testDb } = require('./helpers/db');
-const { boot, check, done } = require('./helpers/app');
-const { importOptions } = require('../scripts/migrate-to-postgres');
-
-const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'tips-import-'));
-const quiet = { log() {} };
-const creator = ids.newId('user');
-const supporter = ids.newId('user');
-const T = (s) => `2026-09-${s}Z`; // an ISO time, as the SQLite release wrote them
-
-/** A SQLite file as the SQLite release left it: its schema and a few rows in every table. */
-function fixture(file) {
- const s = new Database(file);
- s.exec(fs.readFileSync(path.join(__dirname, 'fixtures', 'sqlite-v3-schema.sql'), 'utf8'));
- const ins = (table, row) => s.prepare(`INSERT INTO ${table} (${Object.keys(row).join(', ')}) VALUES (${Object.keys(row).map(() => '?').join(', ')})`).run(...Object.values(row));
- ins('settings', { id: 1, schema_version: 3, created_at: T('01T00:00:00.000') });
- ins('creator_tip_profiles', {
- creator_subject: creator, handle: 'alex', display_name: 'Alex', page_enabled: 1, accepting: 1, tts_enabled: 0, media_requests_enabled: 1,
- revision: 4, created_at: T('01T10:00:00.000'), updated_at: T('02T11:30:00.250'),
- page_settings: '{"goal_amounts":false,"supporters_page":true}', filter_words: '["badword","two words"]', filter_action: 'hold', filter_links: 0,
- });
- const tip = (id, fields) => ins('tip_interactions', {
- id, creator_subject: creator, supporter_subject: supporter, supporter_name: 'Viewer', kind: 'tip', amount: 250, funding: 'credit', settlement: 'billing',
- payment_state: 'settled', delivery_state: 'delivered', created_at: T('03T12:00:00.000'), updated_at: T('03T12:00:01.000'), settled_at: T('03T12:00:00.500'), ...fields,
- });
- tip('tint_01', { billing_txn_id: 'txn_1', request: '{"goal_id":"tgoal_01"}', target: '{"service":"live","type":"stream","id":"42"}', message: 'great stream' });
- // Two things SQLite took and PostgreSQL does not: a NUL in text, an unpaired surrogate inside JSON.
- tip('tint_02', { message: 'nul\u0000here', request: '{"legacy_message":"cut \\ud83d","n":1}', billing_txn_id: 'txn_2', payment_state: 'reversed', reversal_txn_ids: '["txn_9"]', reversed_bits: 100, reversed_at: T('04T09:00:00.000'), anonymous: 1, hide_amount: 1, test: 0 });
- tip('tint_03', { kind: 'paid_message', amount: 500, message: 'hello chat', funding: 'checkout', payment_state: 'pending', delivery_state: 'awaiting_payment',
- settled_at: null, billing_intent_id: 'pi_1', checkout_url: 'https://checkout.example/pi_1', idempotency_key: 'api:usr:k1', transfer_due: 1, transfer_attempts: 2, next_transfer_at: 1790600000123 });
- tip('tint_04', { supporter_subject: null, supporter_name: null, erased_at: T('05T08:00:00.000'), moderation: 'hidden', moderated_by: 'filter', filtered: 1, test: 1, origin: 'import',
- settlement: 'imported', legacy_source: 'live:transactions:7', provider: 'powerchat', provider_ref: 'live-chat:3', request: '{}' });
- ins('tip_goals', { id: 'tgoal_01', creator_subject: creator, title: 'Desk', target_amount: 1000, status: 'active', opening_amount: 50, created_at: T('02T00:00:00.000'), updated_at: T('02T00:00:00.000'), reached_at: null });
- ins('tip_goal_contributions', { id: 7, goal_id: 'tgoal_01', interaction_id: 'tint_01', amount: 250, reversed_amount: 0, created_at: T('03T12:00:00.600'), updated_at: T('03T12:00:00.600') });
- ins('tip_goal_contributions', { id: 12, goal_id: 'tgoal_01', interaction_id: 'tint_02', amount: 250, reversed_amount: 100, created_at: T('03T12:00:00.700'), updated_at: T('04T09:00:00.000') });
- ins('paid_messages', { id: 'tpm_01', interaction_id: 'tint_01', creator_subject: creator, kind: 'paid_message', text: 'hello', highlight_seconds: 30, status: 'delivered', chat_ref: 'null', test: 0, created_at: T('03T12:00:01.000'), updated_at: T('03T12:00:02.000') });
- ins('paid_media_requests', { id: 'tmr_01', interaction_id: 'tint_02', creator_subject: creator, url: 'https://youtu.be/x', provider: 'youtube', status: 'accepted', queue_ref: '{"queue_id":5}', created_at: T('03T12:00:01.000'), updated_at: T('03T12:00:02.000') });
- ins('overlay_configs', { id: 'tovc_01', creator_subject: creator, kind: 'alerts', name: 'Alerts', settings: '{"min_amount":1,"sound_url":null}', revision: 2, created_at: T('02T00:00:00.000'), updated_at: T('02T00:00:00.000') });
- ins('overlay_tokens', { id: 'tovt_01', creator_subject: creator, token_hash: 'a'.repeat(64), scopes: '["alerts","goals"]', config_id: 'tovc_01', created_by: creator, created_at: T('02T00:00:00.000'), revoked_at: T('06T00:00:00.000') });
- ins('overlay_deliveries', { seq: 3, id: 'tovd_03', creator_subject: creator, kind: 'alert', interaction_id: 'tint_01', payload: '{"interaction_id":"tint_01","amount":250}', status: 'delivered', sends: 4, dedupe_key: 'alert:tint_01', created_at: T('03T12:00:01.000'), delivered_at: T('03T12:00:01.500'), hidden: 0 });
- ins('overlay_deliveries', { seq: 9, id: 'tovd_09', creator_subject: creator, kind: 'goal', goal_id: 'tgoal_01', payload: '{"goal":{"id":"tgoal_01"},"by":null}', status: 'pending', dedupe_key: 'goal:tgoal_01:r2', created_at: T('03T12:00:01.000'), hidden: 1 });
- ins('interaction_effects', { id: 4, interaction_id: 'tint_01', effect: 'chat_line', adapter: 'live-chat', state: 'delivered', attempts: 1, result: '{"ref":{"chat_message_id":1}}', created_at: T('03T12:00:01.000'), updated_at: T('03T12:00:02.000') });
- ins('interaction_effects', { id: 5, interaction_id: 'tint_01', effect: 'overlay_alert', adapter: 'overlay', state: 'delivered', result: 'null', created_at: T('03T12:00:01.000'), updated_at: T('03T12:00:01.000') });
- ins('interaction_effects', { id: 6, interaction_id: 'tint_03', effect: 'paid_message', adapter: 'live-chat', state: 'queued', attempts: 2, next_attempt_at: 1790600000999, last_error: 'Live 503', created_at: T('03T12:00:01.000'), updated_at: T('03T12:00:01.000') });
- ins('migration_maps', { id: 2, source: 'live', source_table: 'transactions', source_id: '7', target_type: 'interaction', target_id: 'tint_04', status: 'imported', run_id: 'timp_1', created_at: T('01T00:00:00.000'), updated_at: T('01T00:00:00.000') });
- ins('import_runs', { id: 'timp_1', source: 'live', dry_run: 0, started_at: T('01T00:00:00.000'), finished_at: T('01T00:00:05.000'), report: '{"counts":{"imported":1},"holds":[]}' });
- ins('tip_moderators', { creator_subject: creator, moderator_subject: supporter, name: 'Mod', added_by: 'invite:tmin_1', created_at: T('02T00:00:00.000') });
- ins('tip_moderator_invites', { id: 'tmin_1', creator_subject: creator, token_hash: 'b'.repeat(64), created_by: creator, created_at: T('02T00:00:00.000'), expires_at: T('09T00:00:00.000'), used_at: T('02T01:00:00.000'), used_by: supporter });
- ins('tip_moderation_log', { id: 3, creator_subject: creator, interaction_id: 'tint_04', action: 'hidden', by_role: 'moderator', actor: supporter, reason: 'abuse', created_at: T('05T08:00:00.000') });
- // A stored answer is replayed byte for byte: its text keeps its key order and spacing.
- ins('api_idempotency', { key: `${supporter}:tip-key-0001`, request_hash: 'h1', method: 'POST', path: '/api/v1/checkout', status: 201, response: '{"z":1,"a":{"b":2}}', created_at: T('03T12:00:00.000') });
- ins('event_outbox', { id: 1, event_id: 'evt_01', envelope: '{"event_type":"tips.interaction.ready","subject":{"type":"interaction","id":"tint_01"},"payload":{"supporter":null}}', created_at: 1790500000000, attempts: 1, sent_at: 1790500001000, seq: 17 });
- ins('event_outbox', { id: 2, event_id: 'evt_02', envelope: '{"event_type":"tips.goal.updated","subject":{"type":"goal","id":"tgoal_01"},"payload":{}}', created_at: 1790500002000, attempts: 3, next_attempt_at: 1790500999000, last_error: 'timeout' });
- ins('idempotency_receipts', { consumer: 'tips-billing', event_id: 'evt_billing_1', processed_at: 1790500000500 });
- s.close();
- return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
-}
-
-(async () => {
- const file = path.join(tmp, 'tips.db');
- const hash = fixture(file);
-
- const imported = await testDb({ store: 'pglite' });
- const db = imported.db;
- let report;
- const options = await importOptions(db);
- const TABLES = options.tables;
- await check('the importer copies every table, verified by counts and checksums (report.ok)', async () => {
- report = await importSqlite({ sqlite: file, db, truncate: true, tables: TABLES, log: quiet });
- assert.strictEqual(report.ok, true, JSON.stringify(report.problems));
- const rows = Object.fromEntries(report.tables.map((t) => [t.table, t.rows]));
- assert.deepStrictEqual(rows, {
- creator_tip_profiles: 1, tip_interactions: 4, tip_goals: 1, tip_goal_contributions: 2, paid_messages: 1, paid_media_requests: 1,
- overlay_configs: 1, overlay_tokens: 1, overlay_deliveries: 2, interaction_effects: 3, migration_maps: 1, import_runs: 1,
- tip_moderators: 1, tip_moderator_invites: 1, tip_moderation_log: 1, api_idempotency: 1, tips_event_outbox: 2, tips_event_inbox: 1,
- });
- assert.ok(report.tables.every((t) => /^[0-9a-f]{16}$/.test(t.checksum)), 'a checksum per table');
- const renamed = report.tables.filter((t) => t.source !== t.table).map((t) => `${t.source} → ${t.table}`).sort();
- assert.deepStrictEqual(renamed, ['event_outbox → tips_event_outbox', 'idempotency_receipts → tips_event_inbox']);
- assert.strictEqual(crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'), hash, 'the SQLite file is unchanged');
- });
-
- await check('values are converted by column type', async () => {
- const p = await db.one('SELECT * FROM creator_tip_profiles');
- assert.deepStrictEqual([p.page_enabled, p.tts_enabled, p.filter_links, p.revision], [true, false, false, 4]);
- assert.deepStrictEqual(p.page_settings, { goal_amounts: false, supporters_page: true });
- assert.deepStrictEqual(p.filter_words, ['badword', 'two words']);
- assert.strictEqual(p.updated_at, T('02T11:30:00.250'), 'the same ISO time the API sent before');
- const i = await db.one("SELECT * FROM tip_interactions WHERE id = 'tint_01'");
- assert.deepStrictEqual(i.request, { goal_id: 'tgoal_01' });
- assert.deepStrictEqual(i.target, { service: 'live', type: 'stream', id: '42' });
- assert.deepStrictEqual([i.test, i.anonymous, i.transfer_due, i.amount], [false, false, false, 250]);
- assert.deepStrictEqual(i.reversal_txn_ids, []);
- const r = await db.one("SELECT * FROM tip_interactions WHERE id = 'tint_02'");
- assert.deepStrictEqual([r.anonymous, r.hide_amount, r.reversed_bits, r.reversal_txn_ids], [true, true, 100, ['txn_9']]);
- assert.strictEqual(r.message, 'nulhere', 'a NUL is dropped (PostgreSQL text cannot hold it)');
- assert.deepStrictEqual(r.request, { legacy_message: 'cut \ufffd', n: 1 }, 'an unpaired surrogate becomes U+FFFD (jsonb refuses it)');
- assert.deepStrictEqual(options.cleaned().sort((a, b) => a.column.localeCompare(b.column)),
- [{ column: 'tip_interactions.message', values: 1 }, { column: 'tip_interactions.request', values: 1 }], 'and the report says where');
- const pending = await db.one("SELECT * FROM tip_interactions WHERE id = 'tint_03'");
- assert.deepStrictEqual([pending.transfer_due, pending.transfer_attempts, pending.next_transfer_at, pending.settled_at], [true, 2, 1790600000123, null]);
- const erased = await db.one("SELECT * FROM tip_interactions WHERE id = 'tint_04'");
- assert.deepStrictEqual([erased.test, erased.filtered, erased.erased_at, erased.target], [true, true, T('05T08:00:00.000'), null]);
- assert.strictEqual(await db.value("SELECT chat_ref FROM paid_messages WHERE id = 'tpm_01'"), null, "JSON text 'null' → NULL (no reference)");
- assert.deepStrictEqual(await db.value("SELECT queue_ref FROM paid_media_requests WHERE id = 'tmr_01'"), { queue_id: 5 });
- assert.deepStrictEqual((await db.many('SELECT id, result FROM interaction_effects ORDER BY id')).map((e) => [e.id, e.result]),
- [[4, { ref: { chat_message_id: 1 } }], [5, null], [6, null]]);
- assert.deepStrictEqual(await db.value("SELECT scopes FROM overlay_tokens"), ['alerts', 'goals']);
- assert.deepStrictEqual((await db.many('SELECT seq, hidden, test FROM overlay_deliveries ORDER BY seq')).map((d) => [d.seq, d.hidden, d.test]), [[3, false, false], [9, true, false]]);
- assert.strictEqual(await db.value('SELECT response FROM api_idempotency'), '{"z":1,"a":{"b":2}}', 'a stored answer stays byte for byte');
- assert.strictEqual(await db.value('SELECT dry_run FROM import_runs'), false);
- const out = await db.many('SELECT id, envelope, sent_at, next_attempt_at FROM tips_event_outbox ORDER BY id');
- assert.deepStrictEqual(out.map((o) => [o.id, o.envelope.event_type, o.sent_at, o.next_attempt_at]), [[1, 'tips.interaction.ready', 1790500001000, 0], [2, 'tips.goal.updated', null, 1790500999000]]);
- assert.deepStrictEqual(await db.one('SELECT * FROM tips_event_inbox'), { consumer: 'tips-billing', event_id: 'evt_billing_1', processed_at: 1790500000500 });
- });
-
- await check('identities keep their values and continue after the imported maximum', async () => {
- const at = new Date().toISOString();
- assert.strictEqual(await db.value(`INSERT INTO overlay_deliveries (id, creator_subject, kind, payload, status, dedupe_key, created_at)
- VALUES ('tovd_new', $1, 'alert', '{}', 'pending', 'alert:new', $2) RETURNING seq`, [creator, at]), 10, 'the SSE ids go on from 9');
- assert.strictEqual(await db.value(`INSERT INTO interaction_effects (interaction_id, effect, adapter, state, created_at, updated_at)
- VALUES ('tint_02', 'chat_line', 'test', 'queued', $1, $1) RETURNING id`, [at]), 7);
- assert.strictEqual(await db.value(`INSERT INTO tip_goal_contributions (goal_id, interaction_id, amount, created_at, updated_at)
- VALUES ('tgoal_01', 'tint_03', 5, $1, $1) RETURNING id`, [at]), 13);
- assert.strictEqual(await db.value("INSERT INTO tips_event_outbox (event_id, envelope, created_at) VALUES ('evt_03', '{}', 1) RETURNING id"), 3);
- });
-
- await check('a rehearsal can be repeated (truncate), and JSON null verifies without a mapping', async () => {
- const again = await importSqlite({ sqlite: file, db, truncate: true, tables: TABLES, log: quiet });
- assert.strictEqual(again.ok, true, JSON.stringify(again.problems));
- // openvibe-sdk ≥ 0.18 counts JSON null and SQL NULL as equal in verification, so the import is verified
- // even without Tips' own 'null' mapping (which stays: it stores SQL NULL, not a JSON null).
- const plain = await importSqlite({ sqlite: file, db, truncate: true, tables: { ...TABLES, paid_messages: {} }, only: ['paid_messages', 'tip_interactions', 'creator_tip_profiles'], log: quiet });
- assert.strictEqual(plain.ok, true, JSON.stringify(plain.problems));
- await importSqlite({ sqlite: file, db, truncate: true, tables: TABLES, log: quiet });
- });
-
- await check('the service serves the imported data as it did from SQLite', async () => {
- const t = await boot({ db });
- const who = { subject: supporter, username: 'viewer' };
- const r = await t.call('GET', '/api/v1/interactions', { user: who });
- assert.strictEqual(r.status, 200, r.text);
- const byId = Object.fromEntries(r.json.interactions.map((i) => [i.id, i]));
- assert.deepStrictEqual(Object.keys(byId).sort(), ['tint_01', 'tint_02', 'tint_03']);
- assert.strictEqual(byId.tint_01.created_at, T('03T12:00:00.000'));
- assert.strictEqual(byId.tint_01.payment.settled_at, T('03T12:00:00.500'));
- assert.deepStrictEqual(byId.tint_02.payment.reversal_txn_ids, ['txn_9']);
- assert.strictEqual(byId.tint_02.public.supporter_name, 'Anonymous');
- const goal = (await t.call('GET', '/api/v1/goals/tgoal_01', { user: { subject: creator, username: 'alex' } })).json.goal;
- assert.strictEqual(goal.current_amount, 50 + 250 + 150, 'opening amount plus what the contributions keep');
- const replay = await t.call('POST', '/api/v1/checkout', { user: who, key: 'tip-key-0001', body: {} });
- assert.strictEqual(replay.status, 422, 'a different request under a stored key is refused');
- assert.strictEqual(await t.db.value('SELECT count(*) FROM tips_event_inbox'), 1);
- await t.close();
- });
-
- await check('scripts/migrate-to-postgres.js: OK exits 0; a column the schema lacks is a problem and exits 1', async () => {
- const out = execFileSync(process.execPath, [path.join(__dirname, '..', 'scripts', 'migrate-to-postgres.js'), '--pglite', '--sqlite', file], { encoding: 'utf8' });
- assert.match(out, /→ pglite \(\d+ ms\): OK/);
- assert.match(out, /tips_event_outbox\s+2 rows\s+[0-9a-f]{16}\s+\(from event_outbox\)/);
- assert.match(out, /cleaned: tip_interactions\.message: 1 value\(s\) with a NUL or an unpaired surrogate/);
- const extra = path.join(tmp, 'extra.db');
- fs.copyFileSync(file, extra);
- const s = new Database(extra); s.exec('ALTER TABLE tip_goals ADD COLUMN mystery TEXT'); s.close();
- let code = 0; let text = '';
- try { execFileSync(process.execPath, [path.join(__dirname, '..', 'scripts', 'migrate-to-postgres.js'), '--pglite', '--sqlite', extra], { encoding: 'utf8' }); } catch (e) { code = e.status; text = e.stdout; }
- assert.strictEqual(code, 1);
- assert.match(text, /problem: tip_goals: source columns with no target column .*mystery/);
- });
-
- await imported.close();
- fs.rmSync(tmp, { recursive: true, force: true });
- done();
-})().catch((e) => { console.error(e); process.exit(1); });