openvibers/openvibe.billing Commits

Docs and deploy for the staff console: README section, cutover runbook (the console replaces Live's cashout admin; Network redirect URI for client billing), .env.example (BILLING_STAFF_SUBJECTS, BILLING_SESSION_SECRET), nginx (console proxied, /api/v1 denied on the public host, /webhooks/* public), STATUS, manifest proposal adds billing.staff.action

OP OpenVibers committed 23 Sept 2026 · 8af6e69ae6 · 6 files, +130 −27


Changes

ddiff --git a/.env.example b/.env.example
iindex 74db595..27cfa81 100644
---- a/.env.example
++++ b/.env.example
@@@ -3,7 +3,8 @@
# Copy to .env for development. In production this lives at /etc/openvibe/billing.env (0600).
# ═══════════════════════════════════════════════════════════════
--# Server (a service API; other services call http://127.0.0.1:4600)
++# Server: the service API (other services call http://127.0.0.1:4600/api/v1 — never through nginx),
++# provider webhooks and the staff console (https://billing.openvibe.network/).
PORT=4600
HOST=127.0.0.1
NODE_ENV=development
@@@ -23,11 +24,25 @@ OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
# Audience Billing accepts on incoming service tokens.
BILLING_AUDIENCE=openvibe.billing
# Billing's own client credentials (client `billing` registered in the Network) — used for
--# resolve-batch (identity.subject.resolve, audience openvibe.network) and the events relay
--# (events.event.publish, audience openvibe.events).
++# resolve-batch (identity.subject.resolve, audience openvibe.network), the events relay
++# (events.event.publish, audience openvibe.events) and the staff console's SSO code exchange.
OV_OAUTH_CLIENT_ID=billing
OV_OAUTH_CLIENT_SECRET=change-me
++# ── Staff console (Network SSO; see README "Staff console") ──
++# The Network must list ${BASE_URL}/auth/callback (https://billing.openvibe.network/auth/callback)
++# as a redirect URI of OAuth client `billing`.
++# Comma-separated usr_ subject ids allowed to use the console. Each must ALSO be a Network admin
++# (role admin in their Network token). Empty = nobody can sign in.
++BILLING_STAFF_SUBJECTS=
++# Signs the sign-in flow cookie and keys the IP hashes in staff_audit: at least 32 random characters
++# (e.g. `openssl rand -hex 32`). Required in production — the console answers 503 without it.
++BILLING_SESSION_SECRET=
++# Absolute console session lifetime in minutes (5–720).
++#BILLING_SESSION_TTL_MIN=60
++# Audience a Network user token must carry.
++#BILLING_SSO_AUDIENCE=openvibe.network
++
# ── Money rates (Live's current values are the defaults) ────
#BILLING_BITS_PER_USD=100
#BILLING_PRICE_TIERS=[{"min":25000,"usd_per_bit":0.011},{"min":10000,"usd_per_bit":0.0115},{"min":5000,"usd_per_bit":0.012},{"min":2500,"usd_per_bit":0.0124},{"min":1000,"usd_per_bit":0.013},{"min":500,"usd_per_bit":0.014},{"min":0,"usd_per_bit":0.015}]
ddiff --git a/README.md b/README.md
iindex 74667d2..df8d75d 100644
---- a/README.md
++++ b/README.md
@@@ -4,7 +4,8 @@
**Status:** alpha — runtime built and tested (Wave 8); OpenVibe.Live stays authoritative until the
cutover below is run.
--**Domain:** `billing.openvibe.network` (service API; only `/webhooks/*` and `/api/health` are public)
++**Domain:** `billing.openvibe.network` — the staff console (Network SSO, staff only), `/webhooks/*` and
++`/api/health`; the service API `/api/v1` is reachable only on loopback (`127.0.0.1:4600`)
**Decision:** [ADR-012](https://github.com/OpenVibers/OpenVibe.Contracts/blob/main/docs/adr/ADR-012-economic-classification.md) —
economic classification. The survey behind it is [docs/economic-inventory.md](docs/economic-inventory.md).
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §11.1, §11.4, §11.5.
@@@ -30,7 +31,7 @@ webhooks land here and nowhere else.
fnm exec --using=22.22.1 npm install
cp .env.example .env # set OV_OAUTH_CLIENT_SECRET, POWERCHAT_WEBHOOK_SECRET, …
npm run dev # http://localhost:4600
--npm test # 9 test files: stub Network/Events/providers, temp DBs, random ports
++npm test # 10 test files: stub Network/Events/providers, temp DBs, random ports
npm run reconcile # reconciliation report (exit 1 on failure)
npm run freeze -- on "reason" # economy freeze from the host (status | on "<reason>" | off)
node scripts/import-live.js --live-db <snapshot> [--dry-run] [--json]
@@@ -69,7 +70,8 @@ Production: `/opt/openvibe.billing`, env `/etc/openvibe/billing.env`, unit
## API
--All `/api/v1` calls need a service token from OpenVibe.Network (audience `openvibe.billing`). Every
++All `/api/v1` calls need a service token from OpenVibe.Network (audience `openvibe.billing`) and are
++made on loopback (`http://127.0.0.1:4600`); nginx refuses `/api/v1` on the public host. Every
POST needs an `Idempotency-Key` (8–200 of `A-Za-z0-9._:-`); a replay returns the original response
(`Idempotent-Replayed: true`), the same key with another body is `422 idempotency.key_reused`, refusals
are not stored. Errors are RFC 9457 problem+json. People are SubjectRefs `{ "type": "user", "id": "usr_…" }`
@@@ -107,6 +109,58 @@ manifest in [docs/service-manifest-proposal.json](docs/service-manifest-proposal
ship in an openvibe-contracts release, grants are matched locally (exact id or a `.*` family such as
`billing.*`) with contracts' own `capabilities.grants()`.
++## Staff console
++
++Server-rendered pages at the root of `https://billing.openvibe.network` for the people who decide
++payouts — the replacement for Live's cashout admin, which answers 409 once Live runs with
++`BILLING_AUTHORITY=billing` ([docs/live-cutover.md](docs/live-cutover.md)). No JavaScript, no external
++resources, `noindex`, `Cache-Control: no-store`, a strict CSP (`default-src 'none'`, `form-action 'self'`,
++`frame-ancestors 'none'`). Code: [server/console/](server/console/); tests: `test/console.test.js`.
++
++**Sign-in:** OpenVibe.Network SSO, authorization code with PKCE (S256), as OAuth client `billing`
++(redirect `https://billing.openvibe.network/auth/callback`, which the Network must list for that
++client). The code is exchanged server to server on `OV_NETWORK_INTERNAL_URL`; the access token is
++verified offline with the Network key Billing already loads (issuer, audience `openvibe.network`,
++expiry). Billing keeps none of the Network's tokens — the refresh token it is handed is revoked at once.
++
++**Who:** a person whose Network token says `role: "admin"` **and** whose subject (`subject_id`, a
++`usr_…`) is listed in `BILLING_STAFF_SUBJECTS`. Everyone else gets 403 and no session; the refusal is
++recorded in the audit log. The list is re-checked on every request: removing someone ends their session
++on their next click.
++
++**Session:** cookie `__Host-ovb_staff` — random 256-bit id (the database keeps only its SHA-256),
++host-only, `Path=/`, `HttpOnly`, `Secure`, `SameSite=Strict`, absolute lifetime `BILLING_SESSION_TTL_MIN`
++(60 min). The OAuth state + PKCE verifier travel in a 10-minute `ovb_flow` cookie (`Path=/auth`,
++`SameSite=Lax` for the return from openvibe.network) signed with `BILLING_SESSION_SECRET`. Every form
++carries the session's CSRF token (constant-time compare) and cross-site `Origin` / `Sec-Fetch-Site`
++POSTs are refused. Sign-out is a POST.
++
++**What staff may do** is expressed in the API's own capability ids: a staff session is the principal
++`{ sub: <usr_…>, cap: [billing.cashout.manage, billing.ledger.admin] }` and every page names the one
++capability it needs. Every action calls the same functions the API does — `ops/cashouts.approve|deny`,
++`ops/admin.setFreeze` (+ processing the held webhooks on unfreeze), `reconcile()`,
++`providers.reprocess()` — behind the same freeze guard; there is no second implementation.
++
++| Page | Needs | Does |
++|---|---|---|
++| `/` | staff | freeze state, last reconciliation, outstanding credit / payable / pending payouts (test transactions excluded), queue counts |
++| `/cashouts?tab=escrow\|ready\|paid\|denied` | `billing.cashout.manage` | the queue with escrow dates (Billing has no separate "approved" state: approving records a payout already made, so it is **paid**) |
++| `/cashouts/:id` | `billing.cashout.manage` | detail; **approve** needs the provider's payout reference, a payout provider and a ticked confirmation, and is refused before the escrow ends; **deny** needs a reason and returns the amount to the creator's payable. A resubmitted form replays (per-form action key → the ops idempotency key), never pays twice |
++| `/receipts` | `billing.ledger.admin` | provider receipts flagged for review (unattributed site tips, underpaid deliveries, held site-routed tips), rejected ones (incl. deliveries for orders Live already credited — `billing.intent_settled_in_live`), stored-but-unprocessed ones, reversals flagged `review: required`; reprocess an unprocessed/rejected one. Shown by id and outcome only — never the provider payload |
++| `/import-holds` | `billing.ledger.admin` | unmapped Live users from the importer |
++| `/reconciliation`, `/reconciliation/:id` | `billing.ledger.admin` | run + history; a run shows checks, offender counts, warning counts and totals |
++| `/freeze` | `billing.ledger.admin` | freeze / unfreeze with a mandatory reason |
++| `/audit` | `billing.ledger.admin` | the staff audit log |
++
++**Audit:** every staff action — sign-in, sign-out, approve, deny, freeze, unfreeze, reconciliation run,
++reprocess — and every refused attempt (non-staff sign-in, CSRF failure, a refused approval) is one row
++in the append-only `staff_audit` table (actor subject and username, action, target, reason, outcome,
++a small detail such as the payout reference or refusal code, request id, and an HMAC of the client IP
++keyed by `BILLING_SESSION_SECRET`). Done actions are also written to the outbox as
++`billing.staff.action` (visibility `internal`, actor = the staff subject), in the same SQLite
++transaction as their effect. Adjustments are not in the console; they stay on the API
++(`POST /api/v1/admin/adjustments`).
++
## Providers
| Adapter | Enabled when | Settles | Reverses |
@@@ -124,16 +178,19 @@ Reversing a subscription payment revokes the periods it granted.
## Operations
--- **Reconciliation** (`npm run reconcile`, or `GET /api/v1/admin/reconcile`) checks: journal zero-sum
++- **Reconciliation** (`npm run reconcile`, `GET /api/v1/admin/reconcile`, or the console's
++ Reconciliation page) checks: journal zero-sum
per currency, per-transaction balance, cached balances = entry sums, fx mirror, every settled
provider event has exactly one transaction, paid cashouts have payout references, payouts_pending =
requested cashouts. It also lists negative balances, unprocessed/rejected events, items for review
and import holds; totals exclude test transactions. Runs are stored in `reconciliation_runs`.
--- **Freeze** before any risky change: `POST /api/v1/admin/freeze {"on": true, "reason": "…"}`, or on the
++- **Freeze** before any risky change: the console's Freeze page, `POST /api/v1/admin/freeze {"on": true, "reason": "…"}`, or on the
host `node scripts/freeze.js on "<reason>"` (same switch; after `off` the running service processes the
held webhooks on its next retry tick).
- **Review queue**: chargebacks on donated credit and unattributed site tips appear under
-- `warnings`; resolve them with an adjustment (`relates_to` = the transaction).
++ `warnings` and on the console's Receipts page; resolve them with an adjustment (`relates_to` = the transaction).
++- **Payouts** are decided in the staff console (or with `billing.cashout.manage` on the API): pay at the
++ provider, then approve with its payout reference once the escrow has ended.
- **Backups**: `sqlite3 /var/lib/openvibe-billing/billing.db ".backup billing-$(date +%F).db"`.
## Cutover runbook (Live → Billing)
@@@ -161,7 +218,8 @@ Billing did itself.
The domain keeps its placeholder page on
[OpenVibers/OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites) until everything in plan §12.12
holds: owning runtime with health/readiness ✔; canonical identity and scoped service principals ✔;
--server-rendered public routes useful without JavaScript (not yet — Billing has no public pages);
++server-rendered public routes useful without JavaScript (not yet — the only pages are the staff-only
++console);
real persistence and end-to-end workflows ✔; capability/event registration against
OpenVibe.Contracts (proposed, not released); migration strategy ✔ with a security review, sitemap/robots
still to do; acceptance tests ✔. A placeholder is never counted as an implemented service.
ddiff --git a/STATUS.json b/STATUS.json
iindex c073f5c..15e5178 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -6,6 +6,6 @@
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"adr": "ADR-012",
"authoritative": false,
-- "notes": "Runtime, journal, adapters, import and reconciliation are built and tested. The Live client is a patch (docs/live-patch.diff, switch BILLING_AUTHORITY off by default) with its cutover sequence in docs/live-cutover.md; neither is applied or executed, so Live stays authoritative.",
++ "notes": "Runtime, journal, adapters, import, reconciliation and the staff console (Network SSO + PKCE, admins listed in BILLING_STAFF_SUBJECTS; cashout approve/deny, review queue, reconciliation, freeze, audit log) are built and tested. The console needs the Network to add https://billing.openvibe.network/auth/callback to client `billing` before anyone can sign in. The Live client is a patch (docs/live-patch.diff, switch BILLING_AUTHORITY off by default) with its cutover sequence in docs/live-cutover.md; the cutover has not been executed, so Live stays authoritative.",
"updated": "2026-09-22"
}
ddiff --git a/deploy/nginx/billing.openvibe.network.conf b/deploy/nginx/billing.openvibe.network.conf
iindex cc7a5d8..ebf3ea1 100644
---- a/deploy/nginx/billing.openvibe.network.conf
++++ b/deploy/nginx/billing.openvibe.network.conf
@@@ -3,16 +3,17 @@
##
## Deploy to /etc/nginx/sites-available/billing.openvibe.network.conf and reload nginx.
##
--## The Node app (port 4600, systemd unit openvibe-billing) is a service API. Other OpenVibe
--## services call it on http://127.0.0.1:4600 directly; the public host exposes only what must be
--## reachable from the internet:
++## The Node app (port 4600, systemd unit openvibe-billing). Other OpenVibe services call the
++## service API on http://127.0.0.1:4600/api/v1 directly; the public host exposes:
## /webhooks/<provider> payment provider deliveries (PowerChat today)
## /api/health liveness for uptime checks
--## /api/v1/* is not published here (service tokens protect it anyway; keeping it off the public
--## edge removes the attack surface). Everything else answers 404.
++## /, /auth/*, … the staff console (Network SSO, staff only; server-rendered, noindex)
++## /api/v1 is DENIED here: it stays reachable only from loopback (service tokens protect it anyway;
++## keeping it off the public edge removes the attack surface). Every other /api/* path is 404.
## ═══════════════════════════════════════════════════════════════
limit_req_zone $binary_remote_addr zone=ovbilling_hooks:10m rate=20r/s;
++limit_req_zone $binary_remote_addr zone=ovbilling_console:10m rate=5r/s;
server {
listen 80;
@@@ -39,6 +40,7 @@ server {
client_max_body_size 1m;
add_header X-Content-Type-Options nosniff always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
++ add_header X-Robots-Tag "noindex, nofollow" always;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@@@ -55,5 +57,13 @@ server {
location = /api/health { proxy_pass http://127.0.0.1:4600; }
-- location / { return 404; }
++ # The service API: loopback only. Never proxied from the public host.
++ location ^~ /api/v1 { deny all; }
++ location ^~ /api/ { return 404; }
++
++ # Staff console (the app sets CSP, X-Frame-Options, no-store and noindex on every page).
++ location / {
++ limit_req zone=ovbilling_console burst=30 nodelay;
++ proxy_pass http://127.0.0.1:4600;
++ }
}
ddiff --git a/docs/live-cutover.md b/docs/live-cutover.md
iindex cb1370f..0a84d5a 100644
---- a/docs/live-cutover.md
++++ b/docs/live-cutover.md
@@@ -17,7 +17,7 @@ default**: applied and deployed with `BILLING_AUTHORITY` unset, Live behaves exa
| Donation / tip | Live columns + `transactions` | `POST /api/v1/transfers` (kind `donation`, target = the Live stream) |
| Vibes-paid media request / its refund | donation on Live columns / manual unwind | `POST /api/v1/transfers` (kind `paid_interaction`) / `POST /api/v1/transfers/:id/refund` |
| Cashout request / recycle | Live columns | `POST /api/v1/cashouts` / `POST /api/v1/recycle` |
--| Cashout approve / deny / pending list (owner) | Live | **409** — decided in Billing (`billing.cashout.manage`, payout reference required; ADR-012 rule 10) |
++| Cashout approve / deny / pending list (owner) | Live | **409** — decided in Billing's **staff console** (`https://billing.openvibe.network/cashouts`; payout reference required, escrow enforced; ADR-012 rule 10) |
| Subscribe with Vibes | Live columns + `subscriptions` | entitlement check, then `POST /api/v1/subscriptions` (source `credit`) |
| Subscribe via PowerChat / Stripe | Live order + link | `POST /api/v1/intents` (kind subscription, route `site`/`direct`) |
| Cancel, "my subscriptions", channel "subscribed?" + count | Live `subscriptions` | Billing subscriptions/entitlements (cancel keeps the paid period) |
@@@ -78,9 +78,24 @@ default**: applied and deployed with `BILLING_AUTHORITY` unset, Live behaves exa
| live | identity.subject.resolve | openvibe.network (already used by Live's paste client) |
Live is deliberately **not** granted `billing.cashout.manage` or `billing.ledger.admin`.
--3. **Live** has the patch applied and deployed with `BILLING_AUTHORITY` unset (no behaviour change;
++ Those are held only by people, through Billing's staff console (below).
++3. **Staff console** (it replaces Live's cashout admin — without it nobody can approve a payout after
++ the switch):
++ - **Network:** OAuth client `billing` (today a service client with no redirect URI) gets the redirect
++ URI `https://billing.openvibe.network/auth/callback` — add
++ `{ client_id: 'billing', name: 'OpenVibe.Billing', redirect_uris: ['https://billing.openvibe.network/auth/callback'] }`
++ to the seeded `contractClients` in OpenVibe.Network `server/db/database.js` (it only adds the URI to the
++ existing client) and deploy the Network. The Network already supports PKCE S256.
++ - **`/etc/openvibe/billing.env`:** `BILLING_STAFF_SUBJECTS` = the `usr_…` subjects of the people who
++ decide payouts (each must also have role `admin` on the Network); `BILLING_SESSION_SECRET` = 32+
++ random characters (`openssl rand -hex 32`). Restart Billing.
++ - **nginx:** install the updated [deploy/nginx/billing.openvibe.network.conf](../deploy/nginx/billing.openvibe.network.conf)
++ (console proxied, `/api/v1` denied on the public host, `/webhooks/*` public) and reload.
++ - **Check:** sign in at `https://billing.openvibe.network/`, open Cashouts, run a reconciliation from the
++ dashboard; the Audit log shows the sign-in and the run. Someone not listed gets 403.
++4. **Live** has the patch applied and deployed with `BILLING_AUTHORITY` unset (no behaviour change;
`GET /api/admin/money` answers `authority: "live"`).
--4. **Identity:** Live's daily legacy-map sync (`identity-legacy-sync`) has run since the last
++5. **Identity:** Live's daily legacy-map sync (`identity-legacy-sync`) has run since the last
sign-ups (restart Live or wait a day), and Live's log has no `[Identity] live user N is mapped to …`
conflict for a user who holds money. A shadow import against a fresh snapshot shows **no import
holds** for users with a non-zero balance (a held user's Vibes sit on `hold:live:<id>` and would read
@@@ -143,7 +158,9 @@ Live owner calls are made from the browser console on openvibe.live, signed in a
credited twice. `billing 'node scripts/reconcile.js'` → OK; read its `rejected events` line.
8. **Verify the reads** (Live still frozen): as the owner, `api('/funds/balance')` equals your Billing
balance and your frozen Live column; spot-check a streamer's `cashout_balance` the same way.
-- `api('/admin/money')` → `billing.actions.attention: []`.
++ `api('/admin/money')` → `billing.actions.attention: []`. In the staff console, Cashouts lists the
++ imported open cashouts (pending, with their escrow dates) and Receipts lists any held delivery Billing
++ rejected as already credited by Live.
9. **Unfreeze Live**: `api('/admin/money/freeze', { method: 'POST', body: { on: false } })`.
10. **Verify with a small real PowerChat purchase**: Buy Vibes on openvibe.live with PowerChat (100 Vibes).
After the tip: one new settled `powerchat` provider event in Billing, the balance rises by 100 once,
@@@ -197,9 +214,11 @@ Said plainly, for whoever runs this and for Wave 9 (Tips) / Wave 10 (VIP):
notification to the buyer, no sub alert forwarded to the streamer's PowerChat overlay for
PowerChat-paid subscriptions (Vibes-paid ones still get it), no celebration for legacy site-routed
`pcdon:` tips.
--3. **Cashout decisions leave Live's admin:** approve, deny and the pending list answer 409; approving
-- needs a principal with `billing.cashout.manage` calling Billing's API with the PayPal payout
-- reference. Billing has no UI for it.
++3. **Cashout decisions leave Live's admin:** approve, deny and the pending list answer 409 on Live.
++ They move to Billing's staff console (`https://billing.openvibe.network/cashouts`, Network admins listed
++ in `BILLING_STAFF_SUBJECTS`): approving needs the provider payout reference and is refused before the
++ escrow ends; denying needs a reason; each decision is in the staff audit log. Live's 409 message still
++ points at "its operator API" — updating that wording to name the console is a Live-side change.
4. **Displays that read Live's `transactions` table freeze at the cutover:** the stream donation
leaderboard, stream recap tip totals, VOD tip counts and the home "Vibes tipped"/"supporters" stats
stop counting new tips (admin money totals are blanked rather than shown stale).
ddiff --git a/docs/service-manifest-proposal.json b/docs/service-manifest-proposal.json
iindex c90360d..9fab69c 100644
---- a/docs/service-manifest-proposal.json
++++ b/docs/service-manifest-proposal.json
@@@ -1,5 +1,5 @@
{
-- "version": "0.1.0",
++ "version": "0.3.0",
"contractRanges": {
"openvibe-contracts": ">=0.5.1 <1.0.0"
},
@@@ -30,11 +30,12 @@
"billing.subscription.canceled",
"billing.cashout.requested",
"billing.cashout.paid",
-- "billing.cashout.denied"
++ "billing.cashout.denied",
++ "billing.staff.action"
],
"eventsConsumed": [],
"namespacesOwned": [
"billing.*"
],
-- "notes": "The isolated money ledger (ADR-012): balanced append-only journal, provider receipts, subscriptions/entitlements, refunds and payouts. Proposed manifest; replaces the placeholder in manifests/services/billing.json."
++ "notes": "The isolated money ledger (ADR-012): balanced append-only journal, provider receipts, subscriptions/entitlements, refunds and payouts, and a staff console (Network SSO, staff only) for payouts, review, reconciliation and the freeze. Proposed manifest: adds billing.staff.action (internal) to the released 0.2.0."
}